Start from a normal product scene. One client starts a task. Another client joins later and should see the current turn, streamed assistant output, a pending command approval, the latest diff, and token usage. If the page reloads, history should still reconstruct the important parts of the turn.
The source boundary is cleaner than the UI surface. There is a layer of runtime facts, and there are client projections built from those facts. The core protocol accepts typed submissions and emits typed events; app-server v2 turns those events into the shapes clients actually consume.
Keep three objects separate: Submission is the input work order; EventMsg is a runtime fact emitted by the runtime; ServerNotification is an app-server notification for clients.
Evidence boundary. OpenAI's App Server article provides the product-level boundary: App Server sits between clients and the Codex harness, accepts client requests, and turns the harness event stream into client notifications. The public openai/codex source directly verifies protocol types, event types, app-server mapping code, and rollout reconstruction. Source links are pinned to one public snapshot.
This part follows five questions:
- What boundaries does a client send when it starts a turn?
- Why does core reduce interaction to one submission queue and one event queue?
- Which
EventMsgvariants describe lifecycle, content, tools, approvals, usage, and diff? - Why does app-server v2 translate core events instead of forwarding them verbatim?
- How can rollout be reduced back into turns after resume?
1. Start From What Clients Need To See
Protocols are hard to read when they begin as enum lists. The easier route is to ask what client-visible state must remain coherent while a Codex turn is running.
| Client Sees | Source Question | Boundary |
|---|---|---|
| A turn starts, completes, or is interrupted. | Who assigns the id and announces lifecycle changes? | Submission.id plus TurnStarted / TurnComplete. |
| Assistant text streams into the UI. | Is this a full message or an incremental event? | AgentMessage and AgentMessageContentDelta. |
| Commands, patches, MCP, and dynamic tools appear as progress items. | Does the UI infer tool progress, or does runtime emit structured facts? | ItemStarted / ItemCompleted and tool events. |
| A command or file change asks for approval. | Is this just a notification, or does it require a client response? | ExecApprovalRequest / ApplyPatchApprovalRequest. |
| History is visible after reload or resume. | What rebuilds the page state? | RolloutItem and ThreadHistoryBuilder. |
That is the shape of the problem: accept intent, preserve runtime facts, and let different clients consume those facts through their own view.
2. Input Side: A Typed Work Order
app-server v2 requests are not loose JSON blobs. The protocol macro generates
ClientRequest,
where each request has fixed params and response types.
The
turn/start
request uses TurnStartParams.
TurnStartParams
includes the obvious thread_id and input, but it also carries
runtime boundaries: client metadata, additional context, environment selection, cwd,
workspace roots, approval policy, sandbox policy, permissions, model, and service tier.
Starting a turn is therefore more like submitting a typed work order than sending chat text.
In turn_start_inner, app-server maps v2 input into core input, resolves cwd
and environment selections, builds thread-settings overrides, creates
Op::UserInput,
and submits it through
submit_user_input_with_client_user_message_id.
The important line: turn/start returns the core submission id
as turn_id. That gives later events a stable id to point back
to the turn.
The same ordering idea appears in Op::ThreadSettings. It does not start a
turn, but it uses the same submission queue so app-server can preserve caller order
between settings mutations and turn starts.
3. Core Boundary: A Queue Pair
Once app-server has an Op, the core boundary becomes very small. The
Codex
struct is documented as a queue pair: send submissions, receive events. Its fields
are the two channels: tx_sub: Sender<Submission> and
rx_event: Receiver<Event>.
submit
generates a UUID, wraps the Op in
Submission,
and sends it to tx_sub. next_event reads the next
Event
from rx_event. The event id is documented as correlated with the
submission id.
| Core Type | Role | Why It Matters |
|---|---|---|
Op |
What the runtime is being asked to do. | Includes user input, interrupt, approvals, compact, rollback, review, and more. |
Submission |
A work order with an id. | Gives later events a correlation id and carries trace/client message metadata. |
Event |
A runtime fact with an id. | Lets clients attach a fact back to the turn submission. |
EventMsg |
The concrete kind of fact. | Separates lifecycle, content, tools, approvals, diff, token usage, compaction, and rollback. |
4. Output Side: EventMsg Is the Fact List
EventMsg
is a long tagged enum with #[serde(tag = "type", rename_all = "snake_case")].
That wire shape matters: runtime facts are structured variants, not informal strings.
| Category | Representative Variants | Meaning |
|---|---|---|
| Turn lifecycle | TurnStarted, TurnComplete, TurnAborted |
Announce when a turn starts, finishes, or is interrupted. |
| Model content | UserMessage, AgentMessage, AgentReasoning, deltas |
Carry full persisted messages and streaming increments. |
| Tools and file changes | ExecCommand*, PatchApply*, ItemStarted, ItemCompleted |
Represent command, patch, and tool lifecycles structurally. |
| Approval and interaction | ExecApprovalRequest, ApplyPatchApprovalRequest, RequestUserInput |
Pause where a human or client decision is needed. |
| State updates | TokenCount, TurnDiff, ContextCompacted, ThreadRolledBack |
Update usage, diff, compaction, and rollback state. |
The key distinction is that EventMsg is lower-level than the final UI.
A TurnDiffEvent carries the unified diff. A TokenCountEvent
carries token usage and rate-limit snapshots. Layout, grouping, folding, and emphasis
belong to the projection layer.
5. app-server v2 Projects Facts Into Client Views
At app-server, the protocol becomes a multi-client surface. OpenAI's
App Server article
frames this layer as the JSON-RPC boundary between clients and the
Codex harness: it accepts client requests, turns them into core
operations, and projects the harness event stream into stable
client notifications. In source, app-server v2 defines those
notifications through
ServerNotification.
The method list includes turn/started, turn/completed,
item/started, item/completed,
turn/diff/updated, and thread/tokenUsage/updated.
The v2 view is built around Turn and ThreadItem. A turn
exposes status such as completed, interrupted, failed, and in progress. A
ThreadItem
turns user messages, assistant messages, plans, reasoning, and tool calls into
client list items.
The main translation point is
apply_bespoke_event_handling.
It receives core Event { id, msg }, matches on EventMsg,
and emits v2 notifications or server requests. Turn lifecycle events become turn
notifications; approval requests become server requests that need a client response;
token usage and diff have dedicated handlers.
Stateless one-to-one item projections live in
item_event_to_server_notification.
That helper covers assistant message deltas, plan deltas, reasoning deltas,
item started/completed, exec output deltas, and similar direct mappings. Anything that
needs state checks, pending request cleanup, or legacy-event suppression stays in the
bespoke handler.
| Core Event | v2 Output | Reading Rule |
|---|---|---|
TurnStarted |
TurnStartedNotification |
Open a client-visible turn snapshot. |
AgentMessageContentDelta |
AgentMessageDeltaNotification |
Project streaming assistant text. |
ItemStarted / ItemCompleted |
ItemStartedNotification / ItemCompletedNotification |
Expose tool and item lifecycle to clients. |
ExecApprovalRequest / ApplyPatchApprovalRequest |
server request | Ask the client for a decision, then feed the answer back as another Op. |
TokenCount |
ThreadTokenUsageUpdatedNotification |
Convert runtime usage into thread-level client state. |
TurnDiff |
TurnDiffUpdatedNotification |
Expose the latest aggregated unified diff for the turn. |
Connected end to end, a command that needs approval roughly crosses the boundary like this:
turn/start request
-> EventMsg::TurnStarted -> turn/started notification
-> EventMsg::AgentMessageContentDelta -> item delta
-> EventMsg::ExecApprovalRequest -> server request
-> Op::ExecApproval -> core continues
-> EventMsg::ItemCompleted -> item/completed notification
-> EventMsg::TurnComplete -> turn/completed notification
The projection layer also suppresses or redirects events when that keeps the v2 view
coherent. For example, legacy ContextCompacted and some patch/exec events
are not blindly forwarded because v2 clients receive canonical items elsewhere.
6. Rollout Rebuilds Turns From Facts
Realtime notifications explain what connected clients see. Reload and resume require a
durable source. Protocol
RolloutItem
can store SessionMeta, ResponseItem, Compacted,
TurnContext, and EventMsg. It stores rebuildable material,
not a screen snapshot.
InitialHistory
exposes get_event_msgs() for resumed and forked histories by extracting
RolloutItem::EventMsg. Core
record_initial_history
applies rollout reconstruction and seeds token usage from the last persisted
TokenCount.
app-server has a dedicated reducer too:
build_turns_from_rollout_items
converts persisted rollout items into Turn values. Its
ThreadHistoryBuilder
handles persisted event messages, compacted items, response items, and turn context.
TurnStarted opens a turn; TurnComplete closes it.
At shape level, the reduction looks like this:
rollout items:
EventMsg(TurnStarted)
EventMsg(ItemStarted(exec))
EventMsg(ItemCompleted(exec))
EventMsg(TurnComplete)
ThreadHistoryBuilder:
-> Turn { status: completed, items: [...] }
Resume is therefore a reduction over persisted runtime facts. It does not try to recreate one old frontend page; it rebuilds turns and items from durable evidence. Context recovery keeps the next model request usable; protocol/event recovery lets people and clients understand what already happened.
7. A Reading Checklist
| Type | Ask First | Better Reading |
|---|---|---|
TurnStartParams |
What boundaries does this turn carry? | Read input together with cwd, approvals, sandbox, model, permissions, and metadata. |
Op |
What runtime operation is being submitted? | Include approvals, interrupts, compact, rollback, and review, not only user input. |
EventMsg |
Which runtime fact just happened? | Classify it as lifecycle, content, tool, approval, diff, usage, or recovery-related. |
ServerNotification |
Which client view needs this projection? | Check whether bespoke handling transformed, suppressed, or split the core event. |
RolloutItem |
What can this record rebuild? | Treat it as replayable history material rather than rendered UI. |
The opening question now has a compact answer: clients can share one set of facts because Codex separates input, runtime events, client views, and replayable recovery records. The same reading rule applies to tools: once the model asks for a tool call, how does Codex route, execute, approve, and record it?
Sources
- Pinned openai/codex source snapshot
- OpenAI: Unlocking the Codex harness: how we built the App Server
- ClientRequest macro
- turn/start request definition
- TurnStartParams
- turn_start_inner mapping to Op::UserInput
- Submission
- Op
- Codex queue pair
- submit / submit_with_id
- next_event
- Event / EventMsg
- ServerNotification macro
- apply_bespoke_event_handling
- item_event_to_server_notification
- Approval request mapping
- TokenCount mapping
- TurnDiff mapping
- RolloutItem
- build_turns_from_rollout_items
- ThreadHistoryBuilder