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. The account-login click opens a waiting browser window immediately, before the server round trip; once the challenge arrives, that window navigates to its verification URL. The device code and an authorization link remain in the chat, including when the browser blocks the popup. A failed or cancelled startup closes the waiting window. The authorization window has no opener and remains under the user's control after navigation. 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.

First-start home and process errors

The shared app-server launcher creates the resolved CODEX_HOME before starting the CLI. This applies to login, model discovery and chat, including a deployment using the machine-home fallback because ConfigDirRoot is empty. Creating the directory preserves existing credentials and rollout files; it does not authenticate the account.

On 2026-09-22, Loki on the control instance showed login, chat and model discovery failing while waiting for initialize, before any account-login request. The portal exported an explicit home path but created it only when the optional per-user configuration directory was present. A probe with the production Codex CLI 0.154.0 reproduced the failure using a nonexistent temporary home: the CLI reported that CODEX_HOME did not exist and closed stdout. An existing temporary home and the machine home both accepted initialize. The CLI also warned about missing system bubblewrap, but continued with its bundled fallback; that warning did not prevent initialization.

An unexpected process exit now reports the outstanding RPC method, exit code when available, and a bounded, redacted stderr tail. The reader drains stderr for a bounded interval before completing pending requests, including requests registered after stdout has already closed. Intentional shutdown retains normal stream completion. This prevents both the uninformative output-closed message and the early-exit race that could leave a request waiting indefinitely.

Signing in does not configure recurring execution. Unattended Codex tasks records the current scheduling boundary and the proposed native extension for personal workers.

Where it lives and how it is tested