Chapter 14: The App-Server Contract
Reading Contract: Use this chapter to understand app-server as the protocol boundary around shared threads. Track three owners: the client owns a connection and answers server requests; app-server owns typed request ordering, replay, and notification shape; core owns the running agent thread. By the end, you should be able to explain why a reconnecting client can recover a live turn without treating the terminal transcript as the source of truth.

Source boundary: named files, types, functions, request shapes, and event mappings are verified source where this chapter links to pinned Codex commit 569ff6a1c400bd514ff79f5f1050a684dc3afde3. Broader architecture terms such as owner, contract boundary, runtime projection, and replay invariant are surrounding contract inference from those visible anchors, not claims about OpenAI service internals.
Chapter 13 ended at the execution boundary: permission profiles, sandbox backends, and managed-network policy decide what a tool attempt may touch. This chapter moves one layer outward. Once the runtime can enforce those decisions, Codex still needs a contract that lets terminal clients, SDK clients, daemon-managed clients, and remote clients share the same thread without sharing the same implementation.
The mistake is to read app-server as a thin HTTP or JSON-RPC wrapper. The source shows something more specific: app-server is the boundary that turns many client connections into ordered operations over one shared thread lifecycle. It parses loose transport envelopes, converts them into generated protocol types, gates initialization and experimental methods, serializes work by resource, projects core events into client-facing notifications, and remembers pending server-to-client requests so a reconnecting client can pick up the conversation.
That is why this chapter is organized around ownership rather than file names. The interesting question is not “which handler receives this message?” The question is “which owner is allowed to decide the next state transition?“
1. The Boundary Is Bidirectional
The app-server contract has five visible families.
| Shape | Direction | Stable responsibility |
|---|---|---|
| Client request | client to server | Initialize a connection, start or resume a thread, start or steer a turn, list state, or perform a side operation. |
| Client response | server to client | Complete one client request id with success or a JSON-RPC-style error. |
| Client notification | client to server | Send one-way client facts; the current message processor logs these rather than treating them as normal control flow. |
| Server notification | server to client | Broadcast thread, turn, item, status, goal, and lifecycle changes. |
| Server request | server to client, then back | Ask the client or user for approval, input, MCP elicitation data, dynamic-tool execution, or another client-owned decision. |
The bidirectional shape is the product constraint. A coding agent does not just stream output. It may need approval before a command, a user answer before continuing, or a client-owned tool result before the turn can resume. Hiding those pauses as callbacks would make rejoin and replay fragile. App-server makes them protocol objects.
1.1 Transport Creates a Connection, Not Semantics
The transport crate exposes standard I/O, Unix socket, WebSocket, and off modes through AppServerTransport. from_listen_url recognizes stdio://, unix://, ws://, and off; it does not decide what a turn/start or thread/resume means. Remote control is visible separately as an exported transport helper and as a ConnectionOrigin::RemoteControl, not as an AppServerTransport variant.
The standard I/O path makes the boundary concrete. start_stdio_connection allocates a connection id, opens a bounded writer channel, emits ConnectionOpened, forwards each input line as an incoming message, emits ConnectionClosed on EOF, and writes outgoing JSON lines. After that point, the rest of app-server sees a connection and a message, not stdin.
This is the first invariant: transport adapters own framing and disconnection; message processing owns meaning. If those two concerns merge, every client path has to reimplement initialization, backpressure, response matching, and replay ordering.
2. The Request Path Turns Bytes Into Ordered Work
The normal request path has a narrow waist:
- Decode a wire envelope.
- Convert it into a typed
ClientRequest. - Handle
initializebefore normal initialized traffic. - Reject uninitialized or unsupported experimental traffic.
- Compute the request’s serialization scope.
- Execute the matching processor only when the relevant resource queue allows it.
2.1 The Envelope Is Deliberately Lightweight
The file jsonrpc_lite.rs says the implementation does not use true JSON-RPC 2.0 because it neither sends nor expects the "jsonrpc": "2.0" field. The practical shape is still familiar: request, notification, response, and error objects, distinguished with serde’s untagged decoding.
#[serde(untagged)]
pub enum JSONRPCMessage {
Request(JSONRPCRequest),
Notification(JSONRPCNotification),
Response(JSONRPCResponse),
Error(JSONRPCError),
}
pub struct JSONRPCRequest {
pub id: RequestId,
pub method: String,
pub params: Option<serde_json::Value>,
pub trace: Option<W3cTraceContext>,
}
That excerpt is small, but it explains a lot. The wire envelope is intentionally not the domain model. It carries an id, method, params, and optional trace context. The typed app-server protocol begins after conversion into ClientRequest.

