Skip to article
PlanToCodeDocsGet the app

HandbookArchitecture

Message delivery: outbox, turn, history

How a message enters the durable outbox, reaches a Codex turn, is confirmed by Codex’s echo, and waits for you instead of being sent again when the reply is lost.

Checked against the source on 17 September 2026

On this page

The life of a message

A message is saved, accepted, and confirmed at three separate moments
A message is saved, accepted, and confirmed at three separate momentsThe drawing follows one example message from the desktop composer, left to right. The desktop saves the outbox entry in one transaction and returns the receipt, and the composer clears before any turn has to exist. When turn/start answers, the operation ledger records which turn accepted the message. Only when Codex echoes the user message with its operationId does the ledger say sent and the entry leave the queue.
Composeron the desktop
Outbox entry
Operation ledger
Codex child
queued
sending
sending
turn runs
saved
accepted
confirmed
submit
receipt, composer clears
turn/start
reply with turn ID
sent
removed
item/completed
records the user message
  • Savedreceipt · queueId · operationId

    One transaction stores the request fingerprint and the outbox entry, a drain task starts, and the receipt goes back. The desktop composer clears on the receipt. No turn has to exist yet.

  • AcceptedrunId · threadId · turnId · acceptedAt

    Written to the operation ledger when turn/start answers. It proves a turn took the message. Only the echo proves that Codex recorded it.

  • ConfirmeduserMessage.clientId == operationId

    Codex records the user message with the operationId and sends item/completed. The ledger marks the operation sent, and then the entry leaves the queue.

  • No clear replyuserMessage.clientId in history

    If turn/start gets no clear reply, the desktop looks for the operationId in history. Found means sent and removed. Missing or unreadable holds the entry with auto-drain off and asks you to check the timeline first.

One message has several identities

IdentityWhat it identifies
sessionIdThe PlanToCode conversation that owns the outbox.
queueIdThe visible entry in that outbox.
operationIdThe immutable message operation, retained across delivery and recovery.
idempotencyKeyThe remote mutation request. Its replay rules belong to the RPC layer.
runId / threadId / turnIdThe run and the exact agent turn that accepted the operation. The operation ledger keeps them with acceptedAt, and a steer’s acceptance is also stored on the outbox entry.

The submit service computes an immutable request fingerprint and checks the recorded operation identity before admission. The fingerprint is a SHA-256 over sessionId, queueId, userText, promptText, attachments, codexThreadId, speedMode, accessMode, the expected model and effort, rollbackBeforeSend, source, and createdAt. It deliberately excludes intent, sendMode, and expectedActiveRunId, so queueing a message and then sending it now converges on the same operation, while retargeting a steer to a different run is a conflict. The database transaction claims the pair (session_id, operation_id) and saves the outbox JSON together, and a different fingerprint under the same operation ID cannot overwrite the original claim.

Custody, acceptance, and history are separate

Each moment writes its own record. The receipt does not prove a turn started, and an accepted turn does not prove that Codex recorded the message.

MomentRecordWhat it proves
Submitworkspace_chat_outbox and workspace_chat_outbox_operation_identity, written in one transactionThe desktop has custody and returns the receipt.
Just before turn/start reaches the actorcodex_chat_operation_ledger with status sendingA send attempt started, so a turn may exist.
turn/start answersThe ledger result gains runId, threadId, turnId, and acceptedAtA turn accepted the message.
userMessage.clientId arrives live or is found in historyLedger status sent, then the outbox entry is removedCodex recorded the message. Delivery is confirmed.

The persisted entry status has two values: queued and sending. Held and failed entries can return to queued with autoDrain disabled and an error or hold reason. Sent, failed, and blocked also occur as service outcomes; they are not additional values of WorkspaceChatOutboxStatus.

Submission controlMeaning
intent: automaticSave custody and allow the selected drain path.
intent: enqueueAppend to the queue and send automatically when the session is idle.
intent: replace-latestSet the replacement behavior, including rollbackBeforeSend, in the saved entry.
sendMode: queueUse queued delivery.
sendMode: steer-runningUse the steering drain path for an eligible automatic submission.
sendMode: interrupt-runningStop the active run, wait until it has settled, and then start this message as its own run.

Validation ties the controls together: enqueue requires sendMode queue, replace-latest requires interrupt-running, steer-running requires expectedActiveRunId, and any other mode rejects that field. A session outbox holds at most 256 entries, and the phone waits 20 seconds for the receipt.

What the composer actions do

Queue waits for the run, steer joins it, interrupt ends it
Queue waits for the run, steer joins it, interrupt ends itThree rows show the same running turn R and a message sent at the same moment. A queued message waits until R ends and then starts its own run. A steer joins R. An interrupt checks the model, stops R, waits for R to settle, and then starts the message as its own run, earlier than the queued one.
you send
Queue
run R
waits in the queue
its own run
Steer
run R
joins run R
R already ended: its own run. Another run active: it waits for that run to end.
Interrupt
run R
stopped
stops run R
waits ≤ 10 s
its own run
With no run active, each action starts a new run once it reaches the front of the queue.
  • Queueintent: enqueue

    Appended to the end of the queue and never mixed into a running turn. Editing a queued message keeps its queueId and takes a new operationId.

  • Steersteer-running · expectedActiveRunId

    Bound to the run you watched. turn/steer adds the message to that run, so the run’s model, effort, speed, and access apply. If the run ends before using it, the message comes back with auto-drain off.

  • Interruptinterrupt-running

    The model is checked before the run is stopped. The message starts once the old run has left the run and turn registries, checked every 25 ms for up to 10 s. A newer turn that appears during the wait keeps it queued.

  • Replace latestreplace-latest · rollbackBeforeSend

    Stops a running turn like an interrupt, then rolls back the latest turn before this message is sent.

