# Claude Agent SDK backend + clean-room PostgresSessionStore (#9)

**Status:** shipped `v2.7.5-claude-sdk-sessionstore`
**Source:** `boocode_code_review_v2.md` §1 #9, §5h/§5i (happy + SDK `.d.ts`). Decision §6.2: lean SDK.
**SDK:** `@anthropic-ai/claude-agent-sdk@0.3.159` (installed, Commercial Terms — runtime dep OK, code
reference-only; the store is **clean-room** from the real interface, not vendored).

Replace BooCoder's one-shot PTY claude dispatch with a warm, resumable Claude-SDK backend. Two parts:
the clean-room session store (fully testable here) and the backend + wiring (live pump needs a host
smoke against real `claude`).

## Ground-truth SDK API (from the installed `sdk.d.ts`)
- `query({ prompt: string | AsyncIterable<SDKUserMessage>, options?: Options }): Query` where
  `Query extends AsyncGenerator<SDKMessage, void>`.
- `Options`: `sessionStore?: SessionStore`, `resume?: string`, `model?`, `cwd?`,
  `pathToClaudeCodeExecutable?`, `canUseTool?`, `permissionMode?`, `env?`, `allowedTools?`.
- `SessionStore = { append(key, entries): Promise<void>; load(key): Promise<SessionStoreEntry[]|null>;
  listSessions?(projectKey): Promise<{sessionId,mtime}[]>; delete?(key): Promise<void>;
  listSubkeys?({projectKey,sessionId}): Promise<string[]> }`.
- `SessionKey = { projectKey: string; sessionId: string; subpath?: string }` (undefined subpath = main
  transcript; empty string invalid — store maps undefined→'' internally).
- `SessionStoreEntry = { type: string; uuid?: string; timestamp?: string; [k]: unknown }` (opaque JSONL).
- Messages: `SDKSystemMessage{subtype:'init'}` carries `session_id` (+ model/tools); `SDKResultMessage`
  (success/error) ends a turn with `result`, `usage`, `total_cost_usd`; `SDKPartialAssistantMessage` /
  `SDKAssistantMessage` carry text/thinking/tool blocks.

## Part 1 — Clean-room PostgresSessionStore (testable now)
- Schema (`apps/coder/src/schema.sql`): a generic append-only entry table
  `claude_session_entries(id BIGSERIAL PK, project_key TEXT, session_id TEXT, subpath TEXT DEFAULT '',
  entry JSONB, created_at TIMESTAMPTZ DEFAULT clock_timestamp())` + index `(project_key, session_id,
  subpath, id)`. (The store is generic per the SDK's key; the chat↔session ownership lives in
  `agent_sessions`, not here.)
- `apps/coder/src/services/backends/claude-session-store.ts`: `PostgresSessionStore` implementing the
  real `SessionStore` type over `Sql`. `append` = ordered multi-INSERT (id = order); `load` = SELECT
  ORDER BY id → array or null; `listSessions` = group main-transcript rows, mtime = max(created_at) ms;
  `delete` = scoped delete (subpath given → that subpath; omitted → whole session); `listSubkeys` =
  DISTINCT non-'' subpaths. Pure SQL, no SDK import needed beyond the `SessionStore` type.
- Tests `__tests__/claude-session-store.test.ts` (DB-opt-in, mirror `checkpoints.test.ts`): append→load
  round-trip + order, null on unseen key, subpath isolation (main vs subagent), listSessions mtime,
  delete scoping, listSubkeys.

## Part 2 — ClaudeSdkBackend + wiring (live pump needs host smoke)
- `agent_sessions.backend` CHECK adds `'claude_sdk'`.
- `apps/coder/src/services/backends/claude-sdk.ts`: a `ClaudeSdkBackend` implementing `AgentBackend`
  (mirror `warm-acp.ts`/`opencode-server.ts`). `ensureSession` resolves the resume id from
  `agent_sessions(chat_id,'claude').agent_session_id`; `prompt` drives one persistent `query()` in
  streaming-input mode (a pushable `AsyncIterable<SDKUserMessage>` fed per turn) with
  `{ sessionStore, resume, model, cwd: worktreePath, pathToClaudeCodeExecutable: installPath }`,
  reads the `AsyncGenerator<SDKMessage>` until `result`, captures `session_id` from the `init` message
  and persists it to `agent_sessions`. A pure `mapSdkMessage(msg): AgentEvent[]` (unit-tested) maps
  partial/assistant/tool/thinking → the existing `AgentEvent` union; `result.usage`/`total_cost_usd`
  accumulate onto `agent_sessions` (like opencode U.6). `isBusy`/`closeSession`/crash mirror the ACP
  backend.
- Routing: add `claude` to the warm path (`warm-acp-routing.ts` or a sibling `shouldUseClaudeSdk`),
  with the existing PTY `runExternalAgent` kept as the **fallback** (session-less creators + if the SDK
  backend fails to start). Provider registry: claude stays selectable; transport reflects the SDK path.
- Frames + persistence identical to the warm-ACP path (`persistExternalAgentTurn`, broker frames).

## Verify
- Part 1: `pnpm -C apps/coder test` + DB-opt-in store tests against dev postgres; build clean.
- Part 2: `pnpm -C apps/coder build` + `npx tsc -p apps/coder/tsconfig.json --noEmit` (typechecks
  against the REAL SDK types) + pure-mapper unit tests. **Live pump + resume across turns: host smoke
  against real `claude` (auth required) — cannot run from the dev container.**

## Open flags
- SDK peer-deps want `zod@^4`; workspace is `zod@3.25.76` (installed with a warning) — watch at runtime.
- `pathToClaudeCodeExecutable` from `available_agents.install_path`; the SDK spawns the same `claude`
  binary the PTY path uses. ANTHROPIC auth/env must reach the child (host concern).
