Skip to article
HB CodeDocsGet the app

HandbookArchitecture

The Codex app-server integration

How the desktop finds, starts, and supervises the Codex child process: the JSON-RPC pipe, every method the desktop sends, what keeps a child alive, profiles and sign-in, usage limits, and the tool list that is fixed when a thread starts.

Checked against the source on 5 October 2026

On this page

A supervised child process

Codex runs as a child process named codex-app-server. The desktop looks for the binary in the folder of its own executable, then in ../Resources on macOS, then in binaries, src-tauri/binaries, and desktop/src-tauri/binaries below the current directory. It accepts the names codex-app-server and codex-app-server-<target triple>. The binary codex-code-mode-host must be in the same folder. The desktop never falls back to a Codex that is installed on the machine, and a missing sidecar fails the start with an error that names pnpm -C desktop sync:codex-sidecars.

Arguments of the child command, in the order the desktop adds them
-c features.local_thread_store_compression=false
-c features.background_paginated_rollout_migration=false
-c features.multi_agent_v2.tool_namespace="hbcode"
--listen stdio://
--session-source app-server
-c sqlite_home=<storage-profile-home>
--allowed-storage-root <storage-profile-home>
--auth-home <execution-profile-home>
What the child seesValue
CODEX_HOME, CODEX_SQLITE_HOMEThe storage profile home, <app data>/codex/profiles/<uuid>/home. Inherited values are removed first.
CODEX_CONFIG, CODEX_CONFIG_HOME, CODEX_DISABLE_SQLITE_LOGSRemoved from the environment.
Working directory<app data>/codex/sidecar-cwd.
PATHStarts with a folder that holds rg: the bundled codex-path folder, the sidecar folder, or a copy staged in the cache folder. HBCODE_RG_BIN names another rg.
NO_COLOR1.
RUST_LOGwarn, unless HBCODE_CODEX_APP_SERVER_RUST_LOG holds another filter.
stdin, stdout, stderrThree pipes. The process is killed when the desktop drops its handle.

Each profile home has a stable opaque UUID. The desktop creates every folder of that path itself and refuses a symlink in any component. The bundled child refuses to open a database unless --allowed-storage-root, CODEX_HOME, and sqlite_home canonicalize to the same directory.

Before each start the desktop reads the pidfile in the profile root. The file is app-server.pid when storage profile and execution profile are equal, and app-server-<execution profile>.pid for a mixed pair. The desktop terminates the recorded process only if it is still a codex-app-server and this desktop did not start it. A process that this desktop runs is never signalled. The desktop then records the new process ID and drains stderr in a separate task.

The handshake is initialize, with client information and experimentalApi enabled, then initialized. 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, and any other initialization failure shuts it down too. This gate makes an arbitrary upstream binary unusable.

The JSON-RPC pipe