A steer reply that proves the guidance was not admitted returns the operation to automatic queue custody, and transport uncertainty goes through the same reconciliation as any other send. If the old run is still settling after 10 seconds, an interrupt’s replacement also stays queued. Editing a queued message rewrites the text and keeps its place in the queue unless you send it now or reorder it. Removing one deletes the waiting entry and leaves the active run untouched.

The admission boundary controls safe recovery

After the hand-off to the actor, even a timed-out send may have started a turn
After the hand-off to the actor, even a timed-out send may have started a turnThe left part, not to scale, shows the steps before the hand-off: the send path reserves a slot on the actor’s command queue, writes sending to the operation ledger, and checks that the actor is open. A failure there proves nothing reached Codex. The right part is drawn to scale in seconds after the hand-off. The actor writes turn/start and waits 30 seconds for the reply. Codex can start the turn anyway, and a reply that comes later is ignored, so the desktop checks history instead of sending again.
Operation ledger
Send path
App-server actor
Codex child
hand-off to the actor
slot reserved
sending written
actor open
Fails here: PreAdmission. Nothing reached Codex.
not to scale
waiting for the reply
turn may start anyway
turn/start, write ≤ 5 s
accepted, only if the reply comes in time
no reply after 30 s: uncertain
check history
late reply ignored
0 s
10 s
20 s
30 s
40 s
  • Hand-offcommand_permit.send

    The reserved command passes to the actor only after the ledger says sending and the gate finds the actor open. The send path itself stops waiting 31 s after the hand-off.

  • Failed beforePreAdmission

    A reservation timeout, a failed sending write, a retiring actor, or a closed actor. The entry returns to the queue with auto-drain off and the error.

  • Uncertain afterAdmissionUncertain

    An error reply, a failed write, a closed channel, a deadline, or a failed acceptance record. The turn may exist, so the desktop checks history. Found means sent. Missing or unreadable holds the entry.

  • Late resultsexpected_sending_entry

    Recovery changes an entry only while it still matches the snapshot taken while it was sending, so a late result never overwrites a newer queue decision.

Each session has two drain lanes, normal and steer, each with its own guard. A wake-up that arrives while a lane is busy only sets a follow-up flag, which the finishing pass returns, so nothing that lands at the settlement boundary is lost. The steer lane admits only the queued head, only for an exact active-run steer, and never starts a new turn. A steer whose run already ended waits at the head while a different run is working, and then starts as a new run.

Delivery cleanup uses the queue and operation pair. The registry keeps runtime clients leased for active work, including descendant thread activity, and routes approval resolution to the actor that owns the full request identity.

FailureRequired interpretation
Saving custody failsDo not claim a durable receipt. No automatic admission should precede the save.
Acceptance exists, history is delayedKeep acceptance evidence. Do not infer that the message is absent from the execution.

Uncertain delivery

After an uncertain send, or after a restart with an entry still marked sending, the desktop reads the operation’s frozen history route. Delivery is confirmed only by a userMessage.clientId equal to the outbox operation ID, on the storage profile’s indexed thread or on the exact path with its ancestor manifest. Sibling threads are never scanned. A confirmed operation is written to the sent ledger before outbox custody is removed. Missing evidence, a read failure, or a route mismatch leaves the entry visible with automatic drain off, and there is no automatic retry or backoff.

Stop and send now is the only automatic re-send. The figure shows when the desktop allows it.

An accepted steer is sent again only after Codex confirms it was never applied
An accepted steer is sent again only after Codex confirms it was never appliedStop and send now can take back a steer that the running turn accepted but hasn’t used yet. After a model check, the desktop marks the entry requested and asks Codex to detach the steer from run R’s pending input. Only when Codex confirms the detach does the desktop revoke the acceptance, stop R, wait until R has settled, and queue the entry again with the same operationId, so it starts its own turn.
Agent runs
Pending inputof run R
Outbox entry
Operation ledger
run R
stopped and settled
its own run
same operationId
waits to be applied
never applied
sending
requested
authorized
acceptance for run R
stop and send now
ask to detach
detachedForReplay
revoked
stop R
dedicated, auto-drain on
Any other reply, or no reply, sends nothing again: applied, recording, or a changed turn holds the entry with the reason and auto-drain off.
If run R doesn’t settle, the entry waits as authorized. A later history read that finds the message missing queues it again.
  • Requestedhandoff.phase: requested

    The model is checked first. Then the entry records run R, its thread, and its turn, with auto-drain off.

  • Detachturn/detachPendingInputForReplay

    Asks Codex to take the steer out of the run’s pending input by its operationId. Only detachedForReplay or alreadyDetachedForReplay lets the desktop go on.

  • Revokecodex_chat_operation_ledger

    Clears only the acceptance for that exact run, thread, and turn. If the ledger already says sent, nothing is replayed.

  • Dedicatedhandoff.phase: dedicated

    The entry is queued again with auto-drain on and the same operationId, and it starts its own turn once run R has settled.

What Stop does

Stop asks the Codex app-server to cancel the current turn. Commands that already ran and files that were already edited stay as they are, so read the timeline and the Git diff once the run has settled. Removing a queued message, stopping a run, and reverting a file are three different actions, and only the last one touches your working tree.