4.8 KiB
Codex: app-server integration
How we run Codex
Codex is a system-wide CLI (npm install -g @openai/codex). We don't bundle it.
At startup, CodexAppServerClient spawns codex app-server --listen stdio:// as a child process and talks JSON-RPC 2.0 over stdin/stdout (newline-delimited JSON). The Codex process manages its own model inference, sandbox, and tool execution. We just send prompts and react to events.
Version check: codex --version must report >= 0.100 for app-server support.
Why app-server (not MCP)
The old codex mcp-server integration had three unfixable problems:
- Model change = context loss.
codex-replyonly accepts{ prompt, threadId }. No model param. Changing model meant restarting the session. - Permission cancel hangs forever. MCP SDK's
callToolwaits for a response that never comes afterturn_aborted. Our AbortController workaround was brittle. - Session ID confusion. Three different ID fields (
sessionId,conversationId,threadId) — onlythreadIdworked, and it was undocumented.
codex app-server solves all three: per-turn model/policy overrides, clean turn/interrupt RPC, single threadId.
Architecture
Mobile App → Happy Server → CLI (runCodex.ts) → CodexAppServerClient → codex app-server (child process)
↕ JSON-RPC 2.0 over stdio
Events ← codex/event/* notifications
Approvals ← item/commandExecution/requestApproval (server→client RPC)
The client has three responsibilities:
- Lifecycle:
initializehandshake →thread/start→turn/startper message →turn/interrupton abort - Events: Route
codex/event/*notifications to the event handler (same EventMsg types as old MCP) - Approvals: Respond to server→client RPC requests for command/patch approval
Key protocol findings (learned the hard way)
These aren't in any docs. Discovered by trial and error:
| What | Expected | Actual |
|---|---|---|
| Thread ID location | result.conversationId |
result.thread.id |
| Turn params | conversationId, items |
threadId, input |
| Input item format | { type: "text", data: { text } } |
{ type: "text", text } (flat) |
| Sandbox policy | "read-only", "workspace-write" |
{ type: "readOnly" }, { type: "workspaceWrite" } (camelCase objects) |
| Approval method | execCommandApproval |
item/commandExecution/requestApproval |
| Approval decisions | approved, denied, abort |
accept, decline, cancel (wire format differs from internal) |
| Event routing | codex/event with type in params |
codex/event/<type> (type in method name) |
| Empty model string | Ignored | Error: "model '' not supported" (must omit, not send empty) |
Design decisions
Per-turn overrides (no restart needed)
Each turn/start RPC accepts optional model, approvalPolicy, sandboxPolicy. The thread keeps context across policy changes. This eliminated the mode-change restart block and experimental_resume dead code.
Turn completion tracking
sendTurnAndWait() creates a Promise resolved when task_complete or turn_aborted arrives. Safety nets: 10-minute timeout, process exit handler, disconnect handler. This replaced the AbortController hack.
Duplicate tool call fix
The old mapper generated tool-call-start for both exec_approval_request AND exec_command_begin. Since the permission handler already renders approval UI via agent state, this created duplicate cards. Fix: only exec_command_begin generates tool-call-start.
Approval translation layer
Our internal types use approved/denied/abort. The wire protocol uses accept/decline/cancel. mapDecisionToWire() translates between them so the rest of the codebase doesn't need to know about wire format.
Files
codexAppServerClient.ts— JSON-RPC client, turn tracking, approval handlingcodexAppServerTypes.ts— Cherry-picked types from the protocolrunCodex.ts— Main loop, event/approval handler wiringexecutionPolicy.ts— Maps permission modes to approval/sandbox policiessessionProtocolMapper.ts— Events → session protocol envelopes (shared with old code)
What we don't handle yet
The app-server sends ~60 event types we ignore. Notable ones for future:
collab_*— multi-agent collaboration eventsweb_search_*— web search tool resultsplanning_*— planning mode eventsstreaming_content_delta— finer-grained streamingmcp_*— MCP server lifecycle (we do usemcp_startup_complete)