Skip to article
PlanToCodeDocsGet the app

HandbookArchitecture

The Codex app-server integration

How the desktop spawns, drives, and supervises the Codex child process: the JSON-RPC pipe, the handshake, timeouts, notifications and approvals, per-run tool injection, and clients scoped to profile pairs.

Checked against the source on 17 September 2026

On this page

A supervised child process

The spawn path resolves the bundled command, applies the app-owned runtime configuration, and opens piped stdin, stdout, and stderr. It first terminates the orphan recorded in the profile root’s app-server.pid if that process is still a codex-app-server, drains stderr separately, records the new process ID, and uses kill-on-drop. Initialization sends initialize with client information and experimentalApi enabled, then initialized; an initialization failure shuts the child down.

Flags the desktop adds to the child command — illustrative
-c features.local_thread_store_compression=false
-c features.background_paginated_rollout_migration=false
-c features.multi_agent_v2.tool_namespace="plantocode"
-c sqlite_home=<storage-profile-home>
--allowed-storage-root <storage-profile-home>
--auth-home <execution-profile-home>
--listen stdio:// --session-source app-server

The initialize reply must list every one of 15 required protocol extensions, among them threadItem.createdAtMs, threadItem.commandOutputSource, threadItemsList.path, threadUnload, and threadTurn.executionMetadata. One missing entry shuts the child down as incompatible with this build. This gate is what makes an arbitrary upstream binary unusable.

Process transport shape — illustrative request, one JSON object per line
{"id":42,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"plantocode","version":"<build-version>"}}}
The actor holds a turn’s first events until turn/start answers
The actor holds a turn’s first events until turn/start answersOne actor reads everything the Codex child writes to stdout, and the drawing follows one example turn in the order its messages cross that pipe. The child reports turn/started and item/started before it answers request 42, so the actor holds both events. The reply names turn T, the actor opens that turn’s channel, and the held events enter it before the first live delta. A tool call then runs in its own task while deltas keep arriving, and the actor writes the tool’s reply back to the child.
Codex childwrites to stdout
App-server actorthe only reader
Held eventswhile turn/start waits
Turn channelturn T only
Tool taskone per call
turn/start · id 42
turn/started
item/started
reply 42 · turn T
delta
item/tool/call · id 7
delta
reply 7
turn/completed
no turn ID yet
channel opens
held events first
gets only name + arguments
A full channel drops ordinary events. turn/completed and errors that won’t retry wait in order instead.
Threads the agent starts on its own get no channel. Their events go to whoever watches that thread.
  • Replyid

    Matched to the request that waits for it. The turn/start reply carries the turn ID that opens the turn’s channel.

  • EventthreadId · turnId

    Routed into that turn’s channel. While a turn/start for the thread waits, its events are held, up to 4,096, and replayed before live ones.

  • Request from the childitem/tool/call · …/requestApproval

    A tool call runs in its own task with only the tool name and arguments, and the actor writes the reply. An approval with no live consumer is declined.

The child runs inside an app-owned Codex profile home with a stable opaque UUID, created by the desktop below its application data. The owned runtime setup derives and validates that home, cleans inherited storage configuration, and supplies the selected home to the child, and the bundled child refuses to open any database unless --allowed-storage-root, CODEX_HOME, and sqlite_home canonicalize to the same directory.

DeadlineValue
Child start35 seconds.
Request response30 seconds after the write, fixed. The caller gives up after 31 seconds. Only initialize restarts its 30 seconds each time the child sends a request before the reply.
stdin write5 seconds.
Command-queue reservation5 seconds.
Graceful shutdown before kill2 seconds.
Unclaimed approvalDeclined automatically after 30 seconds.

Approvals shown on a phone resolve through run.codexChatResolveApproval, which requires an idempotency key and passes the decision to the authoritative app-server resolver.

The plantocode tool namespace

When a run opens its thread with thread/start or thread/resume, the desktop passes dynamicTools and developerInstructions that add a namespace called plantocode next to the shell and file tools Codex already has. The child is launched with features.multi_agent_v2.tool_namespace set to the same name, so Codex’s own multi-agent tools share it. The set is rebuilt per run: use_user_browser, maintain_sessions, and create_html_document are always present, send_mobile_notification appears when the Desktop and phone notifications setting is on, and synthesize_speech and extract_video_context appear when a Gemini API key is saved. Every spec is marked deferLoading and embeds the current appSessionId, runId, and project directory, and the namespace description with its instructions stays under 600 characters. A call to a tool name the desktop doesn’t know gets a result with success: false and the text “Unsupported PlanToCode app tool.” instead of a protocol error.