One JSON object per line on stdin and stdout
{"id":42,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"hbcode","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 child processwrites 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 the call, no actor state
A full channel drops ordinary events. turn/completed and errors that won’t retry wait in order, and ordinary events are dropped while one waits.
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. It gets the request (tool, arguments, threadId, turnId, callId) and no actor state, and the actor writes the reply. An approval with no live consumer is declined.

Limit of the process and the clientValue
Child start, handshake included35 seconds.
Request response30 seconds after the write. 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.
Place in the actor command queue5 seconds. The queue holds 256 commands.
Tool replies that wait for the actor64.
Messages kept while initialize waits4,096. A full buffer drops an ordinary event before a critical one.
Graceful shutdown before kill2 seconds after stdin closes.
stderr lines kept for the error textThe last 40.

The child can send requests too. The desktop answers two kinds: a tool call in the hbcode namespace and the three approval requests. Every other request gets JSON-RPC error -32601 with the text “HB Code desktop does not handle app-server request”, and a tool call for another namespace is such a request. An approval request that arrives while initialize waits is declined. A stdout line that does not start with { or [ is logged and ignored. When stdout closes, the error carries the stderr tail.

Every method the desktop sends

The Codex client is a fixed set of 27 methods. The desktop sends no other method to the child.

MethodWhen the desktop sends it
initialize, initializedOnce, right after the child starts.
thread/startFirst turn of a new session, a one-shot text request, and a private worker. Carries dynamicTools, developerInstructions, and the access level.
thread/readRight after thread/start, to learn the absolute rollout path. Also to read a thread’s parent, up to 16 levels, when the desktop looks for the root thread.
thread/resumeBefore a turn on a known thread, with the exact path and history sources. Also used to attach to a running turn and before a rollback.
thread/source/readTo read a thread from an exact JSONL path.
thread/items/listEach history page of the timeline.
thread/turns/listBefore a rollback, limit 1, to find the latest turn.
thread/revertRollback of the latest turn, with beforeTurnId. A turn that is inProgress refuses it.
thread/listExisting sessions (sourceKinds appServer, cli, vscode, 25 per page by default, at most 100) and the subagent threads below a thread (ancestorThreadId).
thread/unloadAfter a run, after a one-shot request, and before another child opens the same thread.
thread/settings/updateA change of model, effort, or access level while a turn runs. Without a running turn the desktop sends nothing, and the saved settings apply at the next turn.
thread/commandOutput/readA slice of saved command output.
thread/goal/get, thread/goal/set, thread/goal/clearGoal reads and changes.
turn/startEach new turn.
turn/steerA message for the turn that runs.
turn/detachPendingInputForReplayStop and send now takes a pending message back.
turn/interruptStop.
model/listThe model catalog, with includeHidden false.
account/read, account/sessions/listAccount identity after a sign-in and for the subscription picker.
account/rateLimits/readUsage limits.
account/usage/readToken activity for the usage history.
account/login/start, account/login/cancelBrowser sign-in.

The hbcode tool namespace

thread/start carries dynamicTools, which add one namespace called hbcode 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 list holds 4 to 8 tools and follows the settings at that moment.

ToolIn the list, and its approval class
use_user_browserAlways. Class agent: the agent asks its user.
maintain_sessionsAlways. Class read-only: no one approves.
manage_sessionsAlways. Class agent: the agent asks its user.
send_session_messageAlways. Class desktop: the desktop applies its own rule.
send_mobile_notificationWhen the Desktop and phone notifications setting is on. Class agent: the agent asks its user.
synthesize_speechWhen a Gemini API key is saved. Class agent: the agent asks its user.
create_explainer_videoWhen a Gemini API key is saved. Class agent: the agent asks its user.
extract_video_contextWhen a Gemini API key is saved. Class agent: the agent asks its user.

thread/start also carries developerInstructions. agents/session_rules.rs joins three texts for them, in this order: the app-tool text of the catalog, the directions rule, and the text of the reply style. The Default style adds no text. A normal send and a goal run use the same instructions. Codex reads them when a thread starts. thread/resume sends them again, and Codex uses them only when a compaction rebuilds the context of the resumed thread. A reply style change therefore reaches new sessions, and an older session only after a compaction.

The approval class says who approves a call for an agent that asks its user before tool calls. Codex runs these tools without an approval request, so for Codex only the desktop class has an effect: a session message that needs the user waits in the queue of the recipient.

Every spec is marked deferLoading and holds no session, run, or project values. The app-tool text is the namespace description, which names the tool groups, and then “Find the hbcode tool for the task. Follow its instructions. For a video, give a brief. Its tool runs a worker.” The namespace description and the app-tool text together stay under 600 characters. For each item/tool/call the desktop looks up the calling thread’s session link and that session’s active run, so a resumed thread or a forked subagent reaches the run in progress. A call to a tool name the desktop doesn’t know gets a result with success: false and the text “Unsupported HB Code app tool.” The JSON-RPC reply itself is a normal result.

create_explainer_video starts a private worker thread, with the namespace explainer_video and the tools save_scenes, render_stills, and finish_explainer_video. The desktop gives each call of a worker thread to the job of that thread. A call in another namespace gets “This tool is not a private tool of this worker.” The desktop checks every 250 milliseconds whether the parent run is still the active run of the session. When it is not, the desktop stops the worker, interrupts its turn, and unloads the worker thread.

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

    An HB Code-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 child processCodexRuntimePair

    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.

A Codex child stops only when no lease, subagent thread, or sign-in holds it
A Codex child stops only when no lease, subagent thread, or sign-in holds itOne child of the pair (S1, P1) from start to stop, not to scale. A lease covers the start, run 1, and a later history read. A subagent thread that run 1 started stays active after the run ends, so the child stays busy. The registry checks the cap of 3 each time a lease is taken. The first check finds this child busy and leaves it. The second check finds it idle and least recently used, marks it retiring, and stops it. A probe child for another profile is registered as retiring from its start, reads the account, and stops.
busy: never stopped
idle
Codex child processpair (S1, P1)
Leasesserver + home
Subagentstarted in run 1
Cap of 3on each new lease
Probe childanother profile
starting
available: admits turns
retiring
start
run 1
read
thread still active
retiring from its start
reads account and limits, then stops
over the cap, but busy: stays
over the cap, idle, least recently used: stopped
not to scale
A failed thread/unload or an unsaved subagent path retires the child at once. Every ended sign-in attempt retires that account’s older children once they are idle.
App exit stops every child. A stop closes stdin, waits up to 2 seconds, then kills the process.
  • LeaseCodexAppServerLease

    Every run, history read, and tool worker takes one: a server lease plus a home lease for the pair. A child start and a probe take a home lease alone. Each ends with its work.

  • Subagent threadthread/started · parentThreadId

    Holds the child after the root run ends. It lets go on thread/status/changed idle, on turn/completed, or 5 seconds after an error that won’t retry.

  • Cap checkMAX_LIVE_APP_SERVERS = 3

    Runs after each lease is taken. With more than 3 available children it stops the least recently used idle one. With every child busy the registry grows.

  • Probe childaccount/read · account/rateLimits/read

    The subscription picker reads a profile that has no child through a throwaway process. It admits no turn and is stopped after the read.

Each pair has its own start lock, so one pair never waits for the start of another. A second start for a pair that is still starting fails with “This Codex subscription is still starting.” A child that is marked retiring closes its turn admission gate, and a turn/start that was prepared on it fails before admission.

After each chat run the desktop unloads the thread with thread/unload. A failed unload, or a subagent path that could not be saved, retires that child, because the process boundary is the only proof that the JSONL writer is gone. A goal run also unloads its thread, but a failed unload there is only logged. Before a thread’s source link changes, every idle copy on every live child is unloaded too.

Profiles and sign-in

A sign-in holds one profile’s child for up to 10 min 30 s while other profiles keep running
A sign-in holds one profile’s child for up to 10 min 30 s while other profiles keep runningA browser sign-in for profile Q, not to scale. The desktop starts Q’s child and sends account/login/start. The reply carries loginId and authUrl, and the desktop opens authUrl in the browser. The desktop then waits at most 10 minutes 30 seconds for account/login/completed with the same loginId. It reads account/read and account/sessions/list, saves the profile as authenticated, and announces codex-login-settled. When the attempt ends, the auth generation of Q rises by one and the sign-in child retires once idle. The children of other profiles run turns during the whole attempt.
BrowserChatGPT sign-in
DesktopRust runtime
Codex child processprofile Q
Other profilestheir children
user signs in
opens authUrl
profile Q
waits at most 10 min 30 s
saved as authenticated
codex-login-settled
held by the sign-in lease
account/login/start
returns loginId + authUrl
account/login/completed
account/read
account/sessions/list
attempt ends: auth generation + 1, this child retires once idle
turns and history reads keep running
not to scale
Cancel sends account/login/cancel. A cancel, a timeout, and a failed sign-in save the profile as signed_out and still raise the generation.
A send needs an authenticated active profile. A history read does not.
  • Profilesigned_out → authenticated

    A sign-in reuses a signed_out profile that is not active. Without one it creates a profile with a new UUID and its own home.

  • Sign-in childaccount/login/start · type chatgpt

    Held by a lease for the whole attempt. The attempt is refused while profile Q has a turn, a home lease, or another sign-in.

  • Resultaccount/login/completed · loginId

    Matched by loginId. The desktop keeps only email, plan, user ID, display name, and workspace fields. It never stores tokens, login IDs, or browser URLs.

  • Auth generationper execution profile

    Rises after every ended attempt: success, failure, timeout, or cancel. A child with an older generation is not reused for a new turn.

RuleDetail
Active profileThe setting agent.chat.codex_profile_id. A switch writes this setting and announces the change. It stops no child, and a running turn keeps its pair.
SendNeeds an active profile with the status authenticated.
History readNeeds no sign-in. A linked transcript is read through any local profile.
Delete a profileAllowed when the profile is not active, owns no session link, and no linked transcript lies in its folder. The registry also refuses while the profile has a turn, a lease, a subagent thread, or a sign-in.
What a delete stopsOnly the children of that profile. It blocks every new lease until the folder is gone, then removes the row and the folder.

A phone reads the Codex profiles with system.codexAuthProfiles and switches with system.activateCodexAuthProfile. The switch is a mutating method, so it needs an idempotency key. The answer is the new profile list.

Usage limits

Codex limits come from account/rateLimits/read on the child of the active profile. The desktop reads rateLimitsByLimitId.codex, or rateLimits when that entry is missing. It keeps the primary and the secondary window (usedPercent, window minutes, resetsAt), the credits, and the plan. A phone gets the same values with system.codexUsageLimits.

ReadCache and limit
Limits of the active profile60 seconds, one value for the whole desktop. The cache has no profile key and a profile switch does not clear it, so after a switch the old profile’s limits can show for up to 60 seconds.
Subscription picker60 seconds per profile, signed-in profiles only. Profiles are read one at a time, each with a ceiling of 45 seconds. A failed read is not cached, and a refresh clears the cache.
Picker read of a profile with no childStarts a probe child that admits no turn and stops after the read.

Requests outside a run

Session titles and text improvement use a one-shot text request. The desktop starts an ephemeral thread with no tools, permissions :read-only, and approval policy never. It starts one turn, waits at most 120 seconds for the text, and unloads the thread. After a timeout or an error it abandons the turn first. An empty answer is an error.

The model catalog comes from model/list with includeHidden false. The desktop caches it for 300 seconds per storage profile and clears the cache when a profile is deleted. A send reads the catalog and checks the selected model and effort against it before the turn is admitted.

The list of existing Codex sessions comes from thread/list on the child of the active profile. The desktop never opens the Codex state database and never scans rollout files for it.

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. 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 and add no other transcript parser.

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