Codebase walkthrough

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

Frontend: React + Vite + Tailwind Backend: Express + ws Storage: better-sqlite3 Claude: claude-agent-sdk Terminal: node-pty + xterm Desktop: Electron

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.

Layer 1 · server/claude-sdk.js

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), and resume: sessionId claude-sdk.js:226 to continue a conversation.
  • Loops the async generator for await (const message of queryInstance) claude-sdk.js:688. The first message carries session_id, which it captures claude-sdk.js:690 and announces via a session_created event.
  • Implements canUseTool claude-sdk.js:581 — the SDK's permission callback. Non-allowlisted tools trigger a permission_request to the UI, then waitForToolApproval() claude-sdk.js:617 blocks until the user decides.
  • Abort = queryInstance.interrupt() claude-sdk.js:811.
Layer 2 · server/modules/providers/

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.

Design point The live streaming run is not a method on this class. The provider class owns everything request/response (history loading, model lists, on-disk sync). The fire-and-stream run is registered separately in Layer 3 — which is exactly what keeps the dispatcher provider-agnostic.
Layer 3 · chat-websocket.service.ts

The WebSocket transport

handleChatSend() chat-websocket.service.ts:107 is the dispatcher:

  1. Reads the session row from the DB (getSessionById chat-websocket.service.ts:119) → gets its provider.
  2. Looks up spawnFns[provider] chat-websocket.service.ts:131 and starts a ChatRun chat-websocket.service.ts:137.
  3. 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.

Layer 4 · run registry + session writer

In-memory run state & ID remapping

  • chat-session-writer.service.ts wraps the raw WebSocket. It remaps every outbound event's sessionId from Claude's native id to the app-facing id, assigns a monotonic seq, and captures the provider session id mid-run when the SDK announces it.
  • chat-run-registry.service.ts holds the in-memory ChatRun (status, lastSeq, buffered events, writer). This is what makes reconnect/replay work — a client that refreshes mid-stream re-subscribes with lastSeq and gets missed events replayed chat-websocket.service.ts:282.
Layer 5 · src/

The React frontend

  • Entrypoint: src/components/chat/view/ChatInterface.tsx.
  • State: src/stores/useSessionStore.ts — per-session "slots" holding serverMessages (REST) + realtimeMessages (WS), merged & de-duplicated.
  • Streaming: useChatRealtimeHandlers.ts accumulates stream_delta chunks and re-renders every ~100 ms, then stream_end finalizes into a text message.
  • Rendering: normalizedToChatMessages() maps NormalizedMessage → ChatMessage; ToolRenderer.tsx renders tool_use by 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.
The recipe SDK 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

Userkeyboard
React UIChatInterface
WebSocketchat.send
DispatcherspawnFns[claude]
claude-sdk.jsSDK query()
Claude Code CLIsubprocess
async generatorfor await
NormalizedMessagekind + seq
UI rendersToolRenderer

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.

Tier 1 · SQLite metadata index · server/modules/database/

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.

Tier 2 · Claude's own JSONL transcripts · source of truth

~/.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.fetchHistory claude-sessions.provider.ts:554 reads the JSONL line-by-line, and normalizeMessage() claude-sessions.provider.ts:297 converts each entry into the same NormalizedMessage[] the UI already understands. Uniform pagination via sliceTailPage utils.ts:401.
  • Live indexing: ClaudeSessionSynchronizer scans the folder; sessions-watcher.service.ts uses chokidar to watch for JSONL changes → upserts the DB row → broadcasts session_upserted over WS → the sidebar updates live. This is also how sessions started in a real terminal appear in the web UI.
Tier 3 · In-memory run registry · ephemeral

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)

Two IDs, two lifetimes 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

  1. CreatePOST /api/providers/sessionssessionsDb.createAppSession() inserts a row with a fresh UUID and provider_session_id = NULL. Frontend navigates to that id immediately.
  2. First turn — SDK runs fresh (no resume), announces its session_id on 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).
  3. Subsequent turnschat.send reads provider_session_id from the DB and passes it as sdkOptions.resume claude-sdk.js:226, so the SDK rehydrates Claude's native conversation.
  4. Reload / reconnectchat.subscribe { sessionId, lastSeq } replays buffered events if a run is live; otherwise history is fetched from the JSONL via REST.
  5. AbortabortClaudeSDKSession() calls interrupt(); the registry emits the single terminal complete.

App DB row vs. Claude JSONL

App sessions tableClaude .jsonl
Holdsmetadata + ID mapping + name + jsonl_paththe actual messages
Written bythis appClaude Code CLI
Lifetimeacross restartsacross restarts
Purposefast sidebar queries, cross-provider indexconversation 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

FileWhat it is
server/claude-sdk.jsThe SDK wrapper — the single most important file.
server/modules/websocket/services/chat-websocket.service.tsDispatch & wire protocol.
server/index.js :107-129Where Claude is wired into the spawn map.
server/modules/database/schema.ts :82The sessions table.
server/modules/database/repositories/sessions.db.tsSession CRUD & dual-ID mapping.
server/modules/providers/list/claude/claude-sessions.provider.tsJSONL → UI message normalization.
server/modules/providers/list/claude/claude-session-synchronizer.provider.tsScans ~/.claude/projects/ into the DB.
server/modules/providers/services/sessions-watcher.service.tschokidar watcher → live sidebar updates.
server/shared/utils.tsNormalizedMessage helpers, pagination, JSONL parsing.