Clients per profile pair

Switching accounts sends the next turn to another child on the same storage
Switching accounts sends the next turn to another child on the same storageRows are storage profiles, where a session’s transcript lives. Columns are signed-in accounts, and each cell is one Codex child. Session A stays in row S1, so after the switch to P2 its next turn runs in child (S1, P2) while child (S1, P1) sits idle with the thread unloaded. Session B’s running turn keeps P1 until it ends, and its next turn starts child (S2, P2).
each cell is one Codex child
Account P1active before the switch
Account P2active after the switch
Storage S1transcript stays here
Storage S2transcript stays here
child (S1, P1)idle · thread unloaded after A’s last run
child (S1, P2)
child (S2, P1)
no child yet
Session A
Session B
Session A’s next turn
Session B’s running turn
Session B’s next turn
switch
next turn
after it ends
With more than 3 children, the least recently used idle one is stopped. A lease or a subagent thread keeps a child busy, so it is never stopped.
Every ended sign-in attempt retires that account’s older children once they are idle.
  • Storage profileCODEX_HOME · sqlite_home

    A PlanToCode-owned Codex home named by an opaque UUID. A new session links to the home of the account active at its first turn, and a switch never copies, moves, or relinks the transcript.

  • Account--auth-home

    The signed-in ChatGPT profile, called the execution profile, that is active when a turn is admitted. The turn keeps it until it ends.

  • Codex childCodexRuntimePair

    One per storage and account pair, started on demand. Before a child resumes a thread, every other child unloads its idle copy, so a running turn never moves.

The source link stores a thread ID and the exact rollout path. thread/start returns only the ID, so the desktop reads the absolute path with a separate thread/read before it publishes the link and before turn/start; a child thread’s path arrives with its thread/started notification. Changing credentials never changes the conversation source.

The cap of three never stops a busy child, so with every child busy the registry grows instead of stalling. Every sign-in attempt that ends, including a timeout or cancellation, bumps the execution profile’s auth generation, and a child started under the old generation finishes its work and retires once idle. After each run the desktop unloads the thread with thread/unload, and a failed unload retires that child, because the process boundary is the only proof that the JSONL writer is gone. Before a thread’s source link changes, every idle copy on every live child is unloaded too.

The vendored sidecar

A build passes only while the accepted revision equals the last fetched main
A build passes only while the accepted revision equals the last fetched mainThree values change at different times. Official main moves from A to B to C. The local snapshot moves to B only when reconcile:codex-source fetches it, and the accepted revision moves to B only when accept:codex-source runs. Every sync:codex-sidecars run compares just the snapshot and the accepted revision, so it still builds after official main moved, stops once the snapshot is newer, and builds again after acceptance. tauri:dev runs this sync first.
Official mainkeeps moving
Fetched mainlocal mirror
Accepted revisionupstream-revision
Syncevery build
A
B
C
A
B
A
B
reconcile:codex-source
accept:codex-source
A = A · builds
still A = A · builds
B ≠ A · stops
B = B · builds
sync:codex-sidecars:verify only rechecks built sidecars against their manifest. It fetches and builds nothing.
Every child that starts must list all 15 protocol extensions, or it is shut down.
  • Fetched mainrefs/remotes/origin/main

    Written only by reconcile:codex-source into the local mirror. Sync compares against this snapshot, so a newer commit on official main changes nothing until the next reconcile.

  • Accepted revisionvendor/codex/upstream-revision

    Moves only through accept:codex-source, once the reconciled candidate equals fetched main, has no conflicts, and passes its tests. The complete override files move with it.

  • Syncsync:codex-sidecars

    Stops before building unless the accepted revision equals fetched main. Otherwise it checks out that revision, copies the overrides on top, builds with --locked, and verifies the manifest.

tauri:dev runs sync:codex-sidecars first, so the child is built from the vendored source rather than taken from a globally installed Codex. Expect a Rust build and the network access described in desktop/BUILD.md.

The sync is a freshness gate. It fails unless desktop/vendor/codex/upstream-revision equals the main that the last reconcile:codex-source fetched into the local mirror, so the accepted revision is as current as that fetch. The 309 override files under desktop/vendor/codex/source implement the protocol extensions and the storage boundary the desktop relies on, which is why an unrelated upstream binary fails the initialize gate. Keep the protocol transport as the history boundary instead of adding another transcript parser.

Inspect an already prepared sidecar without starting the app
pnpm -C desktop sync:codex-sidecars:verify