Skip to article
HB CodeDocsGet the app

HandbookArchitecture

Codex turns: steering, approvals, goals, and recovery

What happens inside one Codex run: how a turn starts and is steered, who answers an approval, how a goal joins many turns into one run, how Stop works, and what the desktop does when a turn loses its child process.

Checked against the source on 30 September 2026

On this page

One thread, one run: start, owner, and steering

A Codex run in HB Code is one reader task that follows one turn, or a chain of turns, on one Codex thread. Everything in this chapter hangs on that reader: approvals reach it through the turn route, Stop talks to it through a control channel, a goal gives it the next turn, and recovery gives it a new route. The process, the actor, and the turn route are in the app-server chapter. The outbox that decides when a message is sent is in the message delivery chapter.

StepWhat the desktop does, and what a failure means
Open the threadSends thread/start for a session with no Codex thread, or thread/resume with excludeTurns: true for a linked one. A target that is a child thread is redirected to its root: the desktop follows parentThreadId with thread/read for at most 16 ancestors. If it fails: Nothing was sent. The message stays in the outbox.
Claim the threadWrites the owner record for that Codex thread ID: app session, run, and the pid of the Codex child. If it fails: Another run owns the thread. The send stops with “Codex thread … is already running for session …”.
Start guardRecords that this run is starting a turn in this session. The run cannot be closed by a finisher until the turn is registered. If it fails: Another turn start owns the session. Nothing was sent.
turn/startWrites the sending ledger entry, then sends turn/start with threadId, input, clientUserMessageId (the operation ID), model, effort, and the access level. From here on the outcome is uncertain. The desktop gives up on the answer after 31 seconds.
Register the turnStores the active turn for the app session: runtime pair, run, thread, turn, and a control channel with 16 slots. Then it wakes the steer lane of the outbox. If it fails: The desktop abandons the turn that Codex already started.
Read the turnThe reader takes notifications from the turn route until turn/completed, then looks for a goal. If it fails: See failure and recovery below.

The owner record is a map in desktop memory with the Codex thread ID as its key. It stops two app sessions that link the same thread from running it at the same time. The same run can claim again without an error. A stale owner is removed when its recorded Codex child is dead. When the record has no pid, it is removed after 45 seconds, and only if the run is no longer active. The guard is released after thread/unload and before the finishers wake the outbox, so the next queued message finds the thread free.

Steering adds a message to the turn that runs now. The desktop sends turn/steer on the runtime pair that owns the active turn. It opens no thread, starts no turn, and claims no owner, because the reader that already runs keeps all three.

turn/steer fieldValue
threadIdThe thread of the active turn.
expectedTurnIdThe turn ID the desktop registered. Codex must refuse the steer when another turn is active.
clientUserMessageIdThe operation ID of the outbox entry. The same ID names the message in history later.
inputThe message text and attachments, built like the input of turn/start.
AnswerWhat it means, and what happens to the message
turnId equal to expectedTurnIdCodex took the message into the running turn. The operation ID joins the turn’s list of accepted steers, and the acceptance is saved.
JSON-RPC error -32600Codex refused the steer. It sends this code when no turn is active, when the active turn has another ID, and when the turn is a review or a compaction. The desktop reads this one code as “not admitted”. The message stays queued.
turnId of another turnCodex accepted it for a turn the desktop did not expect. Uncertain. The desktop does not send it again. Check the timeline.
Any other error or a timeoutThe request may or may not have arrived. Uncertain, with the same handling.
No active turn in the desktop, or a turn of another runThe desktop did not send turn/steer. The message stays queued.

Stop and send now needs one more call. A steer that Codex accepted may still wait inside the turn as pending input. The desktop sends turn/detachPendingInputForReplay with the same threadId, expectedTurnId, and clientUserMessageId. The answers detachedForReplay and alreadyDetachedForReplay permit the replay in a new turn. The answer applied means the model already has the message, so no replay starts. The answers recording and staleTarget, and any error, also block the replay.

Who answers an approval

Codex asks for approval with a JSON-RPC request, and it waits for the response. The actor of that Codex child keeps each open request in a map and must find someone who can show it. A request that nobody can show is declined, because a turn that waits for an answer that cannot come would never end.

