Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/content/docs/(deployment)/roadmap.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ If a future channel adapter requires inbound HTTP callbacks (e.g. WhatsApp Busin

Some agent frameworks ship a `doctor` CLI command that checks daemon heartbeats, scheduler health, and channel connectivity. This usually exists to compensate for a fragile runtime — if you need a separate command to tell you your process is dead, the process management is the problem.

Spacebot handles this through its control interface and the cortex. The embedded UI shows adapter status, agent health, and active connections in real-time. The cortex observes system-wide signals and surfaces issues proactively. Channel token validation happens at setup time through the bindings API — if something is misconfigured, you find out immediately when you add the binding, not by running `doctor` ten minutes later.
Spacebot handles this through its control interface and the cortex. The embedded UI now keeps setup and runtime readiness visible in the normal workflow: the shell banner and Overview page call out missing providers, secrets problems, warmup drift, and MCP connectivity issues, while **Settings → System Health** shows per-agent warmup state and enabled MCP server status. The cortex still observes system-wide signals and surfaces issues proactively. Channel token validation happens at setup time through the bindings API — if something is misconfigured, you find out immediately when you add the binding, not by running `doctor` ten minutes later.

## Post-Launch

Expand Down
4 changes: 4 additions & 0 deletions docs/content/docs/(getting-started)/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ This is enough. Spacebot will create a default `main` agent with sensible defaul

Just run `spacebot` with no config file and no API key env var set. It will walk you through provider selection, API key entry, agent naming, and optional Discord setup.

After first launch, the web UI keeps the remaining setup work visible. The Overview page shows a setup readiness checklist, and **Settings → System Health** shows live warmup and MCP connection state so you can fix runtime issues from the control interface instead of guessing.

### Option C: Config file

Create `~/.spacebot/config.toml`:
Expand Down Expand Up @@ -227,6 +229,8 @@ cd interface && bun run dev
# UI at http://localhost:3000, proxies /api to http://localhost:19898
```

If the instance is only partially configured, the UI surfaces that immediately with a setup banner on the shell, a fuller checklist on the Overview page, and runtime details in **Settings → System Health**.

## Messaging platforms

| Platform | Status | Setup guide |
Expand Down
7 changes: 7 additions & 0 deletions interface/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions interface/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.18",
"bun-types": "^1.3.10",
"postcss": "^8.4.36",
"sass": "^1.72.0",
"tailwindcss": "^3.4.1",
Expand Down
87 changes: 87 additions & 0 deletions interface/src/api/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {afterEach, beforeEach, describe, expect, mock, test} from "bun:test";

if (!("window" in globalThis)) {
(globalThis as typeof globalThis & {window: Window & typeof globalThis}).window =
globalThis as Window & typeof globalThis;
}

const {api} = await import("./client");

const originalFetch = globalThis.fetch;

describe("api client runtime health actions", () => {
beforeEach(() => {
globalThis.fetch = mock(async (_input: RequestInfo | URL, _init?: RequestInit) => {
return new Response(JSON.stringify({ success: true, status: "accepted", forced: true, accepted_agents: ["main"], message: "ok" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as unknown as typeof fetch;
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

test("triggerWarmup posts agent and force payload", async () => {
await api.triggerWarmup({ agentId: "main", force: true });

expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/agents/warmup",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: "main", force: true }),
}),
);
});

test("triggerWarmup defaults to all agents without force", async () => {
await api.triggerWarmup();

expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/agents/warmup",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: null, force: false }),
}),
);
});

test("reconnectMcpServer posts the agent-scoped reconnect payload", async () => {
await api.reconnectMcpServer({ agentId: "main", serverName: "filesystem" });

expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/agents/mcp/reconnect",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: "main", server_name: "filesystem" }),
}),
);
});

test("triggerWarmup includes response text in thrown API errors", async () => {
globalThis.fetch = mock(async (_input: RequestInfo | URL, _init?: RequestInit) => {
return new Response("warmup unavailable", { status: 503 });
}) as unknown as typeof fetch;

await expect(api.triggerWarmup({ agentId: "main" })).rejects.toThrow(
"API error: 503: warmup unavailable",
);
});

test("reconnectMcpServer includes response text in thrown API errors", async () => {
globalThis.fetch = mock(async (_input: RequestInfo | URL, _init?: RequestInit) => {
return new Response("reconnect failed", { status: 500 });
}) as unknown as typeof fetch;

await expect(
api.reconnectMcpServer({ agentId: "main", serverName: "filesystem" }),
).rejects.toThrow("API error: 500: reconnect failed");
});
});
83 changes: 83 additions & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ async function fetchJson<T>(path: string): Promise<T> {
return response.json();
}

async function readApiError(response: Response): Promise<Error> {
const errorText = await response.text().catch(() => "");
return new Error(`API error: ${response.status}${errorText ? `: ${errorText}` : ""}`);
}

export interface TimelineMessage {
type: "message";
id: string;
Expand Down Expand Up @@ -940,6 +945,31 @@ export interface ProvidersResponse {
has_any: boolean;
}

export type WarmupState = "cold" | "warming" | "warm" | "degraded";

export interface WarmupStatus {
state: WarmupState;
embedding_ready: boolean;
last_refresh_unix_ms: number | null;
last_error: string | null;
bulletin_age_secs: number | null;
}

export interface WarmupStatusEntry {
agent_id: string;
status: WarmupStatus;
}

export interface WarmupStatusResponse {
statuses: WarmupStatusEntry[];
}

export interface WarmupTriggerResponse {
status: string;
forced: boolean;
accepted_agents: string[];
}

export interface ProviderActionResponse {
success: boolean;
message: string;
Expand Down Expand Up @@ -1220,6 +1250,29 @@ export interface MessagingInstanceActionResponse {
message: string;
}

export interface McpServerStatusInfo {
name: string;
transport: string;
enabled: boolean;
state: string;
}

export interface McpAgentStatus {
agent_id: string;
servers: McpServerStatusInfo[];
}

export interface McpMutationResponse {
success: boolean;
message: string;
}

export interface ReconnectAgentMcpResponse {
success: boolean;
agent_id: string;
server_name: string;
}

export interface BindingInfo {
agent_id: string;
channel: string;
Expand Down Expand Up @@ -1931,6 +1984,21 @@ export const api = {

// Provider management
providers: () => fetchJson<ProvidersResponse>("/providers"),
warmupStatus: () => fetchJson<WarmupStatusResponse>("/agents/warmup"),
triggerWarmup: async (params?: {agentId?: string; force?: boolean}) => {
const response = await fetch(`${API_BASE}/agents/warmup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agent_id: params?.agentId ?? null,
force: params?.force ?? false,
}),
});
if (!response.ok) {
throw await readApiError(response);
}
return response.json() as Promise<WarmupTriggerResponse>;
},
updateProvider: async (provider: string, apiKey: string, model: string) => {
const response = await fetch(`${API_BASE}/providers`, {
method: "PUT",
Expand Down Expand Up @@ -2035,6 +2103,21 @@ export const api = {

// Messaging / Bindings API
messagingStatus: () => fetchJson<MessagingStatusResponse>("/messaging/status"),
mcpStatus: () => fetchJson<McpAgentStatus[]>("/mcp/status"),
reconnectMcpServer: async (params: {agentId: string; serverName: string}) => {
const response = await fetch(`${API_BASE}/agents/mcp/reconnect`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agent_id: params.agentId,
server_name: params.serverName,
}),
});
if (!response.ok) {
throw await readApiError(response);
}
return response.json() as Promise<ReconnectAgentMcpResponse>;
},

bindings: (agentId?: string) => {
const params = agentId
Expand Down
25 changes: 1 addition & 24 deletions interface/src/components/SetupBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { api } from "@/api/client";
import { Banner } from "@/ui";

export function SetupBanner() {
const { data } = useQuery({
queryKey: ["providers"],
queryFn: api.providers,
staleTime: 10_000,
});

if (!data || data.has_any) return null;

return (
<Banner variant="warning" dot="static">
No LLM provider configured.{" "}
<Link to="/settings" className="underline hover:text-amber-300">
Add an API key in Settings
</Link>{" "}
to get started.
</Banner>
);
}
export {SetupBanner} from "./SetupReadiness";
Loading
Loading