# Message delivery: outbox, turn, history

> How a message enters the durable outbox, reaches an agent turn, is confirmed from the agent’s saved history, and waits for you instead of being sent again when the reply is lost.

Handbook / Architecture · Checked against the source on 2026-10-05 · https://www.hbcode.ai/docs/message-delivery

## The steps of a message from send to history

Codex sessions and Claude sessions share the outbox, the operation ledger, the run registry, and the rules that remove an entry. The shared code is in chat/. The module agents/dispatch picks the adapter of the session for four steps: the send, the history read after an uncertain send, the stop, and the take-back of an accepted steer. This chapter describes the shared part and names each step that differs.

**A message is saved, accepted, and confirmed at three separate moments**

The drawing follows one example message in a Codex session 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. A Claude session has the same three moments, and only the proof in the third one differs.

- **Saved** (receipt · 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.
- **Accepted** (runId · threadId · turnId · acceptedAt): Written to the operation ledger when the Codex request turn/start answers. It proves a turn took the message. Only the echo proves that Codex recorded it.
- **Confirmed** (userMessage.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 reply** (userMessage.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.

- [Codex turns](https://www.hbcode.ai/docs/codex-turns.md#turn-lifecycle): The agent side of a Codex send: turn/start, steer, and the echo.
- [Claude run loop](https://www.hbcode.ai/docs/claude-agent-sdk.md#run-loop): The agent side of a Claude send: one agent host per run and the proof from the transcript.

## One message has several identities

| Identity | What it identifies |
| --- | --- |
| sessionId | The HB Code conversation that owns the outbox. |
| queueId | The visible entry in that outbox. |
| operationId | The immutable message operation, retained across delivery and recovery. |
| idempotencyKey | The remote mutation request. Its replay rules belong to the RPC layer. |
| runId / threadId / turnId | The 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, threadId, 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. An operation ID whose entry was removed cannot be created again. The submit fails with a conflict and asks for a new operationId.

An outbox scope is the base session plus every session ID that adds the alias suffix ::skills-agent to it. The start and cancel barrier, the active-run check, and the sent proof all work on the scope. A sent proof under one alias removes the operation from the outbox of every alias. When the scope shows more than one active run, or a cancellation is pending, the desktop treats the scope as ambiguous and drains nothing.

## 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 the agent recorded the message. The table shows the moments of a Codex session.

| Moment | Record and what it proves |
| --- | --- |
| Submit | workspace_chat_outbox and workspace_chat_outbox_operation_identity, written in one transaction. The desktop has custody and returns the receipt. |
| Just before turn/start reaches the actor | chat_operation_ledger with status sending. A send attempt started, so a turn may exist. |
| turn/start answers | The ledger result gains runId, threadId, turnId, and acceptedAt. A turn accepted the message. |
| The echo arrives live or is found in history | The echo is a user message whose clientId equals the operationId. The ledger status becomes sent, and then the outbox entry is removed. Codex recorded the message, and delivery is confirmed. |

A Claude session writes the same records. The run and the proof differ.

| Step | Codex session | Claude session |
| --- | --- | --- |
| Run | turn/start on the Codex app-server actor. | A new agent host process for each run. |
| Delivery proof | userMessage.clientId equals the operationId, live or in history. | The transcript holds a user message with the UUID that claude_operations saved. The desktop reads it during the run and at the end. |
| Steer | turn/steer adds the message to the active turn. | The host request send writes the message into the open query. Claude reads it after its current step. |
| Replace latest, speed mode | Available. | Not available. The submit fails with a validation error, and no receipt exists. |
| Active turn that accepts more input | Registered per session. A steer and stop and send now need it. | The active run of the session, from the moment the host has the message that started it. The turn ID is the Claude ID of that message. |

The persisted entry status has two values: queued and sending. Flags carry the rest. autoDrain says that the drain may send the entry by itself. error and holdReason say why an entry waits. acceptance holds the run, thread, and turn of an accepted steer. handoff holds the stage of stop and send now. sessionMessage holds the origin of a message from another session: the sender, its call, run, and thread, its access and project, and the exchange. The desktop keeps the origin in its store. A client gets the message fact of the user row that the entry becomes, without a link and without a delivery. Sent, canceled, and blocked are results of a command. They are not values of WorkspaceChatOutboxStatus. A saved entry that cannot be decoded is shown as a blocked entry and is not dropped.

| Submission control | Meaning |
| --- | --- |
| intent: automatic | Save the message at the front of the queue, behind entries that are sending, and start the drain lane of its send mode. |
| intent: enqueue | Save the message at the back of the queue. It goes when the session is idle and the entries before it are gone. An enqueue for a queueId that is already queued keeps its place. |
| intent: replace-latest | Codex only. Save the message at the front with rollbackBeforeSend set. |
| sendMode: queue | Wait for the active run to end. |
| sendMode: steer-running | Use the steer lane and add the message to the run named by expectedActiveRunId. |
| sendMode: interrupt-running | Stop 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. The outbox has no entry limit. The phone waits 20 seconds for the receipt.

The operation ledger is the table chat_operation_ledger with the primary key (app_session_id, operation_id). Its status only moves toward stronger proof.

| Ledger status | Meaning and replacement rule |
| --- | --- |
| sending | A send attempt started. failed cannot replace it. sent and canceled can. |
| sent | The agent’s history holds the message. No other status replaces it. |
| canceled | The run was stopped before the message was confirmed. sending cannot replace it. Only the reopen check below starts it again. |
| failed | The attempt failed before the agent took the message. Any status can replace it. |

In a Codex session the ledger entry also holds admission_input_digest, a SHA-256 over the text with normalized line ends and the ordered image paths. A sent proof under any alias of the same base session counts. When SQLite cannot be written, a memory fallback of 256 entries holds the record. A full fallback drops its oldest record that is not sent, and it refuses the new record when all 256 are sent proofs.

You can send a canceled operation again. If the canceled attempt had an acceptance, the desktop first reads the agent’s history. A found message settles the operation as sent. Only a readable history without the message reopens the ledger row.

## What the composer actions do

**A queued message waits for the run. A steer joins the run. An interrupt stops the run.**

Three 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.

- **Queue** (intent: enqueue): Queue for later puts the message at the back of the queue. It is never mixed into a running turn. Editing a queued message keeps its queueId and its place, and takes a new operationId.
- **Steer** (steer-running · expectedActiveRunId): Bound to the run you watched. The message joins that run, so the run’s model, effort, speed, and access apply. Codex takes it with turn/steer, and Claude reads it after its current step. If the run ends before using it, the message comes back with auto-drain off.
- **Interrupt** (interrupt-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 latest** (replace-latest · rollbackBeforeSend): Codex only. Stops a running turn like an interrupt, then rolls back the latest turn before this message is sent. If Codex no longer knows the thread, the desktop clears the thread link and sends the message to a fresh thread.

Queue, steer, and interrupt work for both agents. Replace latest exists only in a Codex session. 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.

Replace latest checks the model first and then asks Codex to roll back the latest turn. If Codex reports that the thread is gone, the desktop deletes the session’s thread link under the history-source lock and sends the message to a fresh thread.

Editing a queued message rewrites the text and keeps its place in the queue unless you send it now or reorder it. Removing a queued entry deletes it and leaves the active run untouched. An entry that is sending cannot be removed. The command returns blocked, and the entry stays until the ledger or the history settles it.

## The admission boundary controls safe recovery

Every send attempt ends in one of six results. The result type ChatSendError is shared by both agents, and each adapter decides which class a failure belongs to. The class tells the outbox if the agent can have taken the message, because only then can a second send duplicate it.

**Of six send results, only a deferred send goes back to the queue with auto-drain on**

One outbox entry with status sending on the left. Six send results fan out from it, and each one has the entry state that follows on the right. Delivered removes the entry. An accepted steer stays sending with its acceptance. Deferred returns to the queue with autoDrain on and a holdReason. Canceled and proven unadmitted return to the queue with autoDrain off. An uncertain send makes the desktop read the agent’s history. A found message removes the entry, and a missing or unreadable one holds the entry with an error.

- **Delivered** (ChatSendStatus: sent): The agent’s history holds the message. The ledger says sent, and then the entry leaves the outbox of every alias of the session.
- **Accepted steer** (sending · acceptedAt · runId · threadId · turnId): The running turn took the message as pending input. The entry stays sending and keeps the acceptance until the delivery proof or the end of the run settles it.
- **Deferred** (ChatSendError::Deferred): The desktop held the message back on purpose, for example while an interrupted run still settles. The next drain tries again. A dedicated replay is the exception and gets autoDrain off.
- **Canceled** (ChatSendStatus: canceled): The run was stopped before the message was confirmed. The entry returns with autoDrain off and no error.
- **Proven unadmitted** (ChatSendError::ProvenUnadmitted): The agent did not take the message. The entry returns with autoDrain off and the error text.
- **Uncertain** (ChatSendError::AdmissionUncertain): The agent may have taken the message. The same path handles a result that says sending without an acceptance. The desktop reads the agent’s saved history before it changes the entry.

The next figure shows where the boundary sits in a Codex send. Before the hand-off to the actor a failure is proven unadmitted. After it every failure is uncertain.

**After the hand-off to the actor, a send that timed out may have started a turn**

The drawing shows a Codex session. The 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.

- **Hand-off** (command_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 before** (PreAdmission): 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 after** (AdmissionUncertain): 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 results** (expected_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. In a Codex session 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.

## Uncertain delivery

After an uncertain send the desktop reads the saved history of the session’s agent. In a Codex session it 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. In a Claude session the desktop looks for the operation’s saved message UUID in the Claude transcript. 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 the text “The desktop could not confirm whether this message reached the agent. Check the timeline before sending it again.” There is no automatic retry or backoff.

A restart uses the same read. At startup the desktop starts both drain lanes for every saved outbox. A client that loads an outbox while no drain and no run is active also triggers the history read for each entry that is still sending. If the ledger cannot be read at that moment, the desktop holds those entries with the same text.

Stop and send now is the only path that sends an accepted message again. It works for both agents. The figure shows when the desktop allows it, with a Codex session as the example. In a Claude session the desktop takes the message back with the host request cancel, and Claude answers whether it has started the message. A phone calls it as run.chatOutboxStopAndSendNow. The desktop runs the hand-off on its own task, so it finishes when the relay stops waiting for the response.

**An accepted steer is sent again only after the agent confirms it was never applied**

The drawing shows a Codex session. A Claude session follows the same steps and takes the steer back with the host request cancel. Stop 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.

- **Requested** (handoff.phase: requested): The model is checked first. Then the entry records run R, its thread, and its turn, with auto-drain off.
- **Detach** (turn/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.
- **Revoke** (chat_operation_ledger): Clears only the acceptance for that exact run, thread, and turn. If the ledger already says sent, nothing is replayed.
- **Dedicated** (handoff.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. The desktop waits 5 s for the stop result.

A phone reads the outbox of several sessions with run.chatReconcileSessions. One call takes at most 16 sessions and returns at most 8 MiB. It fails with invalid params for a session that has more than 256 outbox entries.

- [Recovery of a Codex turn](https://www.hbcode.ai/docs/codex-turns.md#recovery): What the desktop does when the Codex child process ends during a turn.
- [Stop in a Codex turn](https://www.hbcode.ai/docs/codex-turns.md#stop): What the app-server does with the cancel request.
- [RPC replay and idempotency](https://www.hbcode.ai/docs/relay-protocol.md#idempotency): The separate layer that protects remote mutation requests.

## Messages between sessions

An agent can send a message to another session of the same desktop with the tool send_session_message. Both agents have the tool, and the recipient can use either agent. The message is an ordinary outbox entry of the recipient, so everything in this chapter applies to it: custody, the queue, the ledger, and the delivery proof. A message that needs no approval goes into the running turn of the recipient as a steer. Without a running turn, or when the message waits for your approval, it goes to the queue.

**A session message joins the recipient’s running turn, or starts a turn when the recipient is idle**

Two sessions on one time axis, not to scale. The agent in session A calls send_session_message while its run works. The desktop checks the hold rules and saves the message as hop 1 in the outbox of session B. The tool result returns at once, and A keeps working. Session B has a running turn, so the entry is a steer for that run. The desktop checks the hold rules again, and the agent in B gets the message after its current step. The agent in B answers with the same tool. The answer is hop 2 of the same exchange. Session A has no run at that moment, so the answer goes to the queue of A and starts a new run there.

- **Tool call** (send_session_message · recipientSessionId · message): The model chooses only the recipient and the text. The desktop takes the sender session, the run, and the call ID from the calling agent. One tool call sends one message.
- **Recipient entry** (session-message-<uuid> · steer-running or queue): One entry in the recipient’s outbox. With a running turn and no hold, it has the intent automatic, the sendMode steer-running, and the recipient’s run ID. Otherwise it has the intent enqueue and the sendMode queue, at the back of the queue. userText holds the body, and promptText holds the envelope that the agent receives. The route record in chat_session_messages is written in the same transaction.
- **Hold rules** (higher access): Checked when the tool call saves the entry and again before each drain. A held entry has autoDrain off and a holdReason. It goes only when you send it from the queue.
- **Turn in the recipient** (accessMode of the recipient): The message has no access level of its own. For a message that starts a turn, the desktop reads the recipient’s level just before the turn starts. A message that joins a running turn runs with the access of that turn.
- **Exchange** (chain_id · hop): A run that a session message started or joined continues that exchange with hop + 1. Any other run starts a new exchange at hop 1. The hop is a record only: no hop count holds a message.

The prompt text is an envelope. Its first line starts with the plain sentence “Message from another HB Code session.” and then tells the agent how to answer: “Answer this message in one of three ways: do it, ask one question, or object with the reason.” The next line opens the tag session_message with the attributes from_session_id, from_title, and from_agent. The body and the closing tag follow. The plain first sentence exists because Claude’s saved-session index skips a first message that starts with a tag. The agent saves the envelope with the message, and the timeline reads the sender back from that saved text. A row with an operation ID of your own is never read as a session message, so an envelope that you type names no sender.

| Limit | Result when it is passed |
| --- | --- |
| Sends of one tool call: 1 | A repeat of the same call returns already-sent. A repeat with another recipient fails. |
| Recipient: any other session | A message to the sender’s own session fails. |

A saved message waits for you when the recipient has a higher access level than the sender. The project directory does not matter: a message to a session in another project goes like any other message. When the recipient runs a turn, the desktop compares the access of that turn. The four access levels form one ordered scale for both agents, from read-only to unrestricted. The tool result then says waiting-for-approval with the reason, and the sending agent tells its user. Otherwise the result says queued, also for a message that joins a running turn. The desktop checks the rules again before each drain, because the recipient’s settings can change while the message waits. A running turn whose access the desktop cannot confirm, or a recipient session that the desktop cannot read at that moment, also holds the message. No limit applies to the length of an exchange.

A client cannot create a session message. A submit with an operation ID that starts with session-message- fails unless that entry is already in the queue. Sending the queued entry with any intent other than enqueue approves it, and then no hold rule applies.

The table chat_session_messages keeps one row per message: operation_id as the primary key, the recipient session, the sender session, sender_call_id, sender_run_id, sender_thread_id, chain_id, hop, created_at, and delivery. The pair (sender_session_id, sender_call_id) is unique, which makes a repeated tool call safe. The row is written in the same transaction as the outbox entry. chain_id and hop record the exchange, and sender_thread_id names the agent thread that sent the message. delivery is sent, needs-approval, or declined, and the row of the sender shows it.

The desktop gives Claude one rule in the text that Claude appends to its system prompt. The rule tells Claude to use send_session_message for HB Code sessions, because Claude Code’s own SendMessage and ListAgents tools cannot reach them.

- [The session message tool](https://www.hbcode.ai/docs/tools/agent-tools.md#session-messages): The tool contract that the agent sees.
- [Access levels](https://www.hbcode.ai/docs/agents.md#access-levels): The ordered scale that the hold rule compares.
- [Message rows and their links](https://www.hbcode.ai/docs/timeline.md#message-rows): The message fact, the row title, the delivery, and the link to the other end.

## The run registry

The run registry is shared. It holds at most one active run per app session in memory, under a run ID of the form run-<uuid>-<milliseconds>. A second start for the same session fails with “Chat send already in progress for this session. Please wait or stop current run.” The registry records the operation that started the run and the last session message that joined it, which is how a session message finds its exchange.

| Rule | Mechanism |
| --- | --- |
| One barrier per base session | Every drain attempt, interrupt, and stop and send now takes the start and cancel barrier, a mutex. A start and a stop therefore never interleave. |
| No start during a stop | A pending cancellation blocks a new run with “Run cancellation is in progress. Please wait.” The outbox waits. |
| A steered run continues after its turn ends | Codex only. When a turn ends while a successor turn keeps the same run, the finisher leaves the run in the registry. |
| One finalizer | The flag finalizing is claimed once per run. Its owner reduces the live overlay, and no later live writer adds rows. |
| Restore never replaces | A turn that starts after its predecessor removed the run puts the same run ID back. If a different run is active, the restore is refused. |
| Stale reclaim | A run whose newest activity is older than 45 seconds is removed, unless its agent vetoes. A registered Codex turn vetoes. A live Claude agent host process vetoes. |

The reclaim check runs at each new start and each time a client reads the active runs, for example with run.list. The 45 seconds are longer than the agent request timeout, so a slow start is not reclaimed.

## What Stop does

Stop starts one cancellation worker per session and run. Every caller that asks for the same stop shares its result. A caller waits 5 seconds and then gets the answer that the cancellation is still settling. The worker goes on without the caller, and it keeps new runs blocked until it has a result. A success is kept for 60 seconds for repeated requests. A failure is dropped at once, so the next request checks the run again. If the attempt itself crashes, the worker tries again after 250 ms. After the result every outbox in the session’s scope drains.

Dispatch asks the Claude adapter first. If the session has a Claude run with that ID, the adapter sends an interrupt to the agent host, waits for the run to end, and stops the host. Otherwise the desktop marks the run as aborted and asks the Codex app-server to cancel the current turn.

Stop removes no message. run.cancel leaves queued messages as they are. An accepted steer that the agent did not take before the run ended returns to the queue with automatic drain off and the text “The active run stopped before this message reached the agent. Review and send it again.” In a Claude session the interrupt drops every message that still waits for the turn, so each of them returns this way. Commands that already ran and files that were already edited also stay, 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.

- [Stop in a Codex turn](https://www.hbcode.ai/docs/codex-turns.md#stop): What the app-server does with the cancel request.
- [Stop in a Claude run](https://www.hbcode.ai/docs/claude-agent-sdk.md#run-loop): How the agent host ends a run.
- [Recovery of a Codex turn](https://www.hbcode.ai/docs/codex-turns.md#recovery): What the desktop does when the Codex child process ends during a turn.

## Session titles

The first message names the session. The desktop writes a provisional title from the first 4 words of the first non-empty line, or “Fresh chat” when that line gives nothing. It replaces only a placeholder title. Then the session’s own agent writes the final title of 2 to 4 words and at most 36 characters. Codex uses the model gpt-5.6-luna with medium reasoning effort and tries up to 3 times. Claude uses its small model, haiku. One generation runs per session at a time. A failure keeps the provisional title and is logged under session_title_generation. A session that a session message starts is named from the body of that message and not from the envelope.