A Codex approval waits for a person only while someone can show it
A Codex approval waits for a person only while someone can show itSix lanes over one axis in seconds, to scale. Each lane is one approval request that arrives at 0 seconds. A request for a turn with a live route opens a card with no deadline, and a phone accepts it after 24 seconds with run.chatResolveApproval. A request from a subagent thread is claimed by the run’s observer and opens a card in the root session. A subagent request that nobody claims is declined when its 30 second window ends. A request with no route and no observer is declined at once. A request whose turn completes after 14 seconds is declined with the turn. With the access level Unrestricted the reader accepts the request and no card opens.
Own turnturn route is live
Subagent threadobserver claims it
Subagent threadnobody claims it
No consumerno route, no observer
Turn ends firstturn/completed
UnrestrictedapprovalPolicy never
approval request arrives
card in the session, no deadline
accepted
run.chatResolveApproval
card in the root session, no deadline
claimed
waits for a claim: 30 s
declined
declined at once
card open
declined with the turn
accepted by the reader, no card
0 s
10 s
20 s
30 s
  • Turn routeitem/*/requestApproval

    A request for a turn that a run reads goes to that reader. The card has no deadline. It closes with an answer, with the end of the turn, or with serverRequest/resolved.

  • Observer claimUNROUTED_APPROVAL_CLAIM_TIMEOUT · 30 s

    A request with no route is broadcast. The observer of the root run, or of a watched thread, must claim it within 30 seconds. After a claim the card has no deadline.

  • Declined by the desktopdecline

    At once with no route and no observer, or with no supported decision. After 30 seconds with no claim. With turn/completed, with an abandoned turn, and when the actor stops.

  • Answerrun.chatResolveApproval

    Offered to every live Codex child, most recently used first. The actor that holds the request checks request, thread, turn, and item ID and writes the response.

CardRequest method and decisions offered
Commanditem/commandExecution/requestApproval. Offers the list in availableDecisions. Without a list: accept, decline, cancel. A network request adds acceptForSession. A proposed command prefix or network host adds accept broadly.
File changeitem/fileChange/requestApproval. Offers accept, acceptForSession, decline, cancel.
Permissionsitem/permissions/requestApproval. Offers accept, acceptForSession, decline.
  • Delivery has two paths. A request for a turn with a live route goes to that turn’s reader, which shows the card in the session. A request with no route is broadcast to observers. Requests from subagent threads take this path, because no reader owns their turns.
  • An observer must claim a broadcast request within 30 seconds. The run’s observer first proves that the thread belongs to its root thread, with a limit of 10 seconds for that check. An unclaimed request is declined when the 30 seconds end.
  • A request with no supported decision, and a request with no route and no observer, are declined at once.
  • Codex can send the same request again after a reconnect. An identical request keeps its place. A different request with the same ID makes the desktop decline that ID and close the card.
  • When turn/completed arrives, the actor declines every open request of that turn before the reader sees the completion. Abandoning a turn and shutting the actor down do the same.
  • serverRequest/resolved from Codex closes the card as resolved elsewhere. During the initialize handshake no card can exist, so an approval request is declined.

An answer from the desktop window or from a phone takes one path. A phone calls run.chatResolveApproval, and the desktop offers the answer to every live Codex child, most recently used first. Each actor compares the request ID, thread ID, turn ID, and item ID with its map, so exactly one child accepts it. A decision that the request did not offer is refused.

The access level Unrestricted sends approvalPolicy never. If a request still arrives, the reader answers it without a card. It picks the first decision the request offers from acceptForSession, accept, and accept broadly. The other access levels send approvalPolicy on-request.

Goals: many turns in one run

A goal is state that Codex keeps on a thread: an objective, a status, a token budget, and counters. While the status is active, Codex starts the next turn by itself when a turn ends. The desktop does not start these turns. It finds each one and attaches a reader, and the whole chain stays one chat run with one operation ID.

A Codex goal joins many turns into one run, and Stop pauses the goal first
A Codex goal joins many turns into one run, and Stop pauses the goal firstFour lanes, not to scale. The desktop sets the goal to active with thread/goal/set. Codex starts turn 1. After each turn the reader sends thread/goal/get, reads active, and attaches to the next turn with thread/resume. One chat run covers all three turns. Stop comes during turn 3. The desktop first sets the goal to paused and then sends turn/interrupt. Turn 3 ends as interrupted, the goal is paused, and the run ends.
after Stop
Goal statuskept by Codex
Desktopreader of the run
Turnsstarted by Codex
Chat runone operation ID
active
paused
turn 1
turn 2
turn 3
one run for all three turns
thread/goal/set: active
goal/get: active
thread/resume attaches
active
attaches
set: paused first
turn/interrupt
interrupted
the goal is paused, so the run ends
not to scale
  • Goalthread/goal/get · set · clear

    State that Codex keeps on the thread: objective, status, token budget, and counters. While the status is active, Codex starts the next turn by itself.

  • Attachthread/resume · 3 × 250 ms

    After every turn the reader reads the goal. While it is active, the reader resumes the thread and takes the latest turn: a new turn ID, or a turn that is still in progress.

  • One runoperation ID

    All turns of the chain belong to one chat run with one operation ID. A steer sent between two turns waits for the next turn.

  • Stop orderstatus: paused · turn/interrupt

    The pause comes first and retries every 250 ms on transport errors. The interrupt has 8 seconds to reach the reader and 8 seconds for the answer. On failure the desktop abandons the turn.

MethodUse
thread/goal/getReads the goal. The reader calls it after every turn.
thread/goal/setSets objective, status, or tokenBudget. A null tokenBudget clears the budget.
thread/goal/clearRemoves the goal.

A goal has the fields objective, status, tokenBudget, tokensUsed, timeUsedSeconds, createdAt, and updatedAt. The status is one of active, paused, blocked, usageLimited, budgetLimited, and complete. The reader follows the chain only while the status is active.

  • A goal run starts with thread/goal/set and status active. It sends no turn/start. Codex starts the first turn.
  • After each turn the reader sends thread/goal/get. While the goal is active it sends thread/resume with excludeTurns: false and takes the latest turn. A turn counts when its ID is new or when it is still in progress. The reader tries 3 times, 250 ms apart, then reads the goal again.
  • A turn that is in progress gets a turn route and is read like any other turn. A turn that already completed is settled from the snapshot in the resume answer.
  • Every Codex chat run ends with the same check. A message sent to a thread with an active goal therefore stays one run until the goal leaves the active status. A run that finds no active goal still looks once for a newer turn with thread/resume, and then it ends.
  • A steer that arrives between two turns waits. Each new turn wakes the steer lane of the outbox again.
  • A goal run unloads its thread with thread/unload at the end. A failed unload there is only logged.

Stop is a ladder of four steps

Stop marks the run as aborted and then works down a ladder. The order matters for goals: a turn that is interrupted while its goal is still active would be followed by a new turn at once.

StepCall, limit, and failure
Wait for the turnNone. A run that has no registered turn yet is checked every 25 ms. Limit: 45 seconds. If it fails: Stop reports that the cancellation was not acknowledged. The run continues.
Pause the goalthread/goal/set with status paused. Limit: Retries every 250 ms on transport errors, with no upper limit. If it fails: “No goal exists” counts as done. A permanent error clears the abort mark, and the run continues.
Interruptturn/interrupt, sent by the turn’s reader through its control channel. Limit: 8 seconds to reach the reader, then 8 seconds for the answer. If it fails: The desktop goes to the next step.
AbandonThe actor declines the turn’s open approvals, drops the turn route, and writes turn/interrupt itself. Limit: 5 seconds to queue the command. If it fails: Stop reports both errors.

Codex answers turn/interrupt when the turn has stopped, and it sends turn/completed with the status interrupted. The reader takes that completion as the end of the turn, and the run ends as cancelled. A run that was aborted between turn/start and the registration of the turn is handled by the run itself: it pauses the goal and abandons the new turn, and it retries both every 250 ms on transport errors.

The five-second rule after an error

Codex reports a problem inside a turn with an error notification, and the field willRetry says whether Codex goes on. The error alone never ends the turn. The turn ends with turn/completed, and the reader waits for it, but only for 5 seconds.

After an error that won’t retry, a Codex turn has 5 seconds to complete
After an error that won’t retry, a Codex turn has 5 seconds to completeThree turns on one axis in seconds, to scale. Turn A gets an error with willRetry: true and goes on. At 0 seconds it gets an error with willRetry: false. turn/completed follows 1.8 seconds later, and the turn fails with the saved error. Turn B gets the same error at 0 seconds and no turn/completed. The reader waits 5 seconds, and the turn still looks like a running turn. Then the reader fails it. Turn C has no error. After turn/completed the reader keeps reading for 75 milliseconds, drawn to scale as a thin bar, and then drops the route.
Turn Acompletion follows
Turn Bnothing follows
Turn Cno error
turn runs
error, willRetry: true: goes on
waits
failed, with the saved error
error, willRetry: false
turn/completed
turn runs
no turn/completed: waits 5 s
the turn fails
the turn still looks like a running turn
turn runs
completed, route dropped
turn/completed
75 ms drain
0 s
1 s
2 s
3 s
4 s
5 s
  • Error that retrieserror { willRetry: true }

    The reader ignores it. Codex retries, and the turn goes on.

  • Settlement deadlineTURN_FAILURE_SETTLEMENT_TIMEOUT · 5 s

    Starts with the first error that won’t retry. The error is saved. turn/completed inside the window ends the turn as failed, whatever status it names.

  • Deadline endsFailed

    No turn/completed came. The reader fails the turn with the saved error and drops the turn route. It sends no turn/interrupt.

  • DrainTURN_POST_TERMINAL_DRAIN_TIMEOUT · 75 ms

    After a clean turn/completed the reader takes late item notifications until 75 ms pass with none. Then it closes open approval cards and drops the route.

What the reader seesResult of the turn
error with willRetry: trueNothing changes. Codex retries.
error with willRetry: falseThe error is saved and the 5 second deadline starts. A second error replaces the saved one and keeps the first deadline.
turn/completed with status failedFailed, with the error of the completion, or else the saved error.
turn/completed with status completed after a saved errorFailed, with the saved error.
turn/completed with status interruptedFailed when an error is saved. Otherwise interrupted, and the run ends as cancelled.
No turn/completed within 5 secondsFailed, with the saved error. The reader drops the turn route.
The route closes after a saved errorFailed, with the saved error. No rejoin is tried.
turn/completed with no errorCompleted. The reader keeps reading for 75 ms of silence, then drops the route.

The reader wakes every 100 ms when no notification arrives. It uses that tick for Stop requests and for the deadline, and a quiet turn is never failed for silence alone. The 75 ms drain exists because Codex can send the last item notifications just after turn/completed. The same 5 seconds apply to a subagent thread: after an error that will not retry, its thread stops counting as active for the child process after 5 seconds.

turn/completed and an error that will not retry are never dropped on a full turn route. They wait in a separate lane in arrival order, and ordinary events for that turn are dropped while one waits.

When the turn route is lost, the desktop asks for the exact turn

The reader has one signal for a lost connection: its turn route closes while no error is saved. The actor closes every route when it stops, for example when the child’s stdout closes. The desktop then asks Codex what became of the turn, and it asks for that exact turn. A turn cannot move to another process. A new Codex child loads the thread from its saved history and reports a turn that was still in progress as interrupted. The answer is inProgress only when the child that answers still runs the turn.

When the Codex child is lost, the reader asks a new child what became of the turn
When the Codex child is lost, the reader asks a new child what became of the turnFour lanes, not to scale. Codex child A runs turn T, and the reader takes notifications from route A. The stdout of child A closes. The route closes, and the reader sees a lost connection. It waits at most 1 second and leases a client for the same profile pair, which starts child B. The reader sends thread/resume and takes turn T from the answer. Child B loads the thread from its saved history and runs no turn, so it reports turn T as interrupted. No route opens. The timeline gets no new rows during the gap. Then its rows are matched to the snapshot of the turn. The run has no active goal, so it fails with the message that the turn was interrupted.
Codex child Acodex-app-server
Codex child Bsame profile pair
Turn readerone chat run
Timelinedesktop and phones
turn T in progress
stdout closes
thread loaded, no turn runs
started by the registry
reads route A
waits 1 s at most
the run fails: turn was interrupted
live rows
no new rows
rows matched to the snapshot
thread/resume, exact turn
interrupted
route closes: connection lost
not to scale
If the child that answers still runs the turn, the answer is inProgress. Then the actor opens a new route and the reader continues. If the answer is completed, the snapshot settles the run. With an active goal, the run waits for the goal’s next turn.
  • Lost connectionConnectionLost

    The turn route closes and no error is saved. Only this failure starts a rejoin. Any other failure without a final status makes the desktop abandon the turn.

  • New clientlease_codex_app_server_for_runtime

    The reader waits up to 1 second for the old client to close and leases the same runtime pair. The registry starts a new child if the old one is gone.

  • Rejointhread/resume { excludeTurns: false }

    The desktop takes the turn with the expected ID. The turn’s recorded access settings, the owner pid, and the observers move to the new client.

  • Answerinterrupted · inProgress · completed · failed

    A new child reports a turn that was in progress as interrupted. With no error the run fails, or it waits for the next turn of an active goal. A turn is rejoined once.

  • Only a lost connection starts a rejoin. Any other failure that is not a final status makes the desktop abandon the turn with turn/interrupt.
  • The reader waits up to 1 second for the old client to close. Then it leases a client for the same runtime pair, and the registry starts a new child if the old one is gone.
  • The access settings that the old client recorded for the turn move to the new client, and the owner record gets the new pid.
  • When the client changed, the observers for subagent approvals and for agent messages are stopped and started again on the new client.
  • The rejoin is thread/resume with excludeTurns: false. The desktop takes the turn with the expected ID from the answer. When the answer has no such turn, the run fails and the desktop abandons the turn.
Status in the answerWhat the run does
inProgressThe child that answers still runs the turn. The actor opens a new turn route, and the reader registers the turn again and continues.
completedThe snapshot settles the run. No route is opened.
interrupted with no errorThe answer of a new child for a turn that was in progress. A run with an active goal waits for the goal’s next turn. Any other run fails with “Codex app-server turn was interrupted”.
interrupted with an error, or failedThe run fails with that error.

For every answer the desktop first matches the rows on screen to the snapshot of the turn, so the work that Codex saved before the loss stays in the timeline. A turn is rejoined once. When the new route also fails, the run ends with that failure. Between two turns of a goal the reader has no route to lose, so it recovers on transport errors of thread/goal/get and thread/resume instead, at most 2 times in a row.

Child threads, watched threads, rollback

A Codex agent can spawn subagents, and each subagent is a thread of its own below the root thread. No run of the desktop owns their turns. The desktop lists them, saves where their files are, and follows one live only while a window shows it.

MechanismHow it works, and its limits
Child listthread/list with ancestorThreadId, sourceKinds: ["subAgentThreadSpawn"], and useStateDbOnly: true, on the profile that owns the root. Threads that the session links and the index does not know are added with thread/source/read. Limits: 100 threads per page, at most 25 pages. A longer list is cut and logged.
Child paththread/started for a child names its JSONL path, and the desktop saves it. A chat run waits for these saves before it finishes. A failed save retires the Codex child, like a failed thread/unload.
Watched threadA window that shows a subagent registers a watch for the pair of session and thread. The first watch starts a follower that turns the thread’s unrouted notifications into live rows. The follower stops when the last watch is released and no turn of the thread is live. Limits: Watch ID at most 256 bytes. The thread’s root must be the session’s root, checked within 10 seconds.
FollowerSubscribes to the client’s unrouted activity and approvals. When the client goes away it subscribes again. After the child’s turn ends, it matches the live rows to history. Limits: Resubscribes after 1 second. 3 settlement attempts.
Rollbackthread/resume with excludeTurns: true, thread/turns/list with limit 1 and descending order, then thread/revert with beforeTurnId. Limits: Refused while the latest turn is inProgress, and for an exact external JSONL source.
Live settingsthread/settings/update on the runtime pair of the active turn, with model and effort together, the access level, or both. With no active turn nothing is sent. The saved settings apply at the next turn/start.

With no watcher nothing follows a subagent thread. Its notifications stay unrouted, and the desktop reads the thread from history when you open it. An approval from a subagent does not need a watch: the observer of the root run claims it, as the approvals section describes.