2.2 Typed Requests Carry Their Own Serialization Scope
The protocol macro in common.rs generates ClientRequest variants and a serialization_scope() method. The scope vocabulary is explicit:
pub enum ClientRequestSerializationScope {
Global(&'static str),
GlobalSharedRead(&'static str),
Thread { thread_id: String },
ThreadPath { path: PathBuf },
CommandExecProcess { process_id: String },
Process { process_handle: String },
FuzzyFileSearchSession { session_id: String },
FsWatch { watch_id: String },
McpOauth { server_name: String },
}
This is a source-level contract, not an editorial metaphor. A request declares which resource it can race with. A thread/resume is not serialized for the same reason as a filesystem watch or an MCP OAuth transition, but the processor can use one queueing mechanism for all of them.

2.3 Initialization Is a Special Gate
MessageProcessor::process_request converts a JSON-RPC request into ClientRequest and delegates to the same request handler used by typed in-process clients. The in-process path at process_client_request bypasses JSON deserialization, but still calls handle_client_request, so transport choice does not change semantics.
The handler gives Initialize its own branch, then applies the initialized gate and experimental gate to everything else:
if let ClientRequest::Initialize { request_id, params } = codex_request {
let connection_initialized = self
.initialize_processor
.initialize(
connection_id,
request_id,
params,
&session,
outbound_initialized,
)
.await?;
if connection_initialized {
self.thread_processor
.connection_initialized(
connection_id,
ConnectionCapabilities {
request_attestation: session.request_attestation(),
},
)
.await;
}
return Ok(());
}
if !session.initialized() {
return Err(invalid_request("Not initialized"));
}
if let Some(reason) = codex_request.experimental_reason()
&& !session.experimental_api_enabled()
{
return Err(invalid_request(experimental_required_message(reason)));
}
The important part is connection scope. A thread may outlive the client that created it, but initialization and experimental API support belong to the connection currently speaking. If capability lived only on the thread, a reconnecting or older client could accidentally receive shapes it did not negotiate.
2.4 The Queue Sits Between Validation and Execution
After initialization checks, dispatch_initialized_client_request computes serialization_scope(). If a scope exists, it maps the scope to a queue key through RequestSerializationQueueKey::from_scope; otherwise, the request can spawn directly.
let serialization_scope = codex_request.serialization_scope();
if let Some(scope) = serialization_scope {
let (key, access) = RequestSerializationQueueKey::from_scope(connection_id, scope);
self.request_serialization_queues.enqueue(key, access, request).await;
} else {
tokio::spawn(async move {
request.run().await;
});
}
The queue implementation is small enough to read in one pass. enqueue creates one draining task per key. drain pops FIFO, with one optimization: consecutive SharedRead requests for the same key can run together.
if access == RequestSerializationAccess::SharedRead {
while queue.front().is_some_and(|request| {
request.access == RequestSerializationAccess::SharedRead
}) {
let Some(request) = queue.pop_front() else { break };
requests.push(request);
}
}
join_all(requests.into_iter().map(|request| request.request.run())).await;
This is the second invariant: ordering is attached to the resource owner, not to the transport and not to the whole server. That is why app-server can accept many client connections without letting two operations mutate the same thread, process, or auth transition out of order.
3. The Thread Path Preserves Replay Order
Once a request reaches its processor, app-server still does not become the agent runtime. Core owns the running conversation. App-server owns the projection from core events into the client-visible contract, plus the replay and subscription order that clients depend on.
3.1 The Listener Is the Serialization Point for Live Threads
The thread lifecycle processor starts or reuses a listener task through ensure_listener_task_running. That task selects among three categories of work: listener commands, conversation.next_event(), and unload timing. Before emitting typed translations, it records the current event in ThreadState:
let raw_events_enabled = {
let mut thread_state = thread_state.lock().await;
thread_state.track_current_turn_event(&event.id, &event.msg);
thread_state.experimental_raw_events
};
ThreadState is not a second transcript. It stores listener generation, pending interrupts, pending rollback state, the current turn history builder, raw-event opt-in, and the listener command sender. The client view is reconstructed from stored history plus current listener facts, not from terminal text.
3.2 Rejoin Is an Ordered Listener Command
The delicate case is thread/resume while a turn is still running. A client needs committed history, the active turn snapshot, token usage, goal state, and pending server requests. It must also be subscribed to future notifications at the right point. If those actions race, the UI can duplicate items, miss an approval, or show idle while core is still active.
The source handles this through ThreadListenerCommand. Resume response, goal updates, goal snapshots, and server-request resolution all run through the listener’s command channel.
pub(crate) enum ThreadListenerCommand {
SendThreadResumeResponse(Box<PendingThreadResumeRequest>),
EmitThreadGoalUpdated { goal: ThreadGoal },
EmitThreadGoalCleared,
EmitThreadGoalSnapshot { state_db: StateDbHandle },
ResolveServerRequest {
request_id: RequestId,
completion_tx: oneshot::Sender<()>,
},
}

handle_pending_thread_resume_request shows the order. It reads an active turn snapshot, populates turns from rollout history only when pending.include_turns is set, resolves loaded status, adds the connection to the thread, sends the resume response, conditionally emits token usage and goal state, replays pending server requests, and only then conditionally allows goal continuation when pending.emit_thread_goal_update is set:
if pending.include_turns {
populate_thread_turns_from_history(
&mut thread,
&pending.history_items,
active_turn.as_ref(),
);
}
let response = ThreadResumeResponse {
thread,
model,
model_provider: model_provider_id,
service_tier,
cwd,
instruction_sources,
approval_policy: approval_policy.into(),
approvals_reviewer: approvals_reviewer.into(),
sandbox,
permission_profile: Some(permission_profile.into()),
active_permission_profile,
reasoning_effort,
};
let token_usage_thread = pending.include_turns.then(|| response.thread.clone());
outgoing.send_response(request_id, response).await;
if let Some(token_usage_thread) = token_usage_thread {
let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items(
&pending.history_items,
token_usage_thread.turns.as_slice(),
);
send_thread_token_usage_update_to_connection(
outgoing,
connection_id,
conversation_id,
&token_usage_thread,
conversation.as_ref(),
token_usage_turn_id,
)
.await;
}
if pending.emit_thread_goal_update {
if let Some(state_db) = pending.thread_goal_state_db {
send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await;
}
}
outgoing
.replay_requests_to_connection_for_thread(connection_id, conversation_id)
.await;
if pending.emit_thread_goal_update
&& let Err(err) = conversation.continue_active_goal_if_idle().await
{
tracing::warn!("failed to continue active goal after running-thread resume: {err}");
}
This is the third invariant: replay and live subscription are one ordered operation around the listener. They are not two independent reads from two independent truth sources.
3.3 Event Mapping Is a Projection, Not a Dump
The event mapping helper makes the projection boundary explicit. item_event_to_server_notification says it only covers stateless one-to-one projections; callers own surrounding state checks and side effects. Representative cases map core deltas and command events to stable notifications such as AgentMessageDelta, ItemStarted, FileChangePatchUpdated, CommandExecutionOutputDelta, and ItemCompleted in event_mapping.rs.
That split matters for compatibility. A client should not have to understand every internal core event to render a thread. The public contract can add item kinds and notification fields deliberately, while app-server absorbs the gap between core’s vocabulary and the client-visible view.
4. The Reverse Path Makes Client Decisions Replayable
Server-to-client requests are the part of app-server that most clearly separates an agent runtime from a conventional service. Core may be blocked on approval, elicitation, input, or client-owned tool execution. App-server needs those waits to be visible, matched by id, and replayable on rejoin.
4.1 Server Requests Are Generated Protocol Types
The same protocol file generates ServerRequest, typed responses, payload constructors, and export helpers. Concrete request families include command approval, file-change approval, tool user input, MCP server elicitation, permissions approval, and dynamic tool execution in server_request_definitions!.
pub enum ServerRequest {
$variant {
#[serde(rename = "id")]
request_id: RequestId,
params: $params,
},
}
pub enum ServerRequestPayload {
$( $variant($params), )*
}
impl ServerRequestPayload {
pub fn request_with_id(self, request_id: RequestId) -> ServerRequest {
match self {
$(Self::$variant(params) => ServerRequest::$variant { request_id, params },)*
}
}
}
The generated shape is what lets the runtime ask the client a question without becoming tied to one UI. A TUI, SDK client, or remote client can all receive the same typed request family and return the matching typed response.

4.2 Outgoing State Tracks Pending Decisions
OutgoingMessageSender owns next_server_request_id, the outbound envelope sender, request_id_to_callback, request contexts, and analytics. send_request_to_connections assigns an id, builds a typed ServerRequest, stores the callback and optional thread id, then broadcasts or sends to chosen connections.
let id = self.next_request_id();
let request = request.request_with_id(id.clone());
let (tx_approve, rx_approve) = oneshot::channel();
request_id_to_callback.insert(
id,
PendingCallbackEntry {
callback: tx_approve,
thread_id,
request: request.clone(),
},
);
When a client later sends a JSON-RPC response, process_response calls notify_client_response, which looks up the callback and completes the waiting runtime path. If the client sends an error object, notify_client_error completes the same callback with an error.
4.3 Rejoin Replays Unresolved Server Requests
The pending request table also explains why rejoin can recover a blocked turn. pending_requests_for_thread filters unresolved server requests by thread id and sorts them by id. replay_requests_to_connection_for_thread sends those unresolved requests to the rejoining connection after the resume response has been sent.
Resolution is ordered through the listener as well. resolve_server_request_on_thread_listener enqueues ResolveServerRequest, and resolve_pending_server_request emits ServerRequestResolved to subscribed connections.
The invariant is precise: a server request is not just an outbound message. It is a pending runtime decision with an id, optional thread ownership, a callback, rejoin replay behavior, and an ordered resolution notification.
5. Failure Conditions Define the Contract
The app-server source is full of small gates because each one protects a different failure boundary.
| Failure pressure | Simpler design that breaks | App-server mechanism | Protected invariant |
|---|---|---|---|
| Multiple transports | Give every transport its own request semantics | Transport events normalize connection open, incoming message, outgoing queue, and close | Client path cannot change request meaning. |
| Uninitialized connection | Let any method run once parsing succeeds | Initialize branch before initialized dispatch | Capability belongs to the connection currently speaking. |
| Shared thread mutation | Spawn every request immediately | ClientRequestSerializationScope plus queue keys | One owner sees ordered mutations. |
| Long-running turn | Hold the original request open until completion | Request response accepts work; notifications stream progress | Acceptance and progress stay separate. |
| Reconnect during streaming | Rebuild UI from local transcript | Listener command orders history, active turn snapshot, subscription, token usage, and pending requests | Rejoin does not duplicate or lose live state. |
| Runtime waits on client | Treat approval or elicitation as an internal callback | ServerRequest plus pending callback table | Blocked work is visible, matchable, and replayable. |
| Core event churn | Expose every core event directly | Event mapping projects stable notification types | Clients render the contract, not internal churn. |
These are not glamorous mechanisms. They are the difference between “a JSON-RPC endpoint” and “a protocol boundary that can support shared, durable, bidirectional agent threads.”
Apply This
- Define protocol ownership first. Decide what belongs to the connection, thread, runtime, and client before adding convenience handlers.
- Serialize by resource. Use scoped ordering for shared owners instead of a single global lock or unsafe parallel execution.
- Separate acceptance from progress. A request response should say work was accepted; notifications should carry the long-running timeline.
- Make reverse calls first-class. Approval, elicitation, user input, and client-owned tools need ids, callbacks, replay, and resolution events.
- Treat replay as a runtime contract. Reconnect should read from durable history plus live listener state, not from UI text or a second transcript model.
Closing
The app-server contract turns Codex runtime state into a shared platform surface. Clients can create threads, observe turns, replay history, and answer runtime requests without importing the core runtime. The boundary stays useful because it is narrow where it should be narrow - wire envelopes and generated types - and strict where it must be strict: initialization, resource ordering, listener rejoin, and pending server requests.
Chapter 15 follows the clients that use this contract: generated SDK models, daemon startup, local transport choices, and remote-control streams.
Source Map
| Evidence class | Claim | Source anchor |
|---|---|---|
| Verified source | The wire envelope is JSON-RPC-like but intentionally omits the required "jsonrpc": "2.0" field. | jsonrpc_lite.rs |
| Verified source | Client request types are generated with serialization_scope(), and scopes include global, thread, path, process, fs watch, fuzzy search, and MCP OAuth owners. | common.rs |
| Verified source | JSON and typed in-process requests both delegate to handle_client_request; initialized traffic is rejected until Initialize succeeds. | message_processor.rs |
| Verified source | Scoped request queues drain FIFO, while consecutive shared reads for the same key can run together. | request_serialization.rs |
| Verified source | The thread listener orders listener commands, core events, and unload timing, and tracks current-turn state before emitting projections. | thread_lifecycle.rs |
| Verified source | Running-thread resume reads active turn state, optionally reconstructs history and token usage, sends the response, conditionally emits goal state, replays pending server requests, then conditionally allows goal continuation. | thread_lifecycle.rs |
| Verified source | Server requests are generated typed protocol objects with typed client responses and request id constructors. | common.rs |
| Verified source | Outgoing server requests store callbacks and optional thread ids; unresolved thread requests are replayed to rejoining connections. | outgoing_message.rs, send_request_to_connections, pending_requests_for_thread |
| Verified source | Event mapping covers stateless one-to-one event projections; callers remain responsible for surrounding state checks and side effects. | event_mapping.rs, event cases |
| Surrounding contract inference | App-server should be read as a thread ownership boundary rather than as a thin API wrapper. | Inferred from the verified transport, request serialization, listener resume, event projection, and server-request anchors above. |