Current ACP implementation compared with the direct Claude SDK design · complex interactions reconstructed from source

ACP-aligned protocol vs Claude SDK harness

Both trees implement the same architecture — a seq-stamped envelope journal on the host, one WebSocket replayed with ?since=, and a pure client-side fold into timeline items. What differs is the vocabulary riding inside the envelope, and who does the interpretive work. UI code is excluded from every number below.

ACP = current PR implementation · claude-agent-acp 0.56.0 child process · @agentclientprotocol/sdk 1.2.0  ·  Claude SDK = archived direct-SDK design · @anthropic-ai/claude-agent-sdk 0.3.202 in-process

fidelity sources (⑩–⑮, §6) = claude-agent-acp 0.56.0 dist · @anthropic-ai/claude-agent-sdk 0.3.201 types (this tree) · @agentclientprotocol/codex-acp 1.1.2 npm tarball, source-inspected

1Where they actually differ

Seven divergences (D1–D7) carry the wire-protocol signal; everything else in phases ①–⑨ is the same machine twice. Ranked, roughly, by how much they should weigh on the decision. D8–D10 are a different species: not “who does the work” but “can the vocabulary even say it” — covered in phases ⑩–⑮ and the §6 fidelity matrix.

D1 — Where history lives hops ↓

ACP: host SQLite keeps the public session → native ACP session binding. A restarted host lists the row as offline and resurrects it on demand with session/load. The adapter replays its native on-disk transcript into a fresh, bounded 5,000-frame journal.

SDK: the journal is only a delivery buffer; truth is Claude's native JSONL transcript on disk — survives restarts, powers retry() epochs.

Both sides recover completed history from Claude's native store. The ACP host owns registry metadata, not a durable transcript. getMessages still pages the bounded ring, so content older than that retained window is unavailable; disk-backed paging is required follow-up work. In-flight turns die with the host on both sides.

D2 — Stream vocabulary hops ↓

