中文 Books

Part 4

Open the Runtime

A runtime becomes a platform when many clients share one thread model.

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.

JSON-RPC app-server bridge that normalizes transports, serializes resources, maps runtime events, and lets clients rejoin shared threads
The app-server is a contract bridge: transports become connections, client requests become typed operations, core events become stable notifications, and rejoining clients recover from server-owned thread state.

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.

ShapeDirectionStable responsibility
Client requestclient to serverInitialize a connection, start or resume a thread, start or steer a turn, list state, or perform a side operation.
Client responseserver to clientComplete one client request id with success or a JSON-RPC-style error.
Client notificationclient to serverSend one-way client facts; the current message processor logs these rather than treating them as normal control flow.
Server notificationserver to clientBroadcast thread, turn, item, status, goal, and lifecycle changes.
Server requestserver to client, then backAsk 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:

  1. Decode a wire envelope.
  2. Convert it into a typed ClientRequest.
  3. Handle initialize before normal initialized traffic.
  4. Reject uninitialized or unsupported experimental traffic.
  5. Compute the request’s serialization scope.
  6. 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.

App-server message envelope separating request, notification, response, and error objects with id, method, params, trace, result, and error fields
The wire layer is a compact envelope. It lets transports carry requests, notifications, responses, and errors without making the envelope itself the app-server domain model.

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.

App-server request serialization queues keyed by global state, thread, path, process, file watch, and MCP OAuth resources
Resource-scoped queues let app-server protect shared owners without turning every request into a global bottleneck. The source scope enum and queue mapping are the hard boundary behind this figure.

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<()>,
    },
}
Thread listener rejoin flow that orders stored history, active turn snapshot, subscription, pending requests, token usage, and goal continuation
Rejoin is not a transcript reload. The listener orders history reconstruction, live subscription, pending-request replay, token usage, goal state, and possible goal continuation.

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.

Server-to-client request loop with generated request id, pending callback storage, client response matching, rejoin replay, and resolved notification
Server requests are protocol state. App-server assigns ids, stores callbacks, sends requests to connections, matches client responses, replays unresolved requests on rejoin, and emits resolution notifications in listener order.

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 pressureSimpler design that breaksApp-server mechanismProtected invariant
Multiple transportsGive every transport its own request semanticsTransport events normalize connection open, incoming message, outgoing queue, and closeClient path cannot change request meaning.
Uninitialized connectionLet any method run once parsing succeedsInitialize branch before initialized dispatchCapability belongs to the connection currently speaking.
Shared thread mutationSpawn every request immediatelyClientRequestSerializationScope plus queue keysOne owner sees ordered mutations.
Long-running turnHold the original request open until completionRequest response accepts work; notifications stream progressAcceptance and progress stay separate.
Reconnect during streamingRebuild UI from local transcriptListener command orders history, active turn snapshot, subscription, token usage, and pending requestsRejoin does not duplicate or lose live state.
Runtime waits on clientTreat approval or elicitation as an internal callbackServerRequest plus pending callback tableBlocked work is visible, matchable, and replayable.
Core event churnExpose every core event directlyEvent mapping projects stable notification typesClients 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

  1. Define protocol ownership first. Decide what belongs to the connection, thread, runtime, and client before adding convenience handlers.
  2. Serialize by resource. Use scoped ordering for shared owners instead of a single global lock or unsafe parallel execution.
  3. Separate acceptance from progress. A request response should say work was accepted; notifications should carry the long-running timeline.
  4. Make reverse calls first-class. Approval, elicitation, user input, and client-owned tools need ids, callbacks, replay, and resolution events.
  5. 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 classClaimSource anchor
Verified sourceThe wire envelope is JSON-RPC-like but intentionally omits the required "jsonrpc": "2.0" field.jsonrpc_lite.rs
Verified sourceClient 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 sourceJSON and typed in-process requests both delegate to handle_client_request; initialized traffic is rejected until Initialize succeeds.message_processor.rs
Verified sourceScoped request queues drain FIFO, while consecutive shared reads for the same key can run together.request_serialization.rs
Verified sourceThe thread listener orders listener commands, core events, and unload timing, and tracks current-turn state before emitting projections.thread_lifecycle.rs
Verified sourceRunning-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 sourceServer requests are generated typed protocol objects with typed client responses and request id constructors.common.rs
Verified sourceOutgoing 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 sourceEvent 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 inferenceApp-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.