How a Claude Code CLI UI is built
& how its chat sessions persist
A map of the CloudCLI project (@cloudcli-ai/cloudcli) — a web UI that drives
the Claude Code CLI. Two questions answered: how the UI is layered on top of Claude Code,
and how chat sessions are managed & persisted. Claude Code only — sibling providers
(Codex, Gemini, Cursor, OpenCode) follow the same shape and are intentionally omitted.
00Project orientation
The integration with Claude Code does not scrape terminal text. It uses the official
@anthropic-ai/claude-agent-sdk, whose query() spawns the Claude Code binary under
the hood and returns a typed async generator of messages. Everything in this project is built around that stream.
Claude is one of five interchangeable providers behind a uniform abstraction
(claude / cursor / codex / gemini / opencode).
The same UI, websocket protocol, and database serve all of them — the Claude-specific code is isolated to a
few files.
Tech stack
Q1How to make a Claude Code CLI UI
Five layers sit between a user's keystroke and Claude's reply. The crucial design decision: the streaming run lives outside the provider class (it's a standalone function in a spawn-map), while everything request/response (models, auth, history, sync) lives inside the provider class.
The SDK wrapper
The heart of the integration. queryClaudeSDK(command, options, ws) claude-sdk.js:515:
- Calls
query({ prompt, options })from the SDK claude-sdk.js:659. The SDK spawns the CLI binary (pathToClaudeCodeExecutable, resolved eagerly on Windows claude-sdk.js:164). mapCliOptionsToSDK()claude-sdk.js:153 translates UI options → SDK options:cwd,permissionMode,allowedTools/disallowedTools,model,systemPrompt: { preset: 'claude_code' }(so CLAUDE.md loads), andresume: sessionIdclaude-sdk.js:226 to continue a conversation.- Loops the async generator
for await (const message of queryInstance)claude-sdk.js:688. The first message carriessession_id, which it captures claude-sdk.js:690 and announces via asession_createdevent. - Implements
canUseToolclaude-sdk.js:581 — the SDK's permission callback. Non-allowlisted tools trigger apermission_requestto the UI, thenwaitForToolApproval()claude-sdk.js:617 blocks until the user decides. - Abort =
queryInstance.interrupt()claude-sdk.js:811.
The provider abstraction
AbstractProvider abstract.provider.ts:19 defines the shape;
ClaudeProvider claude.provider.ts:16 composes six sub-providers:
auth, models, mcp, skills, sessions,
sessionSynchronizer.
The WebSocket transport
handleChatSend() chat-websocket.service.ts:107 is the dispatcher:
- Reads the session row from the DB (
getSessionByIdchat-websocket.service.ts:119) → gets itsprovider. - Looks up
spawnFns[provider]chat-websocket.service.ts:131 and starts aChatRunchat-websocket.service.ts:137. - Calls
spawnFn(command, runtimeOptions, run.writer)chat-websocket.service.ts:171.
Wired once in server/index.js:
// server/index.js:113-126
spawnFns: {
claude: queryClaudeSDK,
cursor: spawnCursor,
codex: queryCodex,
gemini: spawnGemini,
opencode: spawnOpenCode,
},
abortFns: { claude: abortClaudeSDKSession, /* ... */ },
That one-line map is the entire reason the UI is provider-agnostic — handleChatSend has zero Claude-specific branches.
The wire protocol is a uniform kind-based NormalizedMessage utils.ts:339. Inbound client messages: chat.send, chat.abort, chat.subscribe, chat.permission-response.
In-memory run state & ID remapping
chat-session-writer.service.tswraps the raw WebSocket. It remaps every outbound event'ssessionIdfrom Claude's native id to the app-facing id, assigns a monotonicseq, and captures the provider session id mid-run when the SDK announces it.chat-run-registry.service.tsholds the in-memoryChatRun(status,lastSeq, buffered events, writer). This is what makes reconnect/replay work — a client that refreshes mid-stream re-subscribes withlastSeqand gets missed events replayed chat-websocket.service.ts:282.
The React frontend
- Entrypoint:
src/components/chat/view/ChatInterface.tsx. - State:
src/stores/useSessionStore.ts— per-session "slots" holdingserverMessages(REST) +realtimeMessages(WS), merged & de-duplicated. - Streaming:
useChatRealtimeHandlers.tsaccumulatesstream_deltachunks and re-renders every ~100 ms, thenstream_endfinalizes into atextmessage. - Rendering:
normalizedToChatMessages()mapsNormalizedMessage → ChatMessage;ToolRenderer.tsxrenderstool_useby category (Edit/Write→diff, Bash→output, Task→subagent container, AskUserQuestion→interactive). - Claude's capabilities (images, abort, permission modes, token usage) are learned dynamically from
GET /api/providers/capabilities.
query() → normalize to kind messages → stream over WS with seq →
provider-agnostic React store + renderers. Session continuity comes from passing the SDK's
session_id back as resume on the next turn.
Request flow
Q2How to manage & persist Claude Code sessions
Three storage tiers — and the clever part is that the app does not duplicate Claude's message transcript. It piggybacks on Claude Code's own persistence, staying a true index/overlay over the CLI.
The sessions table (metadata only)
DB at ~/.cloudcli/auth.db (DATABASE_PATH). The sessions table schema.ts:82:
CREATE TABLE sessions (
session_id TEXT PRIMARY KEY, -- app UUID, stable, frontend-facing
provider TEXT DEFAULT 'claude',
provider_session_id TEXT, -- Claude's native id (= JSONL filename)
custom_name TEXT,
project_path TEXT,
jsonl_path TEXT, -- pointer to Claude's transcript file
isArchived BOOLEAN,
created_at, updated_at
);
No messages here. This row maps IDs, stores names, and points at the transcript.
~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
Claude Code itself writes this file — this is where the actual conversation lives.
The app only indexes it (jsonl_path) and reads it; it never writes it.
- History loading:
fetchHistory → ClaudeSessionsProvider.fetchHistoryclaude-sessions.provider.ts:554 reads the JSONL line-by-line, andnormalizeMessage()claude-sessions.provider.ts:297 converts each entry into the sameNormalizedMessage[]the UI already understands. Uniform pagination viasliceTailPageutils.ts:401. - Live indexing:
ClaudeSessionSynchronizerscans the folder;sessions-watcher.service.tsuses chokidar to watch for JSONL changes → upserts the DB row → broadcastssession_upsertedover WS → the sidebar updates live. This is also how sessions started in a real terminal appear in the web UI.
chat-run-registry.service.ts
During an active run, events are buffered here (≤ 5000 events/run, kept ~5 min after completion)
purely for reconnect/replay via lastSeq. Not durable — once a run completes,
history is served from Tier 2 over REST.
The dual-ID pattern (central design)
session_id is the stable app UUID the frontend uses forever;
provider_session_id is Claude's native id (matches the JSONL filename).
The frontend never sees Claude's native id — the session writer remaps it on every frame.
The full lifecycle
- Create —
POST /api/providers/sessions→sessionsDb.createAppSession()inserts a row with a fresh UUID andprovider_session_id = NULL. Frontend navigates to that id immediately. - First turn — SDK runs fresh (no
resume), announces itssession_idon the first message. The writer captures it →assignProviderSessionId()maps app-id ↔ Claude-id (transaction-guarded dedup for the race where the watcher indexed the JSONL first). - Subsequent turns —
chat.sendreadsprovider_session_idfrom the DB and passes it assdkOptions.resumeclaude-sdk.js:226, so the SDK rehydrates Claude's native conversation. - Reload / reconnect —
chat.subscribe { sessionId, lastSeq }replays buffered events if a run is live; otherwise history is fetched from the JSONL via REST. - Abort —
abortClaudeSDKSession()callsinterrupt(); the registry emits the single terminalcomplete.
App DB row vs. Claude JSONL
App sessions table | Claude .jsonl | |
|---|---|---|
| Holds | metadata + ID mapping + name + jsonl_path | the actual messages |
| Written by | this app | Claude Code CLI |
| Lifetime | across restarts | across restarts |
| Purpose | fast sidebar queries, cross-provider index | conversation source of truth |
By piggybacking on Claude Code's own persistence instead of re-storing every message, the app stays a true index/overlay over the CLI — which is exactly what lets it stay in sync with sessions started outside the UI.
03Key files to read first
| File | What it is |
|---|---|
server/claude-sdk.js | The SDK wrapper — the single most important file. |
server/modules/websocket/services/chat-websocket.service.ts | Dispatch & wire protocol. |
server/index.js :107-129 | Where Claude is wired into the spawn map. |
server/modules/database/schema.ts :82 | The sessions table. |
server/modules/database/repositories/sessions.db.ts | Session CRUD & dual-ID mapping. |
server/modules/providers/list/claude/claude-sessions.provider.ts | JSONL → UI message normalization. |
server/modules/providers/list/claude/claude-session-synchronizer.provider.ts | Scans ~/.claude/projects/ into the DB. |
server/modules/providers/services/sessions-watcher.service.ts | chokidar watcher → live sidebar updates. |
server/shared/utils.ts | NormalizedMessage helpers, pagination, JSONL parsing. |