32 KiB
Nucleic — Backend Adapter Internals (v0)
Message-by-message design for the two production adapters that implement AgentBackend
(BACKEND_PROTOCOL): 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-sdksdk.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— notdecision/reason.- Codex app-server — CONFIRMED verbatim from
openai/codexsource (codex-rs/app-server-protocol/src/protocol/, commit7a19b14): real methods arethread/start,turn/start,turn/interrupt,thread/resume(notnewThread/startTurn) and the v2 approval decision enum isaccept/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):
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<Data, Error> { get } // NDJSON, one line per element
var stderrLines: AsyncThrowingStream<Data, Error> { 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-8Data, 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→awaited 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 sameid. 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 <generated.json> # registers our in-process server
[--model <id>] [--append-system-prompt <s>]
[--add-dir <path> ...]
[--resume <backendSessionID> [--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:
// generated.json
{
"mcpServers": {
"nucleic": {
"type": "http",
"url": "http://127.0.0.1:<ephemeralPort>/mcp",
"headers": { "Authorization": "Bearer <perSessionToken>" }
}
}
}
- Bound to
127.0.0.1, ephemeral port, per-session bearer token so only thisclaudeprocess can call it. One server may serve all sessions, keyed by token →SessionID. - Implements minimal MCP:
initialize,tools/list(advertises one toolapprove),tools/call. - ✅
approveinput schema (CONFIRMED — fields are snake_case on the wire):
{ "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
- ✅
approvereturn (CONFIRMED): a normal MCPCallToolResultwith one text content block whosetextis the JSON-stringified decision object:
// the MCP result envelope:
{ "content": [ { "type": "text", "text": "<JSON-stringified object below>" } ] }
// 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"),messageon deny,updatedInputon allow — notdecision/reason. The object must be JSON-stringified inside the text block.updatedInputis type-optional in the SDKPermissionResultbut 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-sdksdk.d.ts. The in-processPermissionResultalso hasinterrupt?/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:denysoclaudeexits cleanly.
1.3 stdin — prompts & follow-ups 🟡
Initial prompt and each follow-up turn are written as NDJSON user messages:
{"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_useforEdit/Write/MultiEdit/NotebookEdit, emitfileChange(path from input;emitsFileChangeEvents=falseflags it inferred). Authoritative diff still comes fromWorktreeManager. - Partial assembly:
content_block_start(tool_use)→ accumulateinput_json_delta→ finalize oncontent_block_stopintotoolCallCompleted.
1.5 resume / interrupt / shutdown
- resume: relaunch with
--resume <backendSessionID>(+--fork-sessionifResumeSpec.fork); same stdin/stdout wiring; UI history already restored from our transcript. - interrupt:
SIGINTto the process; the stream closes; a finalresultmay not arrive, so the adapter emitsrunFinished(outcome:.interrupted)itself. - shutdown: close stdin,
SIGTERM, await exit, tear down the MCP token.
1.6 Claude validation backlog
- ✅ Resolved: permission
approverequest + response schema (§1.2);--mcp-configaccepts both inline JSON strings and file paths, and thehttpserver entry withheaders(Bearer) is the documented shape (type:"http",url,headers);sseis deprecated. - ✅ Resolved by the M0 live capture (2026-06-12, claude 2.1.167 — see
M0_RESULTS): stdin user-message envelope is the nested
{"type":"user","message":{role,content:[…]}}form (§1.3);thinkingblock is{type:"thinking",thinking,signature};stream_eventpartial shapes confirmed as tabled in §1.4; theapprovecall carriestool_use_idin practice; verbatim golden lines committed underfixtures/claude/2.1.167/. - ⚠️ New M0 findings: (1) the adapter MUST pass
--permission-mode defaultfor interactive runs — a user-leveldefaultMode:"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--settingsin 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.idis needed forturn/interruptandturn/steer.UserInputis{type:"text",text,textElements?}(alsoimage/localImage/skill/mention).- ⚠︎ Names you'd expect but that are wrong/legacy:
newThread,startTurn,sendUserTurn,sendUserMessageare not current v2 methods. turn/steer {threadId, input, expectedTurnId}appends to an in-flight turn → setsBackendCapabilities.followUpWhileRunning = truefor 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:<granted subset>, 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
acceptWithExecpolicyAmendmentandapplyNetworkPolicyAmendmentstruct variants — but these amend exec/network policy, not the command string. There is no arbitrary command-rewrite, socanModifyToolInput = falsefor Codex stands; the UI hides "edit & allow". - ⚠︎ v1 (deprecated) path uses different camelCase methods
execCommandApproval/applyPatchApprovalwith aReviewDecisionenum (approved/approved_for_session/denied/abort, snake_case). We do not use these — they only fire for legacysendUserTurnturns. - The handler suspends on
ApprovalCoordinator.awaitexactly like Claude's — the two backends are identical above the adapter.
2.4.1 Codex's own review layer is torn down for containerized runs ✅
Codex ships a guardian review layer (features.guardian_approval — stable and ON by default,
validated against codex-cli 0.146.0) that assesses actions including mcp_tool_call and can deny one
or demand a grant. Nucleic already gates every tool it exposes (ApprovalCoordinator + its own UI),
so for Nucleic's MCP tools that layer is pure interference — and it was silently eating the platform
tools: under codex exec --ask-for-approval never nothing can answer it, so host_exec / the VM
tools / linux_container came back as a bare mcp_tool_call with status:"failed" and no result
— a hazard badge and nothing else in the transcript, which the model reported to the user as the tool
not being "authorized".
CodexGateOwnership supplies the launch overrides, added in the containerized branch of both
Codex backends:
| Override | Why |
|---|---|
-c features.guardian_approval=false |
Nucleic is the only gate; matches the approvalsReviewer:"user" we already send per thread. |
-c mcp_servers.nucleic.default_tools_approval_mode="approve" |
Defaults to auto, which re-routes every call on our server through the approval layer even with the guardian off. Enum: auto|prompt|writes|approve. |
Deliberately not applied to host runs: there Codex's native command/file approvals are not interference, they are the seam Nucleic gates commands through, and the container is what makes trusting our own gate reasonable in the first place.
concernsNucleicMCP is the belt-and-suspenders half — it auto-allows an approval concerning our
nucleic server. It matches nested payloads too, because a guardian permission request identifies
the call under permissions[].mcpToolCall.{toolName,connectorName} (snake_case in the core payloads)
rather than at the top level. A bare nucleic is accepted only under a server/connector key: this
project is itself named "nucleic", so matching a bare name could auto-allow an unrelated command
approval.
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 = truefor Codex. - ✅ Token usage is a SEPARATE notification (
thread/tokenUsage/updated), not onturn/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 asthread/start, returns the thread withturnspopulated. (thread/forkbranches a new id;thread/readreads without resuming.) - interrupt:
turn/interrupt { threadId, turnId }→ empty{}result; server then emitsturn/completedwithstatus:"interrupted". Cleaner than a signal. - shutdown:
SIGTERMthe process after closing the connection.
2.7 Edit gate — the PreToolUse hook ✅ (live-validated, codex-cli 0.146.0)
Codex's approval channel (§2.4) is not an edit gate, and treating it as one left every Codex edit
unlocked. Codex sends item/fileChange/requestApproval only when its approval policy doesn't already
allow the write; Nucleic runs it on-request with sandbox: danger-full-access inside a control
container (its bwrap sandbox can't nest there), so a patch in the writable roots was auto-applied
with no approval on the wire at all — and codex exec runs --ask-for-approval never, so it has no
approval channel to begin with. Meanwhile the edit itself is internal: apply_patch (the
*** Update File: envelope), which newer models invoke as tools.apply_patch(…) from inside a
code-mode exec isolate.
The gate is codex's Claude-Code-compatible PreToolUse hook — the analogue of Claude's
canUseTool (§1.2), and the one place where Codex can be told "not yet":
| Property | Behavior (verified against a live 0.146.0 turn) |
|---|---|
| When | Synchronously before the tool runs; codex waits for the hook, so a lock wait parks the tool call |
| Coverage | Top-level and nested code-mode tools — tools.apply_patch(…) fires it |
| Decisions honored | permissionDecision: "deny" (+ a non-empty reason) only; allow/ask are warned and ignored — deny-or-nothing, which is all a lock gate needs |
| Denial the model sees | Script error: Command blocked by PreToolUse hook: <our reason> — and the file is not written |
| Timeout | Fails OPEN (codex runs the tool), so the hook must answer inside its own shorter deadline |
| Payload | {session_id, turn_id, cwd, hook_event_name, model, permission_mode, tool_name, tool_input, tool_use_id} — golden capture at fixtures/codex-appserver/0.146.0/02-pre-tool-use-hook.jsonl |
tool_name normalizes onto Claude's vocabulary, which is what lets the lock logic be shared rather
than re-implemented: a patch arrives as apply_patch with the envelope in tool_input.command, and a
shell call arrives as Bash with the command line. Nucleic maps the first onto arbitrate (the
Edit path) and the second onto arbitrateShellWrites (NASH §5.6) — see CodexToolGate and
LOCKING §4.6 for the semantics, trust model (a root-owned /etc/codex/managed_config.toml, so the
hook is managed/auto-trusted and the agent can't delete it), and failure posture (unwired → allow;
wired-but-unanswerable → deny).
2.8 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 theCodexExecDecoder/CodexExecBackendunattended 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 (startopens a thread; eachresumere-spawns andthread/resumes by id) —followUpWhileRunning = false; persistent-connectionturn/steerfollow-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/codex0.141.0): the whole protocol was regenerated withcodex app-server generate-tsand the wire shapes confirmed byte-for-byte — thejsonrpcheader IS omitted on the wire;initialize/thread.start/turn.startparam spelling; theitem/*envelope is{item, threadId, turnId}; the v2 decision enum isaccept/acceptForSession/decline/cancel(command + file change have dedicated response types; permissions returns{permissions, scope});PatchChangeKindis a tagged object{type, move_path};UserInput.textcarriestext_elements; there is noturn/failed(failure ridesturn/completed.turn.status="failed"withturn.error); reasoning deltas areitem/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 atfixtures/codex-appserver/0.141.0/andfixtures/codex-exec/0.141.0/.Nucleic Control parity landed since: containers, the git/gh/command interceptor,
host_execand the Nucleic-owned MCP tools all run for Codex, and conflict locks acquire through thePreToolUsehook (§2.7) rather than the approval channel — which is what closed the gap this note used to describe (item/fileChange/requestApprovalcarries no path, only anitemId; that correlation exists and still runs, but as a second gate, because the approval itself almost never fires).Not yet built (follow-ups): persistent-connection
turn/steer; auto-approve wiring for Codex (the app-server backend currently always runsapprovalPolicy:on-request); and the edit gate for host (unsandboxed) Codex runs, which start no control server at all — the same boundary thegit/ghinterceptors have (NASH §1).
3. CodexExecAdapter (unattended fallback)
For ApprovalPolicy.fixed. No approval channel (interactiveApprovals=false).
codex exec --json [-o last.txt] [--output-schema schema.json] --sandbox <mode> \
--ask-for-approval <never|untrusted|on-request> [-m <model>] [-C <worktree>]
# resume: codex exec resume <SESSION_ID> | codex exec resume --last [--all]
✅ Critical correction (CONFIRMED from
codex-rs/exec/src/exec_events.rs, rust-v0.139.0): thecodex execJSONL 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
ThreadEventtagged 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 separatethread/tokenUsage/updatednotification (§2.5). - ✅ Approval flags:
--ask-for-approval∈untrusted|on-request|never(on-failuredeprecated);--sandbox∈read-only|workspace-write|danger-full-access. Rollout files at~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.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.--jsonis experimental → tolerate unknowntype/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/<version>/
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/<version>/ 01-turn.jsonrpc 02-approval.jsonrpc 03-file-change.jsonrpc
fixtures/codex-exec/<version>/ 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-schemaand diff.
5. Generic ACP agents
The Grok transport is shared by ACPBackend: any command that implements Agent Client Protocol over newline-delimited JSON-RPC stdio can use the same session, streaming, cancellation, resume, and permission bridge. Built-in profiles cover Grok, OpenCode, OpenClaw, Hermes, and Cursor Agent. Settings → Agents → Custom ACP agent adds a catch-all profile with an executable and shell-style argument field; arguments are split by Nucleic and passed directly, never evaluated by a shell.
OpenClaw is the one built-in profile that is not a self-contained harness. openclaw acp is a bridge: it speaks ACP over stdio but forwards each prompt over WebSocket to a running OpenClaw Gateway, which owns the model and the tools. Two consequences. First, a reachable Gateway is a prerequisite, and the Settings connection dot cannot see it — the probe only checks that openclaw is on PATH and that a Gateway token is present, so a green dot with a down Gateway still fails on the first turn. Second, the bridge never calls the client-side fs/read_text_file, fs/write_text_file, or terminal/* methods, so OpenClaw touches the working tree through its own tools rather than through Nucleic.
Headless runners configure the same profile with NUCLEIC_ACP_EXECUTABLE and NUCLEIC_ACP_ARGUMENTS. Generic executables run on the host unless a compiled profile explicitly declares that the binary is included in Nucleic’s sandbox image. The stable wire identity is BackendID.acp, and the model selector SKU is acp-agent.
6. 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 |
| Edit gate (lock acquire) | canUseTool on every Edit/Write/Bash |
PreToolUse hook on every apply_patch/Bash (§2.7) |
session/request_permission on edit-class calls |
ConflictCoordinator.arbitrate |
| 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 <id> |
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 transport (Codex
omits the "jsonrpc" header per its confirmed dialect; ACP includes it).