Last updated: 2026-04-23
The SDK is not a chat API. It’s the same agent loop Claude Code runs, exposed as a TypeScript function. You hand it a prompt + options and iterate over an async stream of events (system.init, assistant, tool_use, tool_result, result). Everything you see in Claude Code — tool use, plan mode, hooks, sub-agents, sessions — is available as an option on query().
Command Center takes that raw primitive and wraps it in the thinnest possible UI layer. Express for HTTP, vanilla JS for the browser, zero build pipeline for the frontend. The SDK does the hard work; we render it.
┌─────────────────┐ ┌──────────────────────────────────┐
│ Browser │ │ Node.js process (tsx src/server.ts)
│ public/* │◄────────┤ ├── Express (port 3333)
│ vanilla JS │ JSON │ │ ├── /api/agents
└────────┬────────┘ │ │ ├── /api/cwd, /api/browse, /api/files
│ │ │ ├── /api/model/:agentId
│ /api/chat │ │ └── /api/chat ──┐
└──────────────────► │ │
│ ├── state (in-memory) │
│ │ ├── sessionByAgent
│ │ ├── modelOverride │
│ │ └── currentCwd │
│ │ │
│ └── Claude Agent SDK ◄┘
│ ├── query({...})
│ └── spawns `claude` subprocess
└──────────────┬───────────────────┘
│ OAuth session
▼
Claude Max subscription (~/.claude/...)
One OS process. No IPC, no WebSockets (yet), no secondary server. The claude binary that the SDK spawns is the only child process.
| Path | Role | LOC |
|---|---|---|
src/server.ts |
Express app, all /api/* routes, SDK call sites for chat + chat/stream + task runs, classifier, .env loader, WhisprDesk proxy, Settings + Sessions + custom-agent CRUD wiring |
~700 |
src/agents.ts |
Built-in agent configs (Main / Comms / Content / Ops) + MODELS table | ~120 |
src/agentRegistry.ts |
Merges built-ins + custom agents at runtime; findAgent(), allAgents(), subAgentsFor(), isBuiltInAgent(), builtInIds() |
~40 |
src/customAgents.ts |
SQLite CRUD for custom_agents table — create / update / delete / find |
~140 |
src/memory.ts |
better-sqlite3 init (shared db at data/lab.db), memories schema, CRUD, augmentedSystemPrompt() injection helper |
~130 |
src/settings.ts |
settings table + schema, configValue(dbKey, envKey) reader with env-fallback, masked-secret API for the UI |
~150 |
src/sessions.ts |
sessions + session_messages tables, transactional appendTurn(), auto-titling, restore helpers |
~160 |
src/hello.ts |
One-shot URL summarizer (smoke test entry; npm run hello) |
~15 |
public/index.html |
All UI markup — sidebar, chat, modals (folder, tasks, memory, settings, agent editor, history) | ~140 |
public/style.css |
Dark command-center theme; markdown rendering; modal + popover + voice indicator styles | ~900 |
public/app.js |
Frontend — agents, streaming chat with WAV-conversion mic, folder picker, @file + slash-command popovers, task board, memory + settings + agent + history modals, slash dispatcher, /think + /export + /plan, mic ⌥V shortcut, session usage chip, restore flow | ~1,100 |
scripts/screenshot.mjs |
Playwright script that regenerates all 14 README screenshots | ~140 |
scripts/launch-command-center.command |
Move-safe Desktop launcher (auto-locates project via candidate list; pkill + lsof retries) | ~150 |
playwright.config.ts |
Two projects: smoke (offline) + engine (@engine-tagged, real SDK) |
— |
tests/smoke.spec.ts |
7 offline UI tests | — |
tests/features.spec.ts |
14 offline feature tests (memory, slash, custom agents, settings, etc.) | — |
tests/chat.spec.ts |
2 @engine tests (streaming reply, task classifier) | — |
Total hand-written: ~2,500 LOC across the whole project. By design. Everything the SDK gives us for free stays in the SDK.
┌────────────┐
│ memory.ts │ ← opens the shared SQLite db (data/lab.db)
└─────┬──────┘
│ exports `db`
┌─────────┼──────────────┬──────────────┐
▼ ▼ ▼ ▼
settings customAgents sessions (memory itself)
│ │ │
└─────────┼──────────────┘
│
▼
agentRegistry.ts ── merges built-ins + custom
│
▼
server.ts ── wires it all up + API surface
memory.ts is the dependency root because it owns the shared SQLite handle. Every other DB-touching module imports db from there. agentRegistry is the only module that can answer “is this agent built-in or custom?”
All state is in-memory on the server. Restart = fresh.
| State | Type | Where | Reset on |
|---|---|---|---|
| Session IDs per agent | Map<agentId, sessionId> |
server.ts:17 |
/api/reset/:agentId, cwd change, model override |
| Model overrides | Map<agentId, string> |
server.ts:18 |
POST /api/model/:agentId with empty body |
| Current working directory | string (default: os.homedir()) |
server.ts:19 |
POST /api/cwd |
| Chat history (UI only) | state.conversations[agentId] |
app.js:4 |
New chat button, cwd change, model change |
Frontend history is cosmetic — it shows what the user and agent have said in the current browser session. The actual conversation context lives in the SDK session (resume:). Reload the browser and history is empty, but if the server still has the session ID, the agent still remembers.
This duality is deliberate. Cleaner than trying to persist + sync. Persistence is tracked as C04 (SQLite-backed memory that survives restarts).
POST /api/chat/stream (C02)Same body as /api/chat; returns NDJSON (one JSON object per line). Event kind field:
init — {sessionId, model, apiKeySource} captured from SDK inittext_delta — {text} incremental assistant text from includePartialMessages: truetool_use — {name, input} tool invocation (Agent = delegation)result — {text} authoritative final texterror — {message}done — end of streamUsed by the UI for token-by-token rendering. Original /api/chat retained for tests and fallback.
GET /api/tasks / POST /api/task / POST /api/task/:id/run / DELETE /api/task/:id (C03)agentId overridequery() with the assigned agent’s config; no resume: (fresh context per task)queued → active → done | errorcreatedAt, startedAt, completedAt, result, errorPOST /api/chatRequest:
{ "agentId": "main", "message": "..." }
Response:
{
"reply": "...",
"toolUses": [{"name": "Read", "input": {...}}],
"model": "claude-sonnet-4-6",
"apiKeySource": "none",
"cwd": "/Users/you/project"
}
Server:
agent from agents.tsresumeId from sessionByAgent.get(agentId)modelId via effectiveModel(agentId) (override → default)query({ prompt, options: { allowedTools, systemPrompt, resume, cwd, model } })system.init → captures session_id, model, apiKeySourceassistant with tool_use blocks → appends to toolUses[]result string → final textsession_id to sessionByAgentThe route is buffered (full response before response sent). Streaming is C02.
All synchronous, all return JSON, all reject bad input with { error } + 400.
src/agents.ts){
id: string // route key: "main" | "comms" | "content" | "ops"
name: string // display name
emoji: string // avatar glyph
accent: string // color (sidebar icon, etc.)
description: string // one-line summary for sidebar
systemPrompt: string // personality + role
allowedTools: string[] // SDK tool allowlist
model: string // default model
}
Current defaults:
| Agent | Tools | Model | Why |
|---|---|---|---|
| Main | (none) | Sonnet 4.6 | Pure reasoning, routing, triage |
| Comms | WebFetch |
Sonnet 4.6 | Draft messages; sometimes pull context |
| Content | WebSearch, WebFetch |
Opus 4.7 | Best creative output |
| Ops | Read, Glob, Grep |
Sonnet 4.6 | Read files in current cwd |
Adding a new agent = one entry in this file. No server changes needed.
The SDK’s resume: sessionId option is how multi-turn conversations work. We capture session_id from the first system.init message of each query() call and store it under the agent’s ID. Next message → pass resume: → SDK loads the prior conversation’s context.
Gotcha: changing cwd mid-session confuses the agent (context includes the old cwd). We work around this by clearing all agent sessions (sessionByAgent.clear()) on POST /api/cwd. Same for model overrides — changing the model per-agent clears that agent’s session.
Three models wired via MODELS in agents.ts:
claude-opus-4-7 — best creative/reasoningclaude-sonnet-4-6 — balanced defaultclaude-haiku-4-5 — fastest/cheapestPer-agent default in the config file. Runtime override via POST /api/model/:agentId. The UI surfaces both: sidebar shows each agent’s current model as a chip; <select> in the chat header changes it. The model used for each specific reply is echoed back in the response’s model field and shown in the message footer — so you always know what answered you.
The SDK checks in order:
ANTHROPIC_API_KEY env var (if set, uses it)claude CLI’s stored OAuth session (~/.claude/...)Currently the env var is unset, so every call rides on the Max subscription via OAuth. The response’s apiKeySource field is "none" when OAuth is active — we translate that to “Max plan · subscription” in the UI for clarity.
Commercial distribution would require API key auth. Not a todo for this project; that’s Clawless territory.
Pure vanilla JS. No framework. Global state object holds:
agents — loaded once from /api/agentsmodels — loaded once from /api/modelsactiveAgentId — current selectionconversations — { [agentId]: Array<{role, text, toolUses?, model?, apiKeySource?}> }cwd, home, browse, filePopover — UI helpersRendering is re-render-the-world on every state change (cheap at this scale). renderMessages() rebuilds the chat log from state.conversations[activeAgentId].
No client-side routing. No persistence. Deliberate simplicity.
Read/Write/Bash; notifications + packaging are a later concern tracked as C07.query() throws, the UI shows the message and the session may or may not be usable; safest fix is “New chat”.