# Nucleic β€” Backend Adapter Internals (v0) Message-by-message design for the two production adapters that implement `AgentBackend` ([BACKEND_PROTOCOL](BACKEND_PROTOCOL.md)): **ClaudeCodeAdapter** (stream-json + an in-process MCP approval server) and **CodexAppServerAdapter** (JSON-RPC), plus the **CodexExecAdapter** fallback. This is the deepest layer; everything here is adapter-private and absorbs CLI churn. **Confidence key:** βœ… documented/stable Β· 🟑 observed/SDK-derived, pin before shipping Β· πŸ”΄ inferred, **the M0 spike must capture a real sample**. Treat πŸ”΄/🟑 as the validation backlog, not as fact. > **Source-validation pass (2026-06-11).** Most former πŸ”΄/🟑 items are now resolved against > primary sources and retagged βœ… inline. Key resolutions: > - **Claude permission tool wire contract** β€” CONFIRMED from the official Claude Code SDK docs > ("Custom permission prompt tool", archived 2025-05-31) + the shipping > `@anthropic-ai/claude-agent-sdk` `sdk.d.ts` (`PermissionResult`): input `{tool_name, input, > tool_use_id?}` (snake_case), return a **JSON-stringified** `{behavior,…}` in an MCP text > block. `behavior`/`message`/`updatedInput` β€” *not* `decision`/`reason`. > - **Codex app-server** β€” CONFIRMED verbatim from `openai/codex` source > (`codex-rs/app-server-protocol/src/protocol/`, commit `7a19b14`): real methods are > `thread/start`, `turn/start`, `turn/interrupt`, `thread/resume` (not `newThread`/`startTurn`) > and the v2 approval decision enum is `accept`/`acceptForSession`/`decline`/`cancel`. > - **Codex exec** β€” CONFIRMED from `codex-rs/exec/src/exec_events.rs` (rust-v0.139.0): the > exec JSONL surface is **snake_case** and distinct from app-server's camelCase. > > The genuinely residual unknowns (need a live capture, not docs) are listed in Β§6. --- ## 0. Shared infrastructure ### 0.1 ProcessHost contract Both adapters spawn through `ProcessHost` (RUNTIME Β§3): ```swift struct ProcessSpec { let executable: String; let args: [String]; let cwd: String let env: [String:String]; let stdinMode: StdinMode } // .pipe | .closed protocol ProcessHandle: Sendable { var stdoutLines: AsyncThrowingStream { get } // NDJSON, one line per element var stderrLines: AsyncThrowingStream { get } func writeLine(_ data: Data) async throws // appends '\n' func sendSignal(_ sig: Int32) // SIGINT / SIGTERM func wait() async -> Int32 // exit code } ``` - **Line framing:** a buffered reader splits on `\n`, holding partial trailing bytes across reads (NDJSON messages can exceed a pipe-read). Lines are UTF-8 `Data`, decoded leniently. - **Buffering:** child stdio is line-buffered; we never block the writer waiting on the reader (BACKEND_PROTOCOL note 7). ### 0.2 JSON-RPC client (Codex) A small JSON-RPC 2.0 layer over `ProcessHandle` stdio: - outbound **requests** correlated by `id` β†’ `await`ed continuations; - inbound **notifications** (no `id`) β†’ event stream; - inbound **serverβ†’client requests** (have `id` + `method`) β†’ dispatched to handlers that must reply with a result keyed by the same `id`. **Approvals arrive this way.** ### 0.3 Lenient decoding Every decoder tolerates unknown fields and unknown enum cases (β†’ `AgentEvent.raw`). No adapter ever traps on an unrecognized native message; it logs + passes through. ### 0.4 `seq` Adapters emit events in arrival order with a provisional local index; the **canonical `seq` is assigned by `TranscriptWriter`** at append (RUNTIME Β§2.1). Adapters do not persist `seq`. --- ## 1. ClaudeCodeAdapter ### 1.1 Invocation βœ…(flags) 🟑(exact combo) ``` claude -p --output-format stream-json --input-format stream-json --verbose [--include-partial-messages] # when verbosity == .full --permission-prompt-tool mcp__nucleic__approve --mcp-config # registers our in-process server [--model ] [--append-system-prompt ] [--add-dir ...] [--resume [--fork-session]] cwd = worktree.path stdin = open pipe (NDJSON user messages) ``` βœ… The SDK option name is `permissionPromptToolName`; the CLI flag is `--permission-prompt-tool`. βœ… **Eval order** (official docs): `settings.json` / `--allowedTools` / `--disallowedTools` are checked **before** the permission tool is invoked. Optimization: pre-allow cheap read-only tools (`Read`/`Glob`/`Grep`) via `--allowedTools` so they never round-trip through our MCP server β€” the `approve` tool only fires for genuinely gated actions. ### 1.2 In-process MCP approval server β€” the bridge βœ…(contract) 🟑(transport choice) The cleverest piece. Claude must call *our* code when a tool is gated, and that call must block until a human (Mac or iPhone) answers. We do this with a **localhost HTTP MCP server hosted inside the app**, registered via `--mcp-config`: ```jsonc // generated.json { "mcpServers": { "nucleic": { "type": "http", "url": "http://127.0.0.1:/mcp", "headers": { "Authorization": "Bearer " } } } } ``` - Bound to `127.0.0.1`, ephemeral port, **per-session bearer token** so only this `claude` process can call it. One server may serve all sessions, keyed by token β†’ `SessionID`. - Implements minimal MCP: `initialize`, `tools/list` (advertises one tool `approve`), `tools/call`. - βœ… `approve` **input** schema (CONFIRMED β€” fields are snake_case on the wire): ```jsonc { "tool_name": "Bash", "input": { "command": "rm -rf build/" }, "tool_use_id": "toolu_…" } // tool_use_id is optional; there is NO "description" field (earlier guess was wrong) ``` - Handler flow: ``` tools/call(approve, {tool_name, input, tool_use_id?}) arrives β†’ look up SessionID from bearer token β†’ consult always_rule (RUNTIME Β§5): match? return mapped allow immediately, no human β†’ else: ApprovalCoordinator.await(ApprovalRequest) // SUSPENDS the HTTP response (this is what emits AgentEvent.approvalRequested into the pipeline) β†’ on resolve(Decision): map β†’ reply text content (below), return tools/call result ``` - βœ… `approve` **return** (CONFIRMED): a normal MCP `CallToolResult` with **one text content block** whose `text` is the **JSON-stringified** decision object: ```jsonc // the MCP result envelope: { "content": [ { "type": "text", "text": "" } ] } // allow β€” updatedInput is expected even when unchanged (echo the original input back): { "behavior": "allow", "updatedInput": { "command": "rm -rf build/" } } // deny: { "behavior": "deny", "message": "Denied: publish not permitted" } ``` > βœ… **Resolved (was the top πŸ”΄).** Keys are `behavior` (`"allow"`/`"deny"`), `message` on deny, > `updatedInput` on allow β€” **not** `decision`/`reason`. The object must be JSON-stringified > inside the text block. `updatedInput` is type-optional in the SDK `PermissionResult` but the > docs direct you to always include it (echo the original if unmodified). Source: Claude Code > SDK "Custom permission prompt tool" (archived 2025-05-31) + `@anthropic-ai/claude-agent-sdk` > `sdk.d.ts`. The in-process `PermissionResult` also has `interrupt?`/`updatedPermissions?`/ > `toolUseID?`, but the MCP wire shape is the two cases above. - **Cancel-run** maps to `behavior:deny` + `interrupt()` (Β§1.5). - **Timeout/abandon:** if the session is killed while a call is suspended, the server returns `behavior:deny` so `claude` exits cleanly. ### 1.3 stdin β€” prompts & follow-ups 🟑 Initial prompt and each follow-up turn are written as NDJSON user messages: ```jsonc {"type":"user","message":{"role":"user","content":[{"type":"text","text":"Refactor the auth layer…"}]}} ``` (🟑 exact envelope β€” `{"type":"user","content":"…"}` vs. the nested `message.content` block form β€” pin in M0.) `AgentInput.context` parts are rendered as additional text blocks with a labeled prefix. We never feed `tool_result` on stdin β€” tools execute inside `claude` itself. ### 1.4 stdout β€” native β†’ AgentEvent Without `--include-partial-messages`, the canonical stream is **whole messages** (βœ…); with it, additional `stream_event` partials interleave (🟑). The adapter handles both. | Native line | Decode | β†’ `AgentEvent.kind` | | --- | --- | --- | | `{"type":"system","subtype":"init","session_id":…}` | βœ… | `sessionStarted` (capture `session_id`, model, tools, cwd) | | `{"type":"assistant","message":{content:[{type:"text",text}]}}` | βœ… | `assistantText(isPartial:false)` | | …`content:[{type:"thinking",…}]` | 🟑 | `thinking` | | …`content:[{type:"tool_use",id,name,input}]` | βœ… | `toolCallStarted` + `toolCallCompleted` | | `{"type":"user","message":{content:[{type:"tool_result",tool_use_id,content,is_error}]}}` | βœ… | `toolResult` | | `{"type":"stream_event","event":{type:"content_block_delta",delta:{type:"text_delta",text}}}` | 🟑 | `assistantText(isPartial:true)` | | …`delta:{type:"input_json_delta",partial_json}` | 🟑 | `toolCallInputDelta` | | `{"type":"system","subtype":"api_retry",…}` | 🟑 | `error(recoverable:true)` | | `{"type":"result","result",usage,total_cost_usd,duration_ms,session_id}` | βœ… | `runFinished` + `usage` | - **fileChange synthesis:** on `tool_use` for `Edit`/`Write`/`MultiEdit`/`NotebookEdit`, emit `fileChange` (path from input; `emitsFileChangeEvents=false` flags it inferred). Authoritative diff still comes from `WorktreeManager`. - **Partial assembly:** `content_block_start(tool_use)` β†’ accumulate `input_json_delta` β†’ finalize on `content_block_stop` into `toolCallCompleted`. ### 1.5 resume / interrupt / shutdown - **resume:** relaunch with `--resume ` (+ `--fork-session` if `ResumeSpec.fork`); same stdin/stdout wiring; UI history already restored from our transcript. - **interrupt:** `SIGINT` to the process; the stream closes; a final `result` may not arrive, so the adapter emits `runFinished(outcome:.interrupted)` itself. - **shutdown:** close stdin, `SIGTERM`, await exit, tear down the MCP token. ### 1.6 Claude validation backlog - βœ… **Resolved:** permission `approve` request + response schema (Β§1.2); `--mcp-config` accepts **both inline JSON strings and file paths**, and the `http` server entry with `headers` (Bearer) is the documented shape (`type:"http"`, `url`, `headers`); `sse` is deprecated. - βœ… **Resolved by the M0 live capture (2026-06-12, claude 2.1.167 β€” see [M0_RESULTS](M0_RESULTS.md)):** stdin user-message envelope is the nested `{"type":"user","message":{role,content:[…]}}` form (Β§1.3); `thinking` block is `{type:"thinking",thinking,signature}`; `stream_event` partial shapes confirmed as tabled in Β§1.4; the `approve` call carries `tool_use_id` in practice; verbatim golden lines committed under `fixtures/claude/2.1.167/`. - ⚠️ **New M0 findings:** (1) the adapter MUST pass `--permission-mode default` for interactive runs β€” a user-level `defaultMode:"auto"` auto-accepts edits *before* the permission tool is consulted; (2) CLI 2.1.167 emits unmodeled types passed through as `.raw`: `system/status`, `system/post_turn_summary`, `rate_limit_event`; (3) the child inherits the host's global plugins/MCP servers β€” consider `--strict-mcp-config`/curated `--settings` in M1. --- ## 2. CodexAppServerAdapter (default Codex path) ### 2.1 Why app-server `codex exec` **cannot answer approvals** (fail-closed). Interactive approve/deny lives only in `codex app-server`'s bidirectional JSON-RPC. So this is the default; `exec` is Β§3. ### 2.2 Process + framing βœ… ``` codex app-server # JSON-RPC 2.0 over stdio, newline-delimited (JSONL) # "jsonrpc":"2.0" header is omitted on the wire; requests carry "id" cwd / --cd = worktree.path ; model + sandbox via thread/turn params ``` βœ… All method names, params, and enums below are **CONFIRMED verbatim** from `openai/codex` source (`codex-rs/app-server-protocol/src/protocol/{common,v1,v2/*}.rs`, commit `7a19b14`). The wire is **camelCase** (serde `rename_all="camelCase"`). The protocol has a current **v2** (`thread/`,`turn/`,`item/`) surface and a deprecated **v1** surface; we target v2. You can regenerate the exact schema for a pinned version: `codex app-server generate-ts` / `generate-json-schema` (or read `codex-rs/app-server-protocol/schema/`). ### 2.3 Lifecycle βœ… ``` β†’ initialize {clientInfo:{name,title?,version}, capabilities?:{experimentalApi,…}} (request) ← result {userAgent, codexHome, platformFamily, platformOs} β†’ initialized (NOTIFICATION, no params) ← REQUIRED before any other request β†’ thread/start {model?, cwd?, approvalPolicy?, sandbox?|permissions?, ephemeral?, …} (request) ← result { thread: { id, sessionId, … } } ← thread.id is the handle …also a thread/started notification β†’ turn/start { threadId, input:[UserInput], …per-turn overrides } (request) ← result { turn: { id, … } } ← turn.id needed for interrupt/steer ←‹notificationsβ€Ί thread/started Β· turn/started Β· item/* Β· turn/completed Β· thread/tokenUsage/updated ``` - `thread.id` β†’ `SessionStarted.backendSessionID`. `turn.id` is needed for `turn/interrupt` and `turn/steer`. - `UserInput` is `{type:"text",text,textElements?}` (also `image`/`localImage`/`skill`/`mention`). - ⚠︎ Names you'd *expect* but that are **wrong/legacy**: `newThread`, `startTurn`, `sendUserTurn`, `sendUserMessage` are not current v2 methods. - `turn/steer {threadId, input, expectedTurnId}` appends to an in-flight turn β†’ sets `BackendCapabilities.followUpWhileRunning = true` for Codex. ### 2.4 Approvals β€” serverβ†’client requests βœ… The server *calls us*; we reply with the same JSON-RPC `id`. v2 methods (CONFIRMED): | Inbound request `method` | β†’ `AgentEvent` | Reply (result) | | --- | --- | --- | | `item/commandExecution/requestApproval` | `approvalRequested(risk: from command)` | `{ "decision": "accept" \| "acceptForSession" \| "decline" \| "cancel" }` | | `item/fileChange/requestApproval` | `approvalRequested(risk:.write)` | same `decision` enum (accept/acceptForSession/decline/cancel) | | `item/permissions/requestApproval` | `approvalRequested(risk:.unknown)` | **not** a `decision` β€” return `{ permissions:, scope:"turn"\|"session" }` | | `mcpServer/elicitation/request` | surfaced as input/approval | elicitation response object | `Decision` mapping (BACKEND_PROTOCOL Β§4.1): `.allowβ†’"accept"`, `.allowAlways(.session)β†’ "acceptForSession"`, `.denyβ†’"decline"`, `.cancelRunβ†’"cancel"`. - βœ… **Input rewrite:** the command enum *does* have `acceptWithExecpolicyAmendment` and `applyNetworkPolicyAmendment` struct variants β€” but these amend exec/network *policy*, not the command string. There is no arbitrary command-rewrite, so **`canModifyToolInput = false` for Codex** stands; the UI hides "edit & allow". - ⚠︎ v1 (deprecated) path uses different camelCase methods `execCommandApproval`/ `applyPatchApproval` with a `ReviewDecision` enum (`approved`/`approved_for_session`/`denied`/ `abort`, snake_case). We do **not** use these β€” they only fire for legacy `sendUserTurn` turns. - The handler suspends on `ApprovalCoordinator.await` exactly like Claude's β€” the two backends are identical above the adapter. ### 2.5 Item/event mapping βœ… Notifications are `{ "method": "...", "params": {...} }`; the per-item lifecycle is `item/started β†’ (deltas) β†’ item/completed`. | Notification | β†’ `AgentEvent.kind` | | --- | --- | | `thread/started {threadId, thread}` | `sessionStarted` | | `turn/started {threadId, turn}` | status only | | `item/started`/`item/completed` `agentMessage {id,text}` | `assistantText` | | `item/agentMessage/delta` | `assistantText(isPartial:true)` | | `item/*` `reasoning {id,summary,content}` Β· `item/reasoning/textDelta` | `thinking` | | `item/*` `commandExecution {command,cwd,status,aggregatedOutput?,exitCode?}` Β· `item/commandExecution/outputDelta` | `toolCallStarted` β†’ `toolResult` (isError = exitCodeβ‰ 0) | | `item/*` `fileChange {changes:[{path,kind}],status}` Β· `item/fileChange/patchUpdated` | `fileChange` per change (native, authoritative) | | `item/*` `mcpToolCall {server,tool,…}` | `toolCallStarted/Completed` + `toolResult` | | `item/*` `webSearch {query}` | `toolCall*` name=web_search | | `thread/tokenUsage/updated {tokenUsage:{total,last:{totalTokens,inputTokens,cachedInputTokens,outputTokens,reasoningOutputTokens}}}` | `usage` | | `turn/completed {turn:{status}}` (status: completed/interrupted/failed) | `turnCompleted` | | `turn/failed` / `error` | `error` | - βœ… **Streaming deltas exist** (`item/agentMessage/delta`, `…/outputDelta`, `…/reasoning/textDelta`) β†’ `BackendCapabilities.partialMessageStreaming = true` for Codex. - βœ… **Token usage is a SEPARATE notification** (`thread/tokenUsage/updated`), *not* on `turn/completed` β€” different from exec (Β§3). Cost in dollars is not reported β†’ `Usage.costUSD = nil`. ### 2.6 resume / interrupt / shutdown βœ… - **resume:** `thread/resume { threadId, path?, excludeTurns? }` β†’ same shape as `thread/start`, returns the thread with `turns` populated. (`thread/fork` branches a new id; `thread/read` reads without resuming.) - **interrupt:** `turn/interrupt { threadId, turnId }` β†’ empty `{}` result; server then emits `turn/completed` with `status:"interrupted"`. Cleaner than a signal. - **shutdown:** `SIGTERM` the process after closing the connection. ### 2.7 Codex app-server validation backlog - βœ… **Resolved:** all method names, params, approval decision enums, event taxonomy, token usage, interrupt, resume β€” confirmed from source at commit `7a19b14`. - 🟑 **Pin per version:** the schema is versioned and some methods are gated behind `capabilities.experimentalApi`; regenerate (`generate-json-schema`) against the pinned Codex release and keep a fixture. Capture one real run for verbatim byte-level confirmation. > **Implementation status (v0, M3 β€” live-validated against codex-cli 0.141.0).** Built and > tested in `Sources/NucleicCore/Codex/`: `CodexJSONRPC` (transport), `CodexAppServerDecoder` > (Β§2.5), `CodexDecisionMapping` (Β§2.4), `CodexAppServerBackend`, and the `CodexExecDecoder` / > `CodexExecBackend` unattended fallback (Β§3). The factory routes `.codex` β†’ app-server and > `.codexExec` β†’ exec; the model picker selects the backend (`BackendID.forModel`: `gpt-*` β†’ > Codex). Both Codex backends use the **per-turn process model** (`start` opens a thread; each > `resume` re-spawns and `thread/resume`s by id) β€” `followUpWhileRunning = false`; > persistent-connection `turn/steer` follow-ups are a later optimization. Item types are > normalized onto Claude's tool vocabulary so the UI, `RiskClassifier`, and command summaries > are shared. > > **Validated live** (against `/opt/homebrew/bin/codex` 0.141.0): the whole protocol was > regenerated with `codex app-server generate-ts` and the wire shapes confirmed byte-for-byte β€” > the `jsonrpc` header IS omitted on the wire; `initialize`/`thread.start`/`turn.start` param > spelling; the `item/*` envelope is `{item, threadId, turnId}`; the v2 decision enum is > `accept`/`acceptForSession`/`decline`/`cancel` (command + file change have dedicated response > types; permissions returns `{permissions, scope}`); `PatchChangeKind` is a tagged object > `{type, move_path}`; `UserInput.text` carries `text_elements`; there is no `turn/failed` > (failure rides `turn/completed.turn.status="failed"` with `turn.error`); reasoning deltas are > `item/reasoning/textDelta`Β·`summaryTextDelta`. A real turn + a real resume were driven > end-to-end (agentMessage, `agentMessage/delta`, `tokenUsage`, `turn/completed`), and golden > captures are committed at `fixtures/codex-appserver/0.141.0/` and `fixtures/codex-exec/0.141.0/`. > > **Not yet built (follow-ups):** persistent-connection `turn/steer`; auto-approve wiring for > Codex (the app-server backend currently always runs `approvalPolicy:on-request`); and Codex > parity for Nucleic Control features (containers, conflict locks, git interceptor, `host_exec`), > which are tied to Claude's in-process MCP server and need a separate design for Codex's JSON-RPC > model β€” notably, `item/fileChange/requestApproval` params carry no path (only `itemId`), so > edit-conflict pre-checking needs item-id correlation. --- ## 3. CodexExecAdapter (unattended fallback) For `ApprovalPolicy.fixed`. No approval channel (`interactiveApprovals=false`). ``` codex exec --json [-o last.txt] [--output-schema schema.json] --sandbox \ --ask-for-approval [-m ] [-C ] # resume: codex exec resume | codex exec resume --last [--all] ``` > βœ… **Critical correction (CONFIRMED from `codex-rs/exec/src/exec_events.rs`, rust-v0.139.0):** > the `codex exec` JSONL surface is **its own schema β€” snake_case β€” and is NOT the app-server > v2 schema.** It needs a **separate decoder** from Β§2.5, not a shared one. - βœ… Flag is `--json` (alias `--experimental-json`); "Print events to stdout as JSONL." - βœ… Envelope `ThreadEvent` tagged by `"type"`: `thread.started {thread_id}`, `turn.started {}`, `turn.completed {usage}`, `turn.failed {error:{message}}`, `item.started/updated/completed {item}`, `error {message}`. **Note the dotted names** (`thread.started`) vs app-server's slash names (`thread/started`). - βœ… `item` = `{ id, type, …}` snake_case: `agent_message {text}`, `reasoning {text}`, `command_execution {command, aggregated_output, exit_code?, status}`, `file_change {changes:[{path, kind:"add"|"delete"|"update"}], status}`, `mcp_tool_call {…}`, `web_search {query}`, `todo_list`, `error`. - βœ… **Token usage is on `turn.completed.usage`** `{input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens}` β€” *inline*, unlike app-server's separate `thread/tokenUsage/updated` notification (Β§2.5). - βœ… Approval flags: `--ask-for-approval` ∈ `untrusted|on-request|never` (`on-failure` deprecated); `--sandbox` ∈ `read-only|workspace-write|danger-full-access`. Rollout files at `~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl` (suppressed by `--ephemeral`). - A would-be approval fails closed β†’ emit `toolResult(isError)` + `error(recoverable:false)` + UI banner (BACKEND_PROTOCOL Β§4.2). 🟑 The exact "fail-closed vs silently-skip" runtime path isn't quotable from source β€” capture one real run. `--json` is experimental β†’ tolerate unknown `type`/fields and pin the version. --- ## 4. Validation fixtures harness After the source-validation pass, the schemas are known; fixtures now exist to **lock byte-level layout and detect drift**, not to discover the protocol. The M0 spike records, per pinned CLI version, native streams + golden normalized output as adapter unit tests: ``` fixtures/claude// 01-simple-turn.ndjson + 01-simple-turn.events.json (golden AgentEvent[]) 02-tool-approval.ndjson + 02…events.json + approval-call.json / reply.json 03-partial-streaming.ndjson + … fixtures/codex-appserver// 01-turn.jsonrpc 02-approval.jsonrpc 03-file-change.jsonrpc fixtures/codex-exec// 01-turn.jsonl 02-file-change.jsonl # SEPARATE schema (Β§3) ``` - Codex needs **two** fixture sets β€” app-server (camelCase, slash methods) and exec (snake_case, dotted types) are different decoders. - Each remaining 🟑 item (Β§1.6, Β§3) maps to a fixture that must exist before the adapter is "done"; the bulk that were πŸ”΄ are already resolved from source. - Adapter tests decode the recorded native stream β†’ assert exact `AgentEvent[]` equality, and (for approvals) assert the reply bytes the CLI accepts. - Re-recording on a CLI upgrade is the regression signal for protocol drift; for Codex, also re-run `codex app-server generate-json-schema` and diff. --- ## 5. Summary of cross-adapter symmetry Despite wildly different wire formats, everything converges by design: | Concern | Claude | Codex app-server | Grok (ACP) | Above the adapter | | --- | --- | --- | --- | --- | | Transport | NDJSON stdout + HTTP-MCP callback | JSON-RPC stdio (duplex) | JSON-RPC stdio (duplex) | β€” | | Approval trigger | MCP `tools/call(approve)` | `…/requestApproval` request | `session/request_permission` request | `ApprovalCoordinator.await` | | Approval reply | `{behavior,…}` text | `{decision}` result | `{outcome:{selected,optionId}}` result | one `Decision` | | Events | message/stream_event | thread/turn/item | `session/update` notifications | one `AgentEvent` stream | | Interrupt | SIGINT | `turn/interrupt {threadId,turnId}` | `session/cancel {sessionId}` | `interrupt()` | | Resume | `--resume ` | `thread/resume {threadId}` | `session/load {sessionId}` | `ResumeSpec` | All cells above are now source-confirmed (Claude via SDK docs/`.d.ts`; Codex via repo source at `7a19b14`; Grok via the published Agent Client Protocol schema + xAI's `grok agent stdio` docs). The `ApprovalCoordinator.await(...) -> Decision` suspension point is the single seam that makes these completely different approval mechanisms look identical to the rest of the app β€” and Grok, like Codex, is a JSON-RPC backend whose approvals arrive as serverβ†’client *requests*, so both share the [`JSONRPCConnection`](../Sources/NucleicCore/JSONRPCConnection.swift) transport (Codex omits the `"jsonrpc"` header per its confirmed dialect; ACP includes it).