How the harness drives the CLI

The OpenAI Codex harness turns a chat round into one spawn of codex app-server. This page is the record of how that spawn is shaped, why it is shaped that way, and what was measured on 2026-09-12 against Codex CLI 0.154 before the shape was chosen.

Why the app-server and not codex exec --json

Codex has two headless faces. codex exec --json prints one JSONL event per item — an agent message arrives whole when it is finished, a command when it has run. The app-server (codex app-server, JSON-RPC 2.0 over stdio, the protocol the Codex desktop app, the IDE extensions and T3 Code speak) streams item/agentMessage/delta token by token, item/reasoning/summaryTextDelta for the thinking, item/started / item/completed for every command execution, MCP tool call, file change and web search, token usage per model request, and it raises server requests — an approval, a question for the user — that the client answers. Same login, same rollouts, same config; strictly more of the round is visible. Both were probed; the app-server is what the harness speaks.

initialize {clientInfo, capabilities.experimentalApi}  →  initialized
thread/resume {threadId, cwd, sandbox, approvalPolicy, developerInstructions, config.mcp_servers, excludeTurns}
  | thread/start {cwd, sandbox, approvalPolicy, developerInstructions, config.mcp_servers}  →  thread/name/set
turn/start {threadId, input:[{type:text,text}], summary}
  … notifications … server requests …
turn/completed {turn.status: completed | failed | interrupted, turn.error}

One Codex thread per mesh thread

Codex mints the thread id itself (a UUIDv7 on thread/start), and thread/resume accepts nothing else — a name is rejected as an invalid session id — so the harness records which Codex thread a mesh thread runs in: one small file per mesh thread under the user's CODEX_HOME (meshweaver/threads/<sha256 prefix>.txt), beside the rollouts it points at, so the two live and die together. Every later round resumes that thread with only the new message; the CLI holds the earlier turns, its tool calls and their results itself. When the resume answers no rollout found for thread id … (code -32600 — a recreated volume, a pruned rollout) the record is forgotten and the thread starts afresh in the same round, handed the mesh thread's earlier user and assistant turns as a bounded transcript.

The thread is also named (thread/name/set, mw-<key prefix>) so a person browsing the volume with codex resume can tell the mesh threads apart; the name is cosmetic, never a key.

The messages the reader understands

Recorded from the real CLI; the reader (CodexTurn) takes exactly these and ignores the rest:

Message What the harness does with it
item/agentMessage/delta The streamed text — yielded token by token, and the item is marked as streamed.
item/completed with an agentMessage Yielded only when no deltas were streamed for that item — otherwise the text would appear twice.
item/reasoning/summaryTextDelta, item/reasoning/textDelta Reasoning content, streamed. A completed reasoning item is used only when nothing streamed.
item/started / item/completed with commandExecution A FunctionCallContent named shell (command, cwd) and its FunctionResultContent (the aggregated output; a non-zero exit code is named; failed / declined becomes the fault).
… with mcpToolCall mcp__{server}__{tool} with the arguments — the same naming as the Claude Code harness — and the result's text content, or its error as the fault.
… with fileChange, webSearch, dynamicToolCall apply_patch (the changed paths and kinds), web_search (the query), the dynamic tool's own name.
thread/tokenUsage/updated The last request's usage as a UsageContent — the thread keeps summing per chunk, so the thread total is never emitted (it would be counted twice).
error with willRetry: false The error message (a retried one is not a verdict).
turn/completed The verdict: status and error.
model/rerouted, mcpServer/startupStatus/updated The model actually used; a failed start of the mesh server (logged, the round runs without mesh access).
anything else Nothing. Rate limits, thread status, remote-control status, a non-JSON line, whatever a newer CLI adds — none may fault the round.

Server requests, answered in-line so the CLI never waits on a human that is not there:

Request Answer
item/commandExecution/requestApproval, item/fileChange/requestApproval {decision: accept} when AutoApprove is on, else decline. With the default ApprovalPolicy: never the CLI does not ask at all.
item/tool/requestUserInput Blank answers, and the question is surfaced in the thread as Codex asks: … so the user answers in the next message.
mcpServer/elicitation/request {action: decline}.
anything else A JSON-RPC method not found, which the CLI treats as declined.

Two verdict shapes measured rather than assumed:

The mesh as the workspace

thread/start takes a config overlay — the CLI's config.toml expressed as JSON, applied to this thread only — which is where the mesh's MCP back-connection goes: {"mcp_servers":{"meshweaver":{"url":…,"http_headers":{"Authorization":"Bearer …"}}}} (snake_case: the CLI ignores anything else). It acts as the user under the user's own bearer token, so mesh access control applies unchanged. The thread's cwd is the shared skills workspace, whose AGENTS.md — the mesh instructions and the skill catalog the agent→skill sync maintains — Codex reads natively. The CLI's own shell tool runs in its read-only sandbox by default with approvals never: the pod's disk is not the workspace.

Login

/login runs the ChatGPT device-code flow through the app-server itself: account/login/start {type: chatgptDeviceCode} answers with the verification URL and the user code; the same process, kept alive for the login session, raises account/login/completed once the user approved in the browser, having written auth.json into the user's CODEX_HOME itself. No browser is opened on the portal and nothing is scraped from a terminal. A pasted OpenAI API key is stored as the harness's credential node and written into the home on the first round. Each user runs under CODEX_HOME = {ConfigDirRoot}/{userId}/.codex — the same users volume as Claude Code.

Where it lives and how it is tested

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.