ACP: streams too — one agent_message_chunk per text delta, live during the turn; chunks accumulate into one message and are never replaced (the adapter diffs the SDK's consolidated message against what already streamed and forwards only any un-streamed tail). Tool calls arrive as UI-ready tool_call frames (title/kind/status on the wire).

SDK: journals the raw model stream verbatim (message_start, content_block_delta, …) and then a complete assistant message that supersedes the accumulated deltas at fold time — ~10× the journal rows, interpretation deferred to the client fold.

Both sides stream token-by-token. The divergence is the vocabulary (semantic chunks vs raw stream events) and whether a later authoritative message replaces what streamed — ACP: no; SDK: yes.

D3 — Permission contract hops ↓

ACP: ships the options on the wire; the client taps an optionId back — zero decision logic on the device.

SDK: ships raw input + rule suggestions; the client must build a full PermissionResult and invent the Allow / Always / Deny UX.

D4 — Questions (AskUserQuestion) hops ↓

ACP: the host converts the elicitation form into ordinary permission cards — mobile has zero question code.

SDK: the host forwards the raw tool input and mobile special-cases it — the host has zero question code; carries 3 parallel interaction channels vs ACP's 1.

Same tool, opposite ends do the work.

D5 — Process model hops ↓

ACP: boots a child process per session and needs 3 stdio round-trips (initialize → session/new → set_mode) before it's usable.

SDK: one in-process query() call whose options carry cwd, mode and model; its callbacks never cross a process boundary.

D6 — Sending a prompt hops ↓

ACP: takes plain ContentBlocks, allows mid-turn sends, and the host echoes your bubble into the journal instantly.

SDK: needs a client-built SDKUserMessage, only accepts when idle, and your bubble waits for the CLI's echo.

D7 — Turn end hops ↓

ACP: turn-end is host-internal — a stopReason answering a parked RPC; devices only ever see the state snapshot.

SDK: journals an explicit result frame with duration, cost and usage — billing telemetry for free.

D8 — Subagents hops ↓

ACP: the spawn becomes a generic think tool call, subagent prose is deliberately dropped, and the tree survives only via the non-standard _meta.claudeCode.parentToolUseId our fold happens to know about.

SDK: a typed spawn (subagent_type, model, isolation), every subagent message tagged with parent_tool_use_id, transcript readable later by (sessionId, agentId).

D9 — Task graph → flat checklist hops ↓

ACP: the four Task tools are suppressed; each mutation re-emits one flat plan — content + status only, priority invented as "medium", everything else dropped on the adapter floor.

SDK: ID-addressed tasks with dependencies (blocks/blockedBy), owner, metadata — a queryable graph.

D10 — Workflows & goals don't cross hops ↓

ACP: the workflow crosses as one opaque kind:"other" tool call; goals don't cross at all — the adapter has no mapping for them.

SDK: a first-class Workflow tool (resumable runs, taskId/runId, transcript dir) and an active_goal message type.

In the diagram below every diverging hop wears a magenta number — ≠ — with a one-line “why it matters”. The Differences filter in the sticky bar hides every hop where the two sides are the same machine.

2Scoreboard

Added lines vs each tree's base (git diff + untracked), production code only; UI and tests counted separately.

metricACP (main)Claude SDKΔ sdk vs acp
LOC added, non-UI production3,0124,428+47%
protocol / envelope package254687+170%
host session harness1,3631,722+26%
journal (in-memory ring)9992−7%
client bindings (fold, stream, hooks)1,2961,927+49%
if ( added (non-UI)135201+49%
ternaries added~31~48+55%
switch / case clauses3 / 224 / 24≈
deepest nesting (both: WS stream route)109≈
new exported symbols (non-UI)~65~120+85%
wire frame kinds / interaction channels6 / 19 / 3
UI (excluded from comparison)1,4191,296−9%
tests (excluded)1,7513,759+115%

Method: ACP = staged + unstaged + untracked on main vs HEAD; SDK = uncommitted work in the worktree (its committed base is upstream sync only). Untracked files counted whole. “UI” = presentational screens/components; fold, stream and hook logic count as client bindings even when they live in .tsx. Lockfiles, plan docs, E2E scripts and packaging tooling excluded everywhere. Caveat: the ACP tree has had more UI iteration; the SDK tree carries heavier excluded infra (978-line relay E2E script, packaged-host validation, 7 Maestro flows).

Same skeleton grew on both sides

Envelope {seq, sessionId, ts, frame}, append-only ring journal (cap 5,000), WS replay from a cursor, admission-ack sends, parked-promise permissions, full-snapshot state frames, pure fold reducer. The protocol choice didn't change the architecture — it changed the vocabulary, and who does the interpretive work.

ACP ships UI-ready events

Semantic update variants: tool_call / tool_call_update with patch-merge, titles and statuses born at the protocol layer, permission options carried on the wire, one interaction channel (AskUserQuestion mapped onto it via elicitation). Small client vocabulary; the fold does chunk-merge, patches, subagent tree.

SDK ships the raw model stream

Verbatim SDKMessages: partial deltas superseded by authoritative messages, uuid identity, tool results as user-role messages, three parallel interaction channels, client-built PermissionResult. Buys native-transcript history, retry epochs, catalog, usage/cost — costs +47% non-UI LOC and +49% ifs.

3Every packet, end to end

create → attach → prompt → stream → permission → question → two devices → turn end → reset — then the complex interactions: ⑩ subagents · ⑪ task graphs · ⑫ plan mode · ⑬ terminal / background Bash · ⑭ workflows · ⑮ goals. One numbered arrow per hop; payloads sit under the actor that sends them. Blue is ACP-only, amber is SDK-only, grey exists on both sides; dashed arrows are in-process calls that never touch a wire. Hops renumber automatically as you filter. Phases ①–⑨ are captured traffic; ⑩–⑮ are reconstructed from adapter/SDK source and badged as such.

Devices — derived state only

envelopes[] (deduped by seq) → fold() → FoldedTimeline { items, meta, state, lastSeq }.

The thread renders from items; status chip, composer gate and pending cards render from state. Everything here is disposable — refolded from scratch on any resync.

Host — the only authority

SessionScopedState mutates only here; every mutation is journaled as a full-snapshot state frame — clients receive status, never compute it.

SessionJournal: per-session in-memory catch-up ring, gapless within one runtime incarnation, cap 5,000. Every append fans out to every subscribed socket in the same call.

Parked asks: ACP Map<requestId, resolve> of unanswered JSON-RPC requests · SDK three Deferred maps (permissions / dialogs / elicitations).

Agent — conversation truth

ACP: one claude-agent-acp child process per active session; its embedded SDK persists the native transcript. Host SQLite stores the lookup key, and session/load restores completed history.

SDK: in-process query() handle over a claude subprocess; history truth is Claude's native JSONL transcript on disk, which outlives the journal.

The fold — pure, rerunnable

for (env of envelopes) {
  if (env.seq <= lastSeq) continue    // dedupe
  update|sdk  → append / merge an item
  *_requested → attach a card to its tool
  *_resolved  → flip that card in place
  state       → replace timeline.state
  reset       → stop; full resync
}
magenta number = the sides diverge here
Mobile UIapps/mobile Relaytunnel by routingKey Hosthost-service · journal + state owner Agentclaude-agent-acp / claude CLI

① Start a new session

Device mints the public uuid; create is idempotent on it. The host boots the agent — this is divergence D5: a child process and a 3-RPC handshake vs one in-process call.

user taps “New chat” — mobile mints the id
acp · sdkcreate is idempotent on this id
sessionId = randomUUID()
// acp example: "7d1e4a02-9c1f-4f7e-b1de-2f6d1a0c55aa"
// sdk example: "6f0d1c2e-4b7a-4c8e-9f21-3d5a7b9c1e0f"
HTTPS POST create — superjson body, Bearer JWT
acplib/host/client.ts:60
POST {relay}/hosts/org_01J8…%3Amac-mini-m2/trpc/acpSessions.create
authorization: Bearer <host JWT>

{ "json": {
    "sessionId": "7d1e4a02-9c1f-4f7e-b1de-2f6d1a0c55aa",
    "workspaceId": "a34e11f8-24c1-47c3-9d0e-88f2b1c9a771"
} }
sdk
POST {relay}/hosts/org_01J8…%3Amac-mini-m2/trpc/sessions.create
authorization: Bearer <host JWT>

{ "json": {
    "sessionId": "6f0d1c2e-4b7a-4c8e-9f21-3d5a7b9c1e0f",
    "workspaceId": "9a8b7c6d-5e4f-4a3b-8c1d-2e0f9a8b7c6d",
    "permissionMode": "default"
} }
acp · sdkrelay never parses the body — elided from here on
// looks up the host tunnel by routingKey
// "org_01J8…:mac-mini-m2", forwards opaquely
→ POST /trpc/acpSessions.create   // acp
→ POST /trpc/sessions.create      // sdk
forwarded tRPC call
acptrpc/router/acp-sessions.ts:64
// gate: PRECONDITION_FAILED unless SUPERSET_ACP_SESSIONS=1
await runtime.acpSessions.create({ sessionId, workspaceId })
// idempotent — same id returns the existing session
sdktrpc/router/sessions.ts
await runtime.sessions.create({ sessionId, workspaceId,
  permissionMode: "default" })
// env from login-shell snapshot, resolves system `claude` binary
spawn adapter + initialize — JSON-RPC over stdio
≠ D5 — one child process per session; 3 stdio round-trips before it's usable
acpruntime/acp-sessions.ts:508,571
spawn(process.execPath, [claude-agent-acp entry],
  { cwd, env: { …process.env, ELECTRON_RUN_AS_NODE: "1" } })

{ "jsonrpc": "2.0", "id": 1, "method": "initialize",
  "params": {
    "protocolVersion": 1,
    "clientInfo": { "name": "superset-host", "version": "1" },
    "clientCapabilities": {
      "fs": { "readTextFile": false, "writeTextFile": false },
      "terminal": false,
      "elicitation": { "form": {} }  // re-enables AskUserQuestion
    } } }

⤷ elicitation.form is the load-bearing line: without it the adapter disallows Claude Code's built-in AskUserQuestion tool — see phase ⑥.

acpresponse (abbreviated)
{ "id": 1, "result": {
    "protocolVersion": 1,
    "agentCapabilities": { … } } }
query() — in-process, no wire hop; the SDK spawns the CLI itself
≠ D5 — cwd, mode, model, callbacks all ride one function call; nothing to handshake
sdkruntime/sessions.ts:506
query({
  prompt: asyncInputQueue,       // user turns pushed later
  options: {
    cwd: "/Users/kirill/.superset/worktrees/…",
    sessionId: "c1a2d3e4-…",     // native Claude session uuid
    permissionMode: "default",
    includePartialMessages: true,
    persistSession: true,
    canUseTool, onUserDialog, onElicitation  // 3 host-local callbacks
  } })
// catalog = await q.initializationResult()

⤷ the three callbacks are host-local functions — an interaction never crosses a process boundary until the host journals it.

session/new
acp
{ "jsonrpc": "2.0", "id": 2, "method": "session/new",
  "params": {
    "cwd": "/Users/kirill/.superset/worktrees/…",
    "mcpServers": []
  } }
acpthe acp id never leaves the host
{ "id": 2, "result": {
    "sessionId": "sess_9f3k…",   // adapter-internal
    "modes": {
      "currentModeId": "bypassPermissions",
      "availableModes": [
        { "id": "default", … }, { "id": "acceptEdits", … }, …
      ] } } }
session/set_mode — adapter boots in bypassPermissions; host forces default
acpD14-c
{ "jsonrpc": "2.0", "id": 3, "method": "session/set_mode",
  "params": { "sessionId": "sess_9f3k…", "modeId": "default" } }
journal seq 1 — state frame
acp · sdkin-memory ring, cap 5 000
journal.append({ kind: "state", state: { …, "status": "idle",
  "lastSeq": 1 } })   // → envelope { seq: 1, sessionId, ts, frame }
200 — full SessionScopedState
acp
{ "result": { "data": { "json": {
  "sessionId": "7d1e4a02-…",
  "workspaceId": "a34e11f8-…",
  "harness": "claude-agent-acp",
  "status": "idle",
  "currentMode": {
    "currentModeId": "default",
    "availableModes": [ … ] },
  "configOptions": [ … ],
  "pendingPermissions": [],
  "cwd": "/Users/kirill/.superset/worktrees/…",
  "lastSeq": 1,
  "lastStopReason": null,
  "lastError": null } } } }
sdk3 pending arrays instead of 1
{ "result": { "data": { "json": {
  "sessionId": "6f0d1c2e-…",
  "claudeSessionId": "c1a2d3e4-…",
  "workspaceId": "9a8b7c6d-…",
  "harness": "claude",
  "status": "idle",
  "model": "claude-sonnet-5",
  "permissionMode": "default",
  "effort": null,
  "pendingPermissions": [],
  "pendingUserDialogs": [],
  "pendingElicitations": [],
  "cwd": "/Users/kirill/.superset/worktrees/…",
  "lastSeq": 1,
  "lastError": null } } } }

② Open an existing session — state, history, stream

Every cold attach, every resync, and a second device's join are the same triple: read state, read a history page, dial the WS from a cursor. Hop two is divergence D1 — the single most consequential difference.

GET state snapshot — tRPC query = GET + ?input=
acp
GET …/trpc/acpSessions.get?input=%7B%22json%22%3A…
// decoded: { "json": { "sessionId": "7d1e4a02-…" } }
sdk
GET …/trpc/sessions.get?input=…
// decoded: { "json": { "sessionId": "6f0d1c2e-…" } }
acp · sdk
← 200 SessionScopedState
// same shape as ①'s "200 — full SessionScopedState";
// this object IS the session's top-level state: status chip,
// composer gate and pending cards all render from it
GET history page — newest first, cursor walks backward
≠ D1 · critical — ACP currently pages its bounded ring; an offline session first rebuilds that ring through a full session/load replay. The direct SDK design pages Claude's native JSONL transcript.
acp
GET …/trpc/acpSessions.getMessages?input=…
// { "json": { "sessionId": "7d1e4a02-…", "limit": 200 } }
sdk
GET …/trpc/sessions.getMessages?input=…
// { "json": { "sessionId": "6f0d1c2e-…", "limit": 200 } }
acpjournal page — rows ARE envelopes
{ "result": { "data": { "json": {
  "items": [
    { "seq": 38, "sessionId": "7d1e…", "ts": 1752107660102,
      "frame": { "kind": "update", "update": {
        "sessionUpdate": "user_message_chunk",
        "content": { "type": "text",
                     "text": "Create hello.txt…" } } } },
    …  // timeline frame kinds only — state/reset excluded
  ],
  "nextCursor": "s38"   // older page: pass cursor "s38"
} } } }
sdknative transcript — no seq on rows
{ "result": { "data": { "json": {
  "items": [
    { "type": "user", "uuid": "b4e2…",
      "session_id": "c1a2d3e4-…",
      "message": { "role": "user", "content": [
        { "type": "text", "text": "List the files…" } ] },
      "parent_tool_use_id": null,
      "parent_agent_id": null },
    …  // Claude's own JSONL rows, 200/page
  ],
  "nextCursor": "1kf3z"  // base36, cycle-guarded
} } } }
WS attach with cursor
acp
WSS {relay}/hosts/org_01J8…%3Amac-mini-m2
    /acp-sessions/7d1e4a02-…/stream?token=<JWT>&since=51
// since = folded.lastSeq (fallback state.lastSeq)
sdk
WSS …/sessions/6f0d1c2e-…/stream?token=<JWT>&since=57
// since = state.lastSeq — emitState stamps its own seq
// into state.lastSeq so resume lands right after it
acp · sdkruntime subscribe()
subscribe({ sessionId, since, onEnvelope })
// journal.after(since) = (since, latest] replayed
// synchronously before live attach → no gap window
replay, then live — one envelope per WS message
acp · sdk
// envelopes (since, latest], then live — shapes as in the
// phases below. Client rules make delivery exactly-once:
//   seq <= lastSeq       → drop (at-least-once dedupe)
//   seq == lastSeq + 1    → fold (the only accept path)
//   seq >  lastSeq + 1    → gap → reconnect from cursor
fold from scratch → timeline (pure, derived, disposable)
≠ D1 — ACP refolds one source; SDK merges two: transcript rows at seq 0 (uuid-keyed), live envelopes on top
acpsession-protocol/fold.ts
foldTimeline(journalPages)
→ { "items": [ MessageItem | ToolCallItem | PlanItem ],
    "meta": { "title", "usage", "currentMode", … },
    "state": { … }, "lastSeq": 51 }
sdk
timelineFromSessionMessages(history)  // transcript → seq-0 items
fold(bufferedEnvelopes)               // live rows on top
→ { "items": [ Message | ToolCall | UserDialog | Elicitation ],
    "meta": { "model", "permissionMode", "lastResult", … },
    "state": { … }, "lastSeq": 57 }
// same uuid live+transcript → upsert, not duplicate

③ Send a prompt

Sends are admission-acks on both sides — a turn can block for hours on a human, so the RPC returns the moment the host accepts; completion only ever arrives on the stream. Divergence D6 lives in the payload shape, the send gate, and who echoes your bubble.

POST prompt — reply is an admission ack only
≠ D6 — plain blocks + mid-turn sends allowed vs client-built SDKUserMessage + idle-only gate
acpacpSessions.prompt
{ "json": {
    "sessionId": "7d1e4a02-…",
    "prompt": [ { "type": "text",
        "text": "Implement exponential retry for webhook delivery." } ]
} }
// ← { "accepted": true }

⤷ mid-turn sends are allowed — prompts queue on the adapter.

sdksessions.sendMessage · createUserMessage()
{ "json": {
    "sessionId": "6f0d1c2e-…",
    "message": {
      "type": "user",
      "message": { "role": "user", "content": [ { "type": "text",
          "text": "Implement exponential retry for webhook delivery." } ] },
      "parent_tool_use_id": null
    }
} }
// ← { "accepted": true }

⤷ requires status "idle" — mid-turn sends throw PRECONDITION_FAILED. The wire shape is the SDK's own message type, built client-side.

session/prompt — stays unanswered until the turn ends (phase ⑧)
acpacp-sessions.ts:296
{ "jsonrpc": "2.0", "id": 10, "method": "session/prompt",
  "params": {
    "sessionId": "sess_9f3k…",
    "prompt": [ { "type": "text",
        "text": "Implement exponential retry for webhook delivery." } ]
} }
input.push(message) — the prompt iterable yields; no wire packet
sdkAsyncInputQueue
input.push(message)   // AsyncInputQueue<SDKUserMessage>
// the SDK's for-await loop consumes it and the subprocess
// begins the turn; turn end is detected from the stream
// (a `result` frame), not from any RPC response
user bubble echo — journaled BEFORE forwarding, fanned out
≠ D6 — the adapter never echoes prompts, so the ACP host journals your bubble instantly; the SDK bubble waits for the CLI's echo (phase ④)
acpseq 2 · one row per prompt block
{ "seq": 2, "sessionId": "7d1e4a02-…", "ts": 1752107660001,
  "frame": {
    "kind": "update",
    "update": {
      "sessionUpdate": "user_message_chunk",
      "content": { "type": "text",
        "text": "Implement exponential retry for webhook delivery." }
    } } }

⤷ fold: appendChunk → new user MessageItem { id: "user:2" }.

state → running — snapshot journaled, fanned out
acpseq 3 · composer stays enabled
{ "kind": "state",
  "state": {
    "sessionId": "7d1e4a02-…", "harness": "claude-agent-acp",
    "status": "running", "title": null,
    "pendingPermissions": [], "lastError": null,
    "currentMode": { "currentModeId": "default" },
    "lastSeq": 3, … } }
sdkseq 2 · composer disables (status ≠ idle)
{ "kind": "state",
  "state": {
    "sessionId": "6f0d1c2e-…", "status": "running",
    "model": "claude-sonnet-5", "permissionMode": "default",
    "pendingPermissions": [], "pendingUserDialogs": [],
    "pendingElicitations": [], "lastSeq": 2, … } }
acp · sdkthe state-update mechanism
// host mutates SessionScopedState, then journals the WHOLE
// snapshot. Fold: not a timeline item —
// timeline.state = frame.state  (replace, never merge)

④ Text streams back

Divergence D2, in the flesh. Agent → host → journal → every socket, one call. Both sides stream live, token by token; ACP's rows are semantic chunks that are never replaced, the SDK's are the raw model stream, later superseded by an authoritative message.

agent_message_chunk — journaled at host, fanned out same call
≠ D2 — streamed live, one row per text delta; chunks accumulate and are never replaced
acpseq 4 · session/update notification, verbatim
{ "seq": 4, "sessionId": "7d1e4a02-…", "ts": 1752107663210,
  "frame": { "kind": "update", "update": {
    "sessionUpdate": "agent_message_chunk",
    "content": { "type": "text",
      "text": "I'll inspect the delivery path and existing tests." }
} } }

⤷ fold appendChunk: last item is an agent message → blocks[last].text += chunk — merged in place, no new item. agent_thought_chunk is identical, folds to role "thought". The SDK's consolidated assistant message never crosses: the adapter diffs it against the already-streamed blocks (acp-agent.js:658) and forwards only an un-streamed tail.

stream_event delta — one journal row PER delta
≠ D2 — every delta burns a seq; the journal grows ~10× faster for the same conversation
sdkseq 3…5 · raw Anthropic stream event, verbatim
{ "seq": 3, "sessionId": "6f0d1c2e-…", "ts": 1752191830123,
  "frame": { "kind": "sdk", "message": {
    "type": "stream_event",
    "uuid": "3c9d41aa-…", "session_id": "c1a2d3e4-…",
    "event": {
      "type": "content_block_delta", "index": 0,
      "delta": { "type": "text_delta", "text": "I'll inspect the " }
    },
    "parent_tool_use_id": null
} } }

⤷ fold appendPartialBlock: accrete into MessageItem{ partial: true, id: "message:assistant:3c9d…:0" }.

authoritative assistant message — replaces the partial
sdkseq 6 · complete message, same uuid
{ "kind": "sdk", "message": {
    "type": "assistant", "uuid": "3c9d41aa-…",
    "message": {
      "role": "assistant",
      "content": [
        { "type": "text",
          "text": "I'll inspect the delivery path and existing tests." },
        { "type": "tool_use", "id": "toolu_01AbCdEfGh", "name": "Write",
          "input": { "file_path": "src/retry.ts", "content": "…" } }
      ] }
} }

⤷ fold: same uuid → replace the partial wholesale (partial: false); the embedded tool_use spawns ToolCallItem{ status: "pending" }. The SDK user prompt also echoes back here as an sdk user frame — that echo is what lands in the timeline.

acp side
// no replacement pass exists on ACP: chunks were already
// final, and the tool call arrives as its own frame (next)

⑤ Tool call needs permission

The parked-promise pattern both sides share: the agent asks, the host parks the ask + journals two rows (request, then state snapshot), the answer RPC resolves the parked promise back into the agent. Divergence D3 is what rides the wire and who constructs the decision.
observed in the current real-adapter lane: Claude can emit two Bash tool uses in parallel, but claude-agent-acp 0.56.0 exposes their permission callbacks to the client one at a time. Superset supports a true multi-request stack; the deterministic ACP adapter pins that case.

tool_call frame — UI-ready at birth
≠ D2/D3 — title, kind, status born at the protocol layer; on SDK they're derived at fold time
acpseq 5
{ "kind": "update", "update": {
    "sessionUpdate": "tool_call",
    "toolCallId": "toolu_01XyZaBcDe",
    "title": "Write src/retry.ts",
    "kind": "edit",
    "status": "pending",
    "rawInput": { "file_path": "src/retry.ts", "content": "…" }
} }

⤷ fold upsertToolCall → new ToolCallItem. Later tool_call_update frames are patches: present fields replace, absent keep — merged only at fold time, each patch its own row.

sdk side
// the tool call already rode inside the authoritative
// assistant message in ④ — no separate tool-call frame
session/request_permission — JSON-RPC REQUEST, parks at the host
acpacp-sessions.ts:731
{ "jsonrpc": "2.0", "id": 7,
  "method": "session/request_permission",
  "params": {
    "sessionId": "sess_9f3k…",
    "toolCall": { "toolCallId": "toolu_01XyZaBcDe",
      "title": "Write src/retry.ts", "kind": "edit", "status": "pending" },
    "options": [
      { "optionId": "allow",  "name": "Allow",  "kind": "allow_once" },
      { "optionId": "reject", "name": "Reject", "kind": "reject_once" }
    ] } }

⤷ host parks the response promise in pendingResolvers keyed by the JSON-RPC id ("7") and pushes onto state.pendingPermissions.

canUseTool() — in-process callback; a promise the host doesn't resolve yet
sdksessions.ts:482
canUseTool("Write",
  { file_path: "src/retry.ts", content: "…" },
  { requestId: "req_011CNxT9aB",
    toolUseID: "toolu_01AbCdEfGh",
    suggestions: [ /* addRules … */ ],
    signal })          // ← parkPermission stores a Deferred
                       //    keyed by the SDK's own requestId

⤷ same parking pattern, no wire: the Deferred sits in one of three pending maps (permissions / dialogs / elicitations), each with its own frames and RPC.

permission_requested + state — 2 rows journaled, fanned out
≠ D3 — ACP: the options ride the wire, client renders and never decides · SDK: raw input + suggestions, client invents the Allow/Always/Deny UX
acpseq 6, then seq 7 state
{ "kind": "permission_requested",
  "pending": {
    "requestId": "7",
    "toolCall": { "toolCallId": "toolu_01XyZaBcDe",
      "title": "Write src/retry.ts", "kind": "edit", "status": "pending" },
    "options": [
      { "optionId": "allow",  "name": "Allow",  "kind": "allow_once" },
      { "optionId": "reject", "name": "Reject", "kind": "reject_once" }
    ],
    "requestedAt": 1752107665004 } }

{ "kind": "state", "state": {
    "status": "awaiting_permission",
    "pendingPermissions": [ { "requestId": "7", … } ],
    "lastSeq": 7, … } }

⤷ the UI acts on the state copy: PermissionStack renders from state.pendingPermissions. The timeline copy attaches a PermissionView to its ToolCallItem — for the record.

sdkseq 7, then seq 8 state
{ "kind": "permission_requested",
  "request": {
    "requestId": "req_011CNxT9aB",
    "toolUseID": "toolu_01AbCdEfGh",
    "toolName": "Write",
    "input": { "file_path": "src/retry.ts", "content": "…" },
    "suggestions": [ { "type": "addRules", "behavior": "allow",
        "rules": [ { "toolName": "Write",
                     "ruleContent": "src/retry.ts" } ],
        "destination": "localSettings" } ],
    "requestedAt": 1752191835001 } }

{ "kind": "state", "state": {
    "status": "requires_action",
    "pendingPermissions": [ { "requestId": "req_011CNxT9aB", … } ],
    "lastSeq": 8, … } }

⤷ PendingActions renders every card from the three pending arrays in state — never from the timeline.

answer — respondToPermission
≠ D3 — an optionId tap vs a client-constructed PermissionResult
acp
{ "json": {
    "sessionId": "7d1e4a02-…",
    "requestId": "7",
    "outcome": { "outcome": "selected", "optionId": "allow" }
} }
// ← { "status": "resolved" }

⤷ the client sends back an optionId it was given — zero decision logic on the device.

sdk
{ "json": {
    "sessionId": "6f0d1c2e-…",
    "requestId": "req_011CNxT9aB",
    "response": {
      "behavior": "allow",
      "updatedInput": { "file_path": "src/retry.ts", "content": "…" },
      "toolUseID": "toolu_01AbCdEfGh",
      "decisionClassification": "user_temporary"
    }
} }
// ← { "status": "resolved" }

⤷ the client builds a full PermissionResult: echo (or edit) the input, classify the decision; “Always allow” sends updatedPermissions: suggestions. bypassPermissions is stripped at this boundary.

unpark — the parked JSON-RPC request finally gets its response
acp
{ "jsonrpc": "2.0", "id": 7,
  "result": { "outcome": { "outcome": "selected", "optionId": "allow" } } }

⤷ turn-cancel or adapter death settles unanswered asks as { "outcome": "cancelled" } via the request's AbortSignal — nothing leaks.

Deferred resolves — canUseTool returns in-process
sdk
// settlePermission(requestId).resolve(…) → canUseTool returns:
{ behavior: "allow",
  updatedInput: { file_path: "src/retry.ts", content: "…" },
  toolUseID: "toolu_01AbCdEfGh",
  decisionClassification: "user_temporary" }

⤷ the SDK runs the tool with updatedInput — which is also exactly how question answers travel, next phase.

permission_resolved + state → running — 2 rows, fanned out
acp · sdksame two-row pattern both sides
{ "kind": "permission_resolved", "requestId": …,
  "outcome"/"response": { … } }        // resolution edge

{ "kind": "state", "state": { "status": "running",
    "pendingPermissions": [], … } }    // pending card disappears
// SDK: status returns to "running" only once ALL THREE
// pending maps are empty

⑥ The agent asks a question — AskUserQuestion

captured against claude-agent-acp 0.56.0 with an authenticated Sonnet/low real-adapter run
Claude asks: “Which failures should retry?” The turn stays parked — answering does not create a new prompt. Divergence D4: the interpretive work happens at opposite ends.

elicitation/create (mode form) — parks like a permission
≠ D4 — rides the unstable elicitation.form capability advertised at initialize (phase ①)
acpacp-sessions.ts:777
{ "jsonrpc": "2.0", "id": 41, "method": "elicitation/create",
  "params": {
    "mode": "form",
    "sessionId": "sess_9f3k…",
    "toolCallId": "toolu_question_01",
    "message": "Which failures should retry?",
    "requestedSchema": {
      "type": "object",
      "properties": {
        "question_0": {
          "type": "string",
          "oneOf": [
            { "const": "429 + 5xx" },
            { "const": "Also network failures" }
          ] },
        "question_0_custom": { "type": "string" }
      } } } }

⤷ extractElicitationQuestions() keeps only question_<n> fields (*_custom ignored — mobile is tap-only); non-form modes → cancel; no recognizable questions → decline.

canUseTool("AskUserQuestion") — a question is just a tool asking permission
sdksessions.ts:482
canUseTool("AskUserQuestion",
  { questions: [ {
      header: "Retry scope",
      question: "Which failures should retry?",
      multiSelect: false,
      options: [
        { label: "429 + 5xx" },
        { label: "Also network failures" } ] } ] },
  { requestId: "req_011CQq2xYz",
    toolUseID: "toolu_question_01", signal })

⤷ parked in the same permissions map as any Write or Bash — the host doesn't know questions exist.

host mints a question card — one per question_<n>, one at a time
≠ D4 — the question becomes an ordinary permission card; mobile has zero question-specific code
acpseq 10 + state seq 11
{ "kind": "permission_requested",
  "pending": {
    "requestId": "q-3f7c9d2e-…",          // randomUUID, host-minted
    "toolCall": { "toolCallId": "toolu_question_01",
      "title": "Which failures should retry?",
      "kind": "other", "status": "pending" },
    "options": [
      { "optionId": "option-0", "name": "429 + 5xx",
        "kind": "allow_once" },
      { "optionId": "option-1", "name": "Also network failures",
        "kind": "allow_once" },
      { "optionId": "skip", "name": "Skip", "kind": "reject_once" }
    ],
    "requestedAt": 1752107668210 } }
// + { "kind": "state", "state": { "status": "awaiting_permission", … } }

⤷ options = enum labels + Skip; card title = the question. Mobile renders and resolves this exactly like the Write ask in ⑤.

question forwarded raw — client special-cases it
≠ D4 — parseAskUserQuestions() sniffs toolName client-side; the host has zero question-specific code
sdkseq 11 + state seq 12
{ "kind": "permission_requested",
  "request": {
    "requestId": "req_011CQq2xYz",
    "toolUseID": "toolu_question_01",
    "toolName": "AskUserQuestion",
    "input": { "questions": [ {
        "header": "Retry scope",
        "question": "Which failures should retry?",
        "multiSelect": false,
        "options": [ { "label": "429 + 5xx" },
                     { "label": "Also network failures" } ] } ] },
    "suggestions": [],
    "requestedAt": 1752191841007 } }
// + { "kind": "state", "state": { "status": "requires_action", … } }

⤷ UI: parseAskUserQuestions(request) matches toolName === "AskUserQuestion" and strictly validates input.questions — match → multi-question QuestionCard; mismatch → plain permission card.

answer the question — same respondToPermission RPC
≠ D4 — ACP: card n resolved mints card n+1 until the form is done · SDK: one RPC carries every answer
acp
{ "json": {
    "sessionId": "7d1e4a02-…",
    "requestId": "q-3f7c9d2e-…",
    "outcome": { "outcome": "selected", "optionId": "option-0" }
} }
// ← { "status": "resolved" }
sdkbuildQuestionAnswers()
{ "json": {
    "sessionId": "6f0d1c2e-…",
    "requestId": "req_011CQq2xYz",
    "response": {
      "behavior": "allow",
      "updatedInput": {
        "questions": [ …input echoed… ],
        "answers": { "Which failures should retry?": "429 + 5xx" }
      },
      "toolUseID": "toolu_question_01",
      "decisionClassification": "user_temporary"
    }
} }
// ← { "status": "resolved" }

⤷ tapped chips + free text merged per question (deduped, comma-joined), keyed by question text. Declining sends behavior: "deny".

answer reaches the agent — the turn was never interrupted; no new prompt exists
acphost aggregates cards → answers the parked id 41
{ "jsonrpc": "2.0", "id": 41,
  "result": {
    "action": "accept",
    "content": { "question_0": "429 + 5xx" }
} }

⤷ Skip → field omitted; cancel mid-form → action: "cancel" aborts the tool. The adapter converts this into its own inner canUseTool result.

sdkin-process
// canUseTool("AskUserQuestion", …) resolves to:
{ behavior: "allow",
  updatedInput: { questions: […],
    answers: { "Which failures should retry?": "429 + 5xx" } },
  toolUseID: "toolu_question_01",
  decisionClassification: "user_temporary" }

⤷ one hop instead of ACP's three (question → cards → aggregate), because host and agent share a process.

⑦ Two devices — the answer race

Second phone, same account. It cold-attaches with the phase-② triple; the fetched state already carries the pending card, so it renders the question before a single WS frame arrives. Then both devices answer.

— no divergence in this phase: identical race semantics on both sides (first answer wins, the second RPC returns already_resolved, the loser's card clears via the state frame). Switch the filter back to All steps to see the packets.
second subscriber attached — nothing was “sent” to it
acp · sdkdevices never talk to each other
// Device B ran the phase-② triple: get + getMessages + WSS
// The fetched state already contained
//   pendingPermissions: [ the question card ]
// From here every journal append fans out to BOTH sockets
// in the same call.
both answer — first wins — the second RPC is a no-op, not an error
acp · sdksettled by the parked promise
// Device B's respondToPermission → { "status": "resolved" }
// Device A answers the same requestId a moment later:
POST …/respondToPermission { "requestId": "q-3f7c9d2e-…", … }
// ← { "status": "already_resolved" }
convergence — resolution + state fan out ×2 sockets
acp · sdk
{ "kind": "permission_resolved", "requestId": "q-3f7c9d2e-…", … }
{ "kind": "state", "state": { "status": "running",
    "pendingPermissions": [], … } }
// A's card clears because the state frame REPLACED its
// sessionState — not because A was told it lost a race.
// Any number of devices stay consistent by this one rule.

⑧ Tool result & turn end

The tool finishes, then the turn closes — divergence D7: ACP's turn-end never appears on the device wire; the SDK journals an explicit result with usage and cost.

tool completes — journaled, fanned out
acpseq 14 · the same patch shape, now with output
{ "kind": "update", "update": {
    "sessionUpdate": "tool_call_update",
    "toolCallId": "toolu_01XyZaBcDe",
    "status": "completed",
    "content": [ { "type": "content",
        "content": { "type": "text", "text": "Wrote src/retry.ts" } } ]
} }

⤷ fold: patch-merge onto the ToolCallItem → completed, content attached.

sdkseq 15 · result arrives as a user-role message (API convention)
{ "kind": "sdk", "message": {
    "type": "user",
    "message": { "role": "user", "content": [
      { "type": "tool_result",
        "tool_use_id": "toolu_01AbCdEfGh",
        "content": [ { "type": "text", "text": "Wrote src/retry.ts" } ],
        "is_error": false } ] }
} }

⤷ fold: match tool_use_id → flip the ToolCallItem to completed / error in place.

turn ends — the prompt RPC from ③ finally returns; host-internal only
≠ D7 — never journaled; devices only ever see the state snapshot two hops down. A failed prompt journals prompt_rejected instead (fold retro-marks the user bubble failed)
acp
{ "jsonrpc": "2.0", "id": 10,
  "result": { "stopReason": "end_turn" } }
result frame — explicit, journaled turn end
≠ D7 — duration, num_turns, cost and usage journaled for free; fold keeps it as meta.lastResult
sdkseq 16 · sessions.ts:591
{ "kind": "sdk", "message": {
    "type": "result", "subtype": "success",
    "duration_ms": 41230, "num_turns": 3,
    "total_cost_usd": 0.084,
    "result": "Done. Retry with backoff implemented.",
    "usage": { … }
} }

⤷ host rule: a result with zero pending callbacks is the authoritative turn end — the CLI doesn't reliably emit session_state_changed(idle).

state → idle — the closing snapshot; composer re-enables
acpseq 15
{ "kind": "state", "state": {
    "status": "idle", "lastStopReason": "end_turn",
    "title": "Webhook retry with backoff",
    "pendingPermissions": [], "lastSeq": 15, … } }
sdkseq 17
{ "kind": "state", "state": {
    "status": "idle",
    "pendingPermissions": [], "pendingUserDialogs": [],
    "pendingElicitations": [], "lastSeq": 17, … } }

⑨ Reconnect, gaps, reset

Both sides use cursor replay inside one journal incarnation. The divergence is the history source used for a full rebuild — D1 again, and where it bites hardest.

redial after background — exponential backoff 250ms → 10s
acp · sdk
WSS …/stream ?since=15        // lastSeq the device has folded
// dedupe / gap rules as in ② — “exactly once, in order” is
// enforced by the CLIENT with two integer comparisons;
// the server just replays. The current ACP client performs only
// a shallow envelope check; full runtime parsing remains TODO.
terminal reset — seq 0, then the socket closes (1000)
≠ D1 — same shape, but the SDK adds latestSeq and a whole epoch mechanism (retry(), cursor_ahead)
acpcursor evicted from the ring
{ "seq": 0, "sessionId": "7d1e4a02-…", "ts": …,
  "frame": { "kind": "reset",
             "reason": "journal_evicted" } }
// also: invalid_since, session_not_found, session_load_failed
// not journaled — seq 0 is out-of-band by construction
sdkring evicted / host restarted / retry epoch
{ "seq": 0, "sessionId": "6f0d1c2e-…", "ts": …,
  "frame": { "kind": "reset",
             "reason": "cursor_unavailable",
             "latestSeq": 57 } }
// also fired when the cursor is AHEAD of the journal.
// retry() starts a new epoch: fresh journal, seq restarts
// at 1, old subscribers get reset("session_restarted")
full resync
≠ D1 · critical — after a host restart ACP resurrects from a registry row plus a full session/load replay into a fresh ring; the direct SDK design reads pages from the JSONL transcript.
acpuseAcpSession.ts:113 — same reads as ②
GET  …/acpSessions.get             // fresh SessionScopedState
GET  …/acpSessions.getMessages     // newest journal page
// refold from scratch (pure) → resubscribe ?since=folded.lastSeq
// older pages on demand via cursor "s<seq>"

⤷ the session lists as "offline" from its SQLite registry row. The first live call (prompt / getMessages / stream attach) runs session/load and rebuilds a fresh journal. Numeric seqs restart at 1; because the cursor has no incarnation id, an overlapping pre-restart cursor is not always detected. Epoch-aware cursors are required follow-up work.

sdkuseClaudeSessionThread.ts:178
GET  …/sessions.get                     // state, incl. lastSeq
WSS  …/stream ?since=state.lastSeq      // open FIRST, buffer
GET  …/sessions.getMessages ×N          // drain full transcript
// timelineFromSessionMessages(history)
// → fold buffered envelopes on top → hydrated

⤷ transcript rows fold at seq 0 (uuid-keyed), buffered live envelopes land after — the overlap upserts instead of duplicating. More moving parts; history survives.

⑩ Subagents

captured against claude-agent-acp 0.56.0; the current acceptance fixture runs a named five-agent Sonnet Workflow to completion with real token and tool usage; lifecycle details cross-checked against sdk 0.3.201 types
Native: the Agent tool is a first-class spawn — the subagent has an id, a type, its own transcript file, and every message it produces is tagged with the spawning tool_use. Over ACP: the adapter renames the spawn to a generic think tool call, deliberately drops subagent prose, and tags nested tool calls with non-standard _meta that our fold happens to understand — D8.

the spawn — Agent (a.k.a. Task) tool_use
≠ D8 — SDK: a typed spawn with subagent_type / model / isolation on the wire · ACP: a generic think call; the spawn options survive only because rawInput echoes the input
acptools.js:19 — case "Agent"/"Task" → kind "think"
{ "kind": "update", "update": {
    "sessionUpdate": "tool_call",
    "toolCallId": "toolu_01TaskAa",
    "title": "Audit retry edge cases",     // = input.description
    "kind": "think",                       // ← a SPAWN, labelled a thought
    "status": "pending",
    "content": [ { "type": "content", "content": {
        "type": "text", "text": "Find every call site that retries webhook delivery…" } } ],
    "rawInput": { "description": "Audit retry edge cases",
      "prompt": "Find every call site that retries webhook delivery…",
      "subagent_type": "Explore", "model": "haiku" },
    "_meta": { "claudeCode": { "toolName": "Task" } }
} }

⤷ subagent_type / model / isolation are not protocol fields — no ACP client can rely on them; they ride only inside the untyped rawInput echo.

sdkAgentInput — sdk-tools.d.ts:410
{ "kind": "sdk", "message": {
    "type": "assistant", "uuid": "9e2f61bb-…",
    "message": { "role": "assistant", "content": [
      { "type": "tool_use", "id": "toolu_01TaskAa", "name": "Agent",
        "input": {
          "description": "Audit retry edge cases",
          "prompt": "Find every call site that retries webhook delivery…",
          "subagent_type": "Explore",
          "model": "haiku",
          "name": "recon-retries",
          "run_in_background": false,
          "isolation": "worktree"
        } } ] }
} }

⤷ the input is a typed spawn: subagent_type · model · name · run_in_background · mode · isolation ("worktree"/"remote"). The client sees exactly what was launched, and with what powers.

subagent stream — every message tagged with the spawning tool_use
sdkparent_tool_use_id — first-class on every SDKMessage
{ "kind": "sdk", "message": {
    "type": "assistant", "uuid": "b7c31d9a-…",
    "session_id": "c1a2d3e4-…",
    "parent_tool_use_id": "toolu_01TaskAa",   // ← the spawn
    "message": { "role": "assistant", "content": [
      { "type": "text", "text": "Scanning delivery/ for retry loops…" },
      { "type": "tool_use", "id": "toolu_02Grep", "name": "Grep",
        "input": { "pattern": "retry", "path": "src/delivery" } }
    ] }
} }

⤷ prose AND tool calls arrive, all tagged. Identity outlives the stream: listSubagents(sessionId) → getSubagentMessages(sessionId, agentId) replays the subagent's own JSONL transcript (…/<sessionId>/subagents/agent-<agentId>.jsonl); SubagentStart/SubagentStop hooks carry agent_id + agent_type.

adapter drops subagent prose — by design
≠ D8 · critical — text/thinking from a subagent never reaches ANY ACP client; there is no frame for “subagent said something”
acpacp-agent.js:1729 — comment is verbatim from source
// subagent assistant message (parent_tool_use_id !== null):
// "It is never streamed live and its text/thinking is internal
//  to the tool call — keep dropping it so subagent prose
//  doesn't leak into the top-level feed."
content = message.message.content.filter(
  (item) => item.type !== "text" && item.type !== "thinking");

⤷ only the tool_use blocks survive the filter and continue to the next hop.

nested tool_call — nesting rides non-standard _meta
acpacp-agent.js:4085 — parentToolUseId stamped on every subagent-born update
{ "kind": "update", "update": {
    "sessionUpdate": "tool_call",
    "toolCallId": "toolu_02Grep",
    "title": "grep \"retry\" src/delivery",
    "kind": "search", "status": "pending",
    "rawInput": { "pattern": "retry", "path": "src/delivery" },
    "_meta": { "claudeCode": {
        "toolName": "Grep",
        "parentToolUseId": "toolu_01TaskAa" } }   // ← the whole tree
} }

⤷ _meta is ACP's sanctioned extension point, but nothing about this key is standard: a generic ACP client renders this call flat, as a sibling of the Task it belongs to.

who can rebuild — and later re-read — the subagent
acpsession-protocol fold.ts — upsertToolCall re-homing
// OUR fold knows the adapter's private key:
claudeParentToolUseId(src) =>
  src._meta?.claudeCode?.parentToolUseId ?? null
// a tagged frame re-homes the item under its Task — splice
// from top level, re-insert as ToolCallItem.children[]
// Different adapter → different key. Standard client → no tree.
// session/load can rebuild retained tree frames from native history;
// frames older than the host ring remain unavailable.
sdksdk.d.ts:760 — any time later
// by (sessionId, agentId), long after the stream:
getSubagentMessages("c1a2d3e4-…", "3f2b…")
// → the subagent's full message history from its own
//   transcript file; survives host restarts

⑪ Native tasks — graph in, checklist out

reconstructed from adapter source — claude-agent-acp 0.56.0 dist + sdk 0.3.201 types · not a captured run
Native: an ID-addressed task graph (TaskCreate / TaskUpdate / TaskList / TaskGet) with dependencies, owner and metadata. Over ACP: the four tools are suppressed and each mutation re-emits one flat plan snapshot — content + status only — D9.

TaskCreate / TaskUpdate — building the graph, on the wire verbatim
sdksdk-tools.d.ts:2381 — TaskCreateInput / TaskUpdateInput
{ "type": "tool_use", "name": "TaskCreate",
  "input": { "subject": "Add retry queue",
    "description": "Durable queue with exponential backoff",
    "activeForm": "Adding retry queue",
    "metadata": { "epic": "webhooks" } } }
// → tool_result: { "task": { "id": "task-1", … } }   ← addressable

{ "type": "tool_use", "name": "TaskUpdate",
  "input": { "taskId": "task-2", "status": "in_progress",
    "addBlockedBy": [ "task-1" ],          // dependency edges
    "owner": "recon-retries" } }           // who's on it

// TaskGet  → { task: { id, subject, description, status,
//                      blocks: [], blockedBy: ["task-1"] } }
// TaskList → { tasks: [ { id, subject, status, owner,
//                         blockedBy: [] }, … ] }

⤷ ids, dependency edges (blocks/blockedBy), owner, metadata — a real graph, queryable mid-run. TodoWrite (content/status/activeForm) still exists alongside as the lightweight form.

adapter: suppress the tools, accumulate private state
acpacp-agent.js:3797 · tools.js:598
// no tool_call frame is ever emitted for these:
shouldEmitToolCall = toolName !== "TodoWrite"
                     && !isTaskTool(toolName)   // TaskCreate/Update/List/Get
// at tool_result, mutate an in-adapter Map instead:
applyTaskCreate(state, input, output)  // keeps subject, status,
applyTaskUpdate(state, input)          // activeForm, description
// TaskList / TaskGet: invisible — reads never cross
one flat plan frame — re-emitted whole after every mutation
≠ D9 — ID-addressed graph in, flat checklist out; every entry's priority is invented as "medium" by construction
acptools.js:629 — taskStateToPlanEntries
{ "kind": "update", "update": {
    "sessionUpdate": "plan",
    "entries": [
      { "content": "Add retry queue",
        "status": "in_progress", "priority": "medium" },
      { "content": "Wire dead-letter alerts",
        "status": "pending",     "priority": "medium" }
    ] } }

⤷ entries carry content + status only; priority is hardcoded "medium". Dropped on the adapter floor: taskId · description · activeForm · blocks/blockedBy · owner · metadata. TodoWrite takes the same door (tools.js:553) and loses its activeForm too. Fold: PlanItem is replaced wholesale per frame — no diffing possible without ids.

⑫ Plan mode — enter, plan, exit

observed in the ACP spike · recorded in plans/done/20260710-session-harness-acp.md · shapes matched to adapter source 0.56.0
The one complex interaction ACP handles better than raw: plan-exit crosses as an ordinary permission request wearing kind:"switch_mode", with the plan document as content and the target permission modes as tappable options.

session/set_mode — entering plan mode; echoed back as a notification
acpsame RPC as phase ①'s third round-trip
{ "jsonrpc": "2.0", "id": 12, "method": "session/set_mode",
  "params": { "sessionId": "sess_9f3k…", "modeId": "plan" } }
// ← { "id": 12, "result": {} }
// then, as its own session/update notification:
{ "sessionUpdate": "current_mode_update",
  "currentModeId": "plan" }
// → journaled inside the next state snapshot (currentMode)
plan exit — a permission request wearing kind "switch_mode"
≠ — the adapter turns ExitPlanMode into a ready-made mode-switch decision: optionIds ARE permission modes, the plan document rides as ordinary content
acpacp-agent.js:2195 — options filtered by availableModes
{ "jsonrpc": "2.0", "id": 19, "method": "session/request_permission",
  "params": {
    "sessionId": "sess_9f3k…",
    "toolCall": {
      "toolCallId": "toolu_01ExitPlan",
      "title": "Ready to code?",
      "kind": "switch_mode",
      "content": [ { "type": "content", "content": {
          "type": "text", "text": "## Plan\n1. Add retry queue…" } } ],
      "rawInput": { "plan": "## Plan\n1. Add retry queue…" }
    },
    "options": [
      { "optionId": "auto",        "name": "Yes, and use \"auto\" mode",       "kind": "allow_always" },
      { "optionId": "acceptEdits", "name": "Yes, and auto-accept edits",      "kind": "allow_always" },
      { "optionId": "default",     "name": "Yes, and manually approve edits", "kind": "allow_once"    },
      { "optionId": "plan",        "name": "No, keep planning",               "kind": "reject_once"   }
    ] } }

⤷ crosses to devices via the exact phase-⑤ machinery: permission_requested row + state snapshot; mobile renders option buttons it already knows how to render. A bypassPermissions option is prepended only when the adapter allows bypass.

accept — the mode actually changes, atomically with the answer
acpacp-agent.js:2195 — accept path
{ "id": 19, "result": { "outcome": {
    "outcome": "selected", "optionId": "acceptEdits" } } }
// adapter reacts: emits
{ "sessionUpdate": "current_mode_update",
  "currentModeId": "acceptEdits" }
// and resolves ExitPlanMode inside the SDK as
//   { behavior: "allow", updatedInput, updatedPermissions:
//     [ { type: "setMode", mode: "acceptEdits",
//         destination: "session" } ] }
// tapping "plan" instead → deny:
//   "User rejected request to exit plan mode."
canUseTool("ExitPlanMode") — same tool, zero interpretation
≠ — the SDK forwards a plain permission ask; that accepting should ALSO flip permissionMode is semantics the client must know by itself
sdkjournaled as phase ⑤'s permission_requested shape
canUseTool("ExitPlanMode",
  { plan: "## Plan\n1. Add retry queue…" },
  { requestId, toolUseID,
    suggestions: [ /* setMode … */ ] })
// the wire says "a tool wants permission" — nothing more.
// Building "Ready to code?" UX, offering target modes, and
// flipping permissionMode on accept is all client code.

⑬ Terminal & background Bash

reconstructed from adapter source — claude-agent-acp 0.56.0 dist + sdk 0.3.201 types · not a captured run
ACP the spec has client-owned terminals (terminal/create, terminal/output, …) gated on clientCapabilities.terminal. Our host says false — and this adapter wouldn't call them anyway: it never invokes terminal/* and gates Bash output streaming on a non-standard _meta capability instead.

what our host advertised back in phase ①
acpacp-sessions.ts:612 — initialize
"clientCapabilities": {
  "fs": { "readTextFile": false, "writeTextFile": false },
  "terminal": false,          // ← no client-owned terminals
  "elicitation": { "form": {} }
}
// and no _meta["terminal_output"] either — see below

⤷ grep the adapter dist for terminal/create: zero calls. Standard ACP terminals are simply not implemented by claude-agent-acp; its only terminal integration is its own _meta extension.

Bash on the wire — adapter-owned "execute", output only at the end
≠ — long-running commands show a heartbeat, not output; stdout arrives in one lump at completion. The SDK side can poll incremental output on demand
acptools.js — case "Bash" → kind "execute" · acp-agent.js:1751 tool_progress
{ "kind": "update", "update": {
    "sessionUpdate": "tool_call",
    "toolCallId": "toolu_01Bash",
    "title": "bun test --watch=false",
    "kind": "execute", "status": "pending",
    "content": [ { "type": "content", "content": {
        "type": "text", "text": "Run the test suite" } } ],
    "rawInput": { "command": "bun test --watch=false",
      "description": "Run the test suite",
      "run_in_background": true },
    "_meta": { "claudeCode": { "toolName": "Bash" } } } }

// long-running: heartbeat patches, NOT output
{ "sessionUpdate": "tool_call_update",
  "toolCallId": "toolu_01Bash", "status": "in_progress",
  "_meta": { "claudeCode": { "toolResponse":
      { "elapsedTimeSeconds": 45 } } } }

// completion — stdout lands once, in one piece
{ "sessionUpdate": "tool_call_update",
  "toolCallId": "toolu_01Bash", "status": "completed",
  "content": [ /* full output as text */ ], "rawOutput": { … } }

⤷ a client that advertises _meta["terminal_output"] = true unlocks the adapter's own lifecycle (_meta.terminal_info / terminal_output / terminal_exit on these same frames) — still adapter-owned, still not ACP's terminal/* methods.

native background shell — spawn, poll, kill — all addressable
sdkBashInput.run_in_background — sdk-tools.d.ts:430 · TaskStop :2628
{ "type": "tool_use", "name": "Bash",
  "input": { "command": "bun test --watch=false",
             "run_in_background": true } }
// → tool_result names the background task; from then on:
//    TaskOutput { task_id }   — incremental output, on demand
//    TaskStop   { task_id }   — kill it
// result frames enumerate background_tasks:
//    { id, type: "shell", command, status, … }

⤷ over ACP those follow-ups have no special case in the adapter switch — TaskOutput / TaskStop surface as generic kind:"other" tool calls (tools.js default arm).

⑭ Workflows

reconstructed from adapter source — claude-agent-acp 0.56.0 dist + sdk 0.3.201 types · not a captured run
Native: a first-class Workflow tool runs a multi-agent program — agent() / parallel() / pipeline() / phase() — with a run id and a resume path. Over ACP: one opaque tool call — D10.

Workflow tool_use — a resumable multi-agent program
sdkWorkflowInput — sdk-tools.d.ts:2462 · WorkflowOutput :3443
{ "type": "tool_use", "name": "Workflow",
  "input": {
    "script": "export const meta = { name: \"triage\", … }
       const findings = await parallel([
         agent(\"scan delivery/\"), agent(\"scan api/\") ])
       await phase(\"fix\", () => pipeline(…))",
    "args": { "area": "webhooks" } } }
// → tool_result (WorkflowOutput):
{ "status": "async_launched",
  "taskId": "task-9", "taskType": "local_workflow",
  "workflowName": "triage",
  "runId": "run-01H…",              // ← resumeFromRunId target
  "transcriptDir": "…/subagents/",
  "scriptPath": "…/workflows/triage.ts" }

⤷ the run is addressable (taskId/runId), its transcripts land on disk, and a re-run with resumeFromRunId replays completed agent() calls from cache instead of re-executing.

the same thing over ACP — one opaque tool call
≠ D10 — a resumable multi-agent program crosses as kind "other"; its lifecycle events don't cross at all
acptools.js default arm — no "Workflow" case exists
{ "kind": "update", "update": {
    "sessionUpdate": "tool_call",
    "toolCallId": "toolu_01Wf",
    "title": "Workflow",            // default: title = tool name
    "kind": "other", "status": "pending",
    "rawInput": { "script": "export const meta = …", "args": { … } },
    "_meta": { "claudeCode": { "toolName": "Workflow" } } } }
// …then a single "completed" patch carrying the
// WorkflowOutput as rawOutput. That's the whole story:
// task_started / task_summary system frames → no case in the
// adapter's system-subtype switch → dropped. Sub-run activity
// crosses only as whatever agent() spawns emit (phase ⑩ rules).

⑮ Goal setting

verified at type/dist level only — no captured frames exist for this one
Goals exist in the SDK as a streamed message type — not a tool — and the ACP adapter has no mapping for them. This is the cleanest example of D10: a capability that simply does not cross the wire.

active_goal — plumbed through the SDK stream
sdksdk.mjs (0.3.201) · StdoutMessage union, sdk.d.ts:6406
// bundled sdk.mjs forwards it into the message stream:
if (e.type === "active_goal") { this.inputStream.enqueue(e) }
// StdoutMessage names coreTypes.SDKActiveGoalMessage
// (alongside SDKTaskSummaryMessage, SDKPostTurnSummaryMessage)
// — but 0.3.201 exports no shape for it and ships no Goal
// tool in ToolInputSchemas: plumbing, not yet public API.
adapter: no mapping — goals do not cross
≠ D10 — zero mentions of "goal" in claude-agent-acp 0.56.0; an active_goal message falls through to the loop's catch-all
acpacp-agent.js:1787 message loop · utils.js:69 unreachable()
// known-but-ignored types get an explicit `break`:
//   tool_use_summary · auth_status · prompt_suggestion ·
//   conversation_reset · control_request_progress
// anything else — active_goal included — hits:
default: unreachable(message)   // logged, dropped, never emitted
aside — codex-acp DOES map goals… via _meta again
codex-acp 1.1.2dist/index.js — createCodexSessionInfoUpdate
// thread/goal/updated crosses as:
{ "sessionUpdate": "session_info_update",
  "_meta": { "codex": { "goal": {
      "objective": "…", "status": "…", "tokenBudget": … } } } }
// thread/goal/cleared → _meta.codex.goal: null
// standard ACP field? no — the extension point, again.
// Two adapters, two private _meta dialects for the same idea.

4Types per layer

The four load-bearing shapes, trimmed to essentials. Where the two sides are identical, the shape is shown once.
Current ACP validation status: the shared TypeScript surface is strict, but the runtime boundary is not fully Zod-checked. tRPC scalar inputs are parsed; ACP content uses shallow checks; relay outputs, nested WS frames, adapter responses and SQLite rows are still trusted after partial checks. The exact boundary matrix lives in packages/host-service/docs/acp-sessions.md.

L1 · wire envelope — what travels mobile ↔ host

Same wrapper on both sides. The difference is the frame union: 6 kinds vs 9 — three interaction channels instead of one.

ACPpackages/session-protocol/src/envelope.ts:4
export type SessionUpdateFrame =
  | { kind: "update"; update: SessionUpdate }   // ACP session/update, verbatim
  | { kind: "permission_requested"; pending: PendingPermission }
  | { kind: "permission_resolved"; requestId: string;
      outcome: RequestPermissionOutcome }
  | { kind: "prompt_rejected"; reason: string; promptStartSeq: number }
  | { kind: "state"; state: SessionScopedState }  // full snapshot on every change
  | { kind: "reset"; reason: string };            // cursor unservable — resync

export interface SessionUpdateEnvelope {
  seq: number;        // per-session, monotonic from 1, gapless
  sessionId: string;
  ts: number;
  frame: SessionUpdateFrame;
}
Claude SDKpackages/session-protocol/src/events.ts:25
export type SessionEventFrame =
  | { kind: "sdk"; message: SDKMessage }          // raw SDK frame, verbatim
  | { kind: "permission_requested"; request: PendingPermissionRequest }
  | { kind: "permission_resolved"; requestId: string;
      response: SessionPermissionResult }
  | { kind: "user_dialog_requested"; request: PendingUserDialogRequest }
  | { kind: "user_dialog_resolved"; requestId: string;
      response: UserDialogResult }
  | { kind: "elicitation_requested"; request: PendingElicitationRequest }
  | { kind: "elicitation_resolved"; requestId: string;
      response: ElicitationResult }
  | { kind: "state"; state: SessionScopedState }
  | { kind: "reset"; reason: string; latestSeq?: number };

export interface SessionEventEnvelope {
  seq: number; sessionId: string; ts: number;
  frame: SessionEventFrame;
}

L2 · journal row — what the host stores

The journal row IS the envelope on both sides, and the append is the same idea character for character.

Both — identical mechanismhost-service …/journal.ts · 99 vs 92 LOC
append(sessionId: string, frame: Frame): Envelope {
  const envelope = { seq: this.nextSeq, sessionId, ts: Date.now(), frame };
  this.nextSeq += 1;
  this.entries.push(envelope);
  if (this.entries.length > this.capacity) this.entries.shift(); // in-memory ring, cap 5,000
  return envelope;  // fanned out to WS subscribers in the same call
}

ACP — the bounded journal currently serves both catch-up and history: getMessages pages retained rows backwards (cursor s<seq>, message-frame kinds only). A restart uses the SQLite registry and session/load to rebuild a fresh ring, but rows beyond the cap remain unavailable. · Claude SDK — the journal is only a live-delivery buffer: history truth is Claude's native JSONL transcript; retry() opens a new journal epoch (seq restarts at 1).

L3 · host session surface — the harness wrapping the agent

One manager class each, same lifecycle verbs. ACP wraps a child process spoken to over JSON-RPC/stdio; the SDK holds an in-process query() handle whose callbacks are host-local.

ACP…/acp-sessions/acp-sessions.ts:166 · 978 LOC
export class AcpSessionManager {
  create({ sessionId, workspaceId })            // idempotent
  prompt({ sessionId, prompt })                 // ContentBlock[] → accepted
  respondToPermission({ sessionId, requestId, outcome })
  cancel · setMode · setConfigOption
  get · list · getMessages({ beforeSeq })       // pages the journal
  subscribe({ sessionId, since, onEnvelope })   // sync replay + live
}
// per session: claude-agent-acp CHILD PROCESS (ACP JSON-RPC over
// stdio), journal, subscribers, parked permission resolvers
// (Map<requestId, resolve>)
Claude SDK…/sessions/sessions.ts:123 · 965 LOC
export class ClaudeSessionManager {
  create(input)                                 // starts query(); idempotent
  retry(input)                                  // NEW Query + native transcript
  sendMessage(input)                            // requires status "idle"
  respondToPermission · respondToUserDialog · respondToElicitation
  interrupt · setModel · setPermissionMode · getCatalog
  get · list · getMessages(input)               // native JSONL transcript
  subscribe({ sessionId, since, onEnvelope })
}
// per session: IN-PROCESS query() handle (canUseTool / onUserDialog /
// onElicitation callbacks), AsyncInputQueue, journal, 3 pending maps
// + claude-runtime.ts: executable resolution, trusted-shell env (228 LOC)

L4 · client timeline item — what the fold produces for the UI

ACP nests subagents as a tree and has first-class plans; the SDK tracks partial-stream state and gives dialogs/elicitations their own timeline kinds.

ACPsession-protocol/src/fold/fold.ts:24 · 595 LOC
export type TimelineItem = MessageItem | ToolCallItem | PlanItem;

interface MessageItem  { kind: "message"; role: "user" | "agent" | "thought";
                         blocks: ContentBlock[]; failed: boolean }
interface ToolCallItem { kind: "tool_call"; id: string;      // toolCallId
                         call: ToolCall;                     // merged patches
                         permissions: PermissionView[];
                         children: TimelineItem[] }     // subagent TREE (Task)
interface PlanItem     { kind: "plan"; entries: PlanEntry[]; removed: boolean }
// every item carries startSeq / endSeq
Claude SDKsession-protocol/src/fold/fold.ts:28 · 704 LOC
export type TimelineItem =
  MessageItem | ToolCallItem | UserDialogItem | ElicitationItem;

interface MessageItem  { kind: "message"; role: "user" | "assistant" | "system";
                         blocks: TimelineContentBlock[];
                         partial: boolean }       // delta-accretion state
interface ToolCallItem { kind: "tool_call"; id: string;      // toolUseID
                         name: string; input: unknown; result: unknown | null;
                         status: "pending" | "completed" | "error" | "denied";
                         permissions: { request; response }[] }
// + UserDialogItem / ElicitationItem: { request, response | null }
// every item carries startSeq / endSeq

5Seq journal & UI folding — the lower-level contract

Where the two sides converged exactly, and where the SDK fold earns its extra 109 lines.

ACP

seq assignment
Host only, per-session, gapless from 1. Append and WS fan-out happen in the same call — no gap window.
row granularity
One row per semantic event: every chunk, every tool_call / patch, every permission edge, every state snapshot. No write-time coalescing.
append vs update
Rows are immutable — everything appends. All merging is client-side at fold time: chunk concat, patch merge, resolution attach.
history source
Claude's native store survives; the host reaches it only through full session/load replay. getMessages then walks the bounded ring backwards (cursor s<seq>; state/reset frames excluded), so this is not complete disk-backed pagination yet.
replay & dedupe
Server replays (since, latest]; client drops seq ≤ lastSeq, treats seq > lastSeq+1 as a gap (reconnect from cursor), treats reset as stop-and-resync. Backoff 250ms → 10s.
fold hot spots
Consecutive-chunk merge · ACP patch semantics · subagent tree re-homing via _meta.claudeCode.parentToolUseId · placeholder tool item when a permission's tool_call fell off the loaded page.

Claude SDK

seq assignment
Same — host, per-session, gapless from 1. Plus epochs: retry() creates a fresh journal restarting at 1 and pushes reset("session_restarted") to old subscribers.
row granularity
One row per SDKMessage — every stream delta is its own row — plus interaction lifecycle edges and state snapshots.
append vs update
Same append-only journal. The fold replaces partial messages with the authoritative assistant message and flips tool / dialog items in place.
history source
The journal is only a recovery buffer. Truth = Claude's native JSONL transcript; transcript rows have no seq (folded at seq 0, uuid-keyed so live re-delivery upserts instead of duplicating).
replay & dedupe
Same semantics, plus cursor_ahead detection (first replayed seq below the cursor after a restart) and zod validation of every frame — an invalid frame forces a reconnect.
fold hot spots
Delta accretion + authoritative replacement (uuid identity) · tool linkage via toolUseID with a placeholder when the permission wins the race · tool results arriving as user-role messages · transcript-vs-live merge at seq 0.

Worked example · raw journal rows → folded timeline

Six rows in on both sides. ACP folds to three items; the SDK folds to two — its text and tool call ride the same assistant message.

ACPfold.ts semantics, per fold tests
seq 10  update  user_message_chunk   "write hello.txt"
seq 11  update  tool_call            tc-1 "Write hello.txt" pending
seq 12  permission_requested         req 7 → tc-1, options [allow, reject]
seq 13  permission_resolved          req 7 → selected "allow"
seq 14  update  tool_call_update     tc-1 completed, content "done"
seq 15  update  agent_message_chunk  "Created hello.txt"
// ────────────────────── folds to ──────────────────────
MessageItem  { role: "user", blocks: ["write hello.txt"] }         // 10
ToolCallItem { id: "tc-1", status: "completed",                    // 11–14
               permissions: [{ req 7, resolution: allow }] }
MessageItem  { role: "agent", blocks: ["Created hello.txt"] }      // 15
Claude SDKfold.ts semantics, per fold tests
seq 41  sdk  stream_event  content_block_start "I'll run"     (uuid A)
seq 42  sdk  stream_event  content_block_delta " ls for you." (uuid A)
seq 43  sdk  assistant (A) [text "I'll run ls for you.",
                            tool_use toolu_9 Bash {command: "ls"}]
seq 44  permission_requested  req_7 → toolu_9 Bash
seq 45  permission_resolved   req_7 → { behavior: "allow" }
seq 46  sdk  user  [tool_result toolu_9 "README.md\nsrc"]
// ────────────────────── folds to ──────────────────────
MessageItem  { role: "assistant", partial: false,                  // 41–43
               blocks: ["I'll run ls for you."] }
               // deltas built it partial; 43 replaced it authoritative
ToolCallItem { id: "toolu_9", name: "Bash", status: "completed",   // 43–46
               permissions: [{ req_7, response: allow }] }

6Capability fidelity — four stacks, one matrix

What each capability looks like natively, and what's left of it after crossing an ACP adapter. Claude columns are verified against claude-agent-acp 0.56.0 dist + sdk 0.3.201 types in this tree; Codex columns against the @agentclientprotocol/codex-acp 1.1.2 npm tarball (source-inspected). Magenta marks fidelity lost in translation.

capability native claude sdk claude → acp native codex app-server codex → acp
conversation session (uuid) + JSONL transcript on disk; resume, fork, retry epochs conversation survives through native transcript + host registry + session/load; host paging is limited to the rebuilt ring (D1) thread → turns → items; persisted, resumable, listable full fidelity — resume / list / close / delete all advertised
plan ExitPlanMode plan document + TodoWrite entries (content/status/activeForm) flat plan entries; activeForm dropped, priority invented "medium"; plan doc rides the switch_mode permission (⑫) turn plan items with step + status per step flat plan; every entry forced priority "medium" (updatePlan maps status + step only)
workflow first-class Workflow tool — script, taskId/runId, transcript dir, resumable (⑭) one opaque kind:"other" tool call; lifecycle frames dropped no equivalent first-class workflow entity n/a — nothing to map
subagent identity agent id + subagent_type + own transcript, readable by (sessionId, agentId) (⑩) mostly lost — a kind:"think" call; identity = the tool_use id; type only in untyped rawInput child threads with collab-agent items carrying sender/receiver thread ids not exposed — child threads excluded from session/list (sourceKinds filter), fork not advertised; thread ids only inside rawInput
subagent activity full message stream tagged parent_tool_use_id — prose + tools + hooks prose deliberately dropped; nested tool calls only via private _meta.claudeCode collaboration items + subAgentActivity events on the thread collab calls become kind:"other"; subAgentActivity explicitly ignored (under the dist's "//ignored types" arm)
dependencies + owners tasks carry blocks/blockedBy edges, owner, metadata (⑪) all dropped — the adapter never reads those fields not represented in the item model not represented
parallel execution parallel() in workflows, background agents/shells, task graph as coordination opaque — concurrent tool calls interleave with no grouping construct agent collaboration primitives (spawn/send across threads) opaque — generic tool calls, relationships live only in rawInput

ACP itself has no standard model for any row below "plan": no workflow entity, no task graph, no subagent addressing — _meta is the protocol's sanctioned extension point, and both adapters lean on it in mutually incompatible dialects (_meta.claudeCode.parentToolUseId vs _meta.codex.goal). Codex "native" cells describe the app-server protocol as consumed by codex-acp's own handlers; we have not run a Codex app-server directly.

ACP has no model for the hard rows

Conversation, permissions, modes, plans: standardized. Workflows, task graphs, subagent identity: not in the protocol at all — every adapter invents a private _meta dialect or silently flattens. The spike doc said it early: “_meta fields are the sanctioned extension point on every type.”

Good chat plane, not a workflow plane

As a common chat/control plane ACP earns its keep — one client vocabulary, permission options on the wire, plan checklists for free. As a portable workflow/multi-agent model it isn't one: the richest capabilities of both agents either flatten (tasks → checklist), go opaque (workflows), or vanish (subagent prose, goals).

What a rich Superset UI would need

Either agent-specific _meta contracts (we already parse claudeCode.parentToolUseId; we'd add codex.goal, and re-do it per agent) or a Superset-owned normalized layer above the adapters — native SDK/app-server in, one internal schema out. The waterfall above is the inventory of what that layer must preserve.