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:
- Not logged in — the CLI retries the API for ~10 s (
errornotifications Reconnecting… n/5 (unexpected status 401 Unauthorized …),willRetry: true), then a finalerrorandturn/completedwithstatus: failed. The harness never gets there for a per-user home: it checksauth.jsonbefore spawning and raisesAuthRequiredException, which the chat turns into the/loginaffordance. AnOPENAI_API_KEYin the environment is not a login — the CLI ignores it (account/readanswersaccount: null); a stored API key is written into the home throughcodex login --with-api-keyon first use. - No rollout to resume — a JSON-RPC error, code -32600,
thread/resume failed: no rollout found for thread id ….
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
src/MeshWeaver.AI.Codex/CodexInvocation.cs— the spawn and the parameter shapes, pure;CodexTurn.cs— the reader, pure;CodexAppServer.cs— the process and the JSON-RPC conversation (the one async leaf);CodexChatClient.cs— the reactive composition (home → login check → MCP back-connection → the process as oneIIoPool.InvokeStreamleaf);CodexThreadMap.cs— the thread record;CodexConnectStrategy.cs— the device-code login.src/MeshWeaver.AI.Codex.Test— the spawn and the reader against recorded messages, and the process path executed against the fake CLI (MeshWeaver.AI.Test.FakeCli,FAKE_CLI_MODE=codex-app-server): streaming without duplication, tool calls and results, resume with the fresh-thread fallback and the record, the transcript hand-off, noise, the not-logged-in verdict, the API-key write, approvals by policy, the surfaced question, the idle bound, the truncated stream.CodexChatClientE2ETestruns the real CLI on demand (CODEX_E2E=1).