中文 Books

The Protocol Boundary

Reading Contract: Treat this chapter as the vocabulary gate. Follow how a client request becomes a queued submission, how the runtime returns correlated events, how app-server projects those events into client-facing items, and how generated schemas make that boundary auditable.

Protocol boundary taxonomy for submissions, operations, events, items, app-server messages, generated schemas, and compatibility
The protocol vocabulary is the shared language: submissions enter, operations carry intent, events report facts, items project state, and schemas keep clients aligned.

Source boundary: direct claims in this chapter are pinned to OpenAI Codex commit 569ff6a1c400bd514ff79f5f1050a684dc3afde3. Submission, Op, Event, EventMsg, app-server JSON-RPC envelope types, event-to-item mapping, app-server request macros, and schema export behavior are verified source where linked. The terms “protocol kernel”, “projection”, “client contract”, and “governance gate” are surrounding contract inference from those source shapes; they are not claims about private OpenAI service internals.

Chapter 3 built the runtime envelope: configuration, auth, managed requirements, feature state, and permission profiles are resolved before later code starts work. The next boundary is language. Once a surface has a valid envelope, it still needs a disciplined way to ask for work and observe what happened.

That discipline is the protocol boundary. Codex does not let a client reach into the session and call arbitrary private methods. It gives clients durable nouns: submission, operation, event, item, request, response, notification, schema. The point is not serialization aesthetics. The point is ownership.

If a concept crosses a protocol boundary, other code can depend on it. A terminal UI can render it. An app-server client can replay it. A schema export can lock it. An older client can keep sending it. That makes protocol design a runtime behavior, not a documentation exercise.

1. Core Runtime Queue

1.1 Submission and Event Correlation

The smallest core loop is visible in codex-rs/protocol/src/protocol.rs. A Submission is a queue entry. It carries a correlation id, an Op, and an optional W3C trace context.

/// Submission Queue Entry - requests from user
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Submission {
    /// Unique id for this Submission to correlate with Events
    pub id: String,
    /// Payload
    pub op: Op,
    /// Optional W3C trace carrier propagated across async submission handoffs.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub trace: Option<W3cTraceContext>,
}

The matching outbound shape is Event. It carries the submission id and an EventMsg payload. That symmetry is the first protocol invariant:

/// Event Queue Entry - events from agent
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Event {
    /// Submission `id` that this event is correlated with.
    pub id: String,
    /// Payload
    pub msg: EventMsg,
}

A client can submit intent and later correlate emitted facts back to that intent. The runtime can serialize, reject, transform, or interrupt operations without exposing its private state. Observability can follow the trace across async handoffs without making trace data part of every domain payload.

Core protocol kernel showing a submission with id, op, and trace entering a runtime queue and correlated events leaving with id and EventMsg
The core protocol is queue-shaped: submissions carry intent into the runtime; events carry correlated facts back out.

This split matters because agent work is not a function call. A turn may stream tokens, request approval, run commands, emit deltas, apply patches, handle interrupts, and finish with usage information. The caller cannot wait for one return value and call that the whole interaction.

1.2 Operations Are Typed Entrances, Not Arbitrary Commands

The Op enum is the runtime’s typed entrance list. It begins with lifecycle and realtime operations, then user-input operations, approval answers, permission responses, context operations, background terminal controls, and other thread-affecting requests. The critical reading is that each operation is a source-level variant, not a string command. The first anchor below covers the turn-entry variants; the approval, permission, and dynamic-tool response variants continue later in the same enum around ExecApproval.

An abridged source excerpt from Op shows the pattern:

pub enum Op {
    /// Abort current task without terminating background terminal processes.
    /// This server sends [`EventMsg::TurnAborted`] in response.
    Interrupt,

    /// Legacy user input.
    ///
    /// Prefer [`Op::UserTurn`] so the caller provides full turn context
    /// (cwd/approval/sandbox/model/etc.) for each turn.
    UserInput {
        /// User input items, see `InputItem`
        items: Vec<UserInput>,
        /// Optional turn-scoped environments.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        environments: Option<Vec<TurnEnvironmentSelection>>,
        /// Optional JSON Schema used to constrain the final assistant message for this turn.
        #[serde(skip_serializing_if = "Option::is_none")]
        final_output_json_schema: Option<Value>,
        /// Optional turn-scoped Responses API `client_metadata`.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        responsesapi_client_metadata: Option<HashMap<String, String>>,
    },

    /// Similar to [`Op::UserInput`], but first applies persistent turn-context
    /// overrides in the same queued operation. This preserves submission order
    /// and prevents the input from starting if the overrides are rejected.
    UserInputWithTurnContext {
        items: Vec<UserInput>,
        environments: Option<Vec<TurnEnvironmentSelection>>,
        final_output_json_schema: Option<Value>,
        responsesapi_client_metadata: Option<HashMap<String, String>>,
        cwd: Option<PathBuf>,
        approval_policy: Option<AskForApproval>,
        // ...
    },
}

The comment on UserInputWithTurnContext is the design clue. Turn-context overrides are applied in the same queued operation as the input. That preserves ordering and prevents the turn from starting if the overrides are rejected.

This is why “configuration” and “protocol” cannot be separated cleanly. Chapter 3 resolved the envelope. Chapter 4 shows how later changes to turn context must still pass through typed operations rather than mutating state out of band.

Entrance typeSource-level consequence
InterruptThe runtime can emit a typed aborted event instead of relying on a signal side channel.
UserInputLegacy input remains accepted, which is a compatibility obligation.
UserInputWithTurnContextInput and context changes are ordered together.
Approval and permission responsesA client decision re-enters as a typed operation, not as terminal text.
Realtime and background-terminal operationsLong-running subsystems still cross the same queue boundary.

The runtime is powerful because the entrance is narrow. A client can ask for many kinds of work, but every request must become an Op.

1.3 Events Are Facts With Compatibility Debt

EventMsg is much larger than a chat message enum. It includes errors, warnings, realtime lifecycle, model reroutes, compaction, rollback, turn start and completion, token usage, agent messages, reasoning, MCP, web search, image generation, shell execution, approvals, permission requests, patch application, plan updates, shutdown, review mode, raw response items, item lifecycle, hooks, deltas, and collaboration.

That breadth is not accidental. Different event families have different persistence, display, replay, and compatibility rules. A command output delta is not an assistant paragraph. A permission request is not final history. A patch update is not a model-visible instruction. A thread rollback is not a normal message.

The enum also carries explicit compatibility details. A direct source excerpt shows turn events preserving v1 wire names while accepting v2 aliases:

/// Agent has started a turn.
/// v1 wire format uses `task_started`; accept `turn_started` for v2 interop.
#[serde(rename = "task_started", alias = "turn_started")]
TurnStarted(TurnStartedEvent),

/// Agent has completed all actions.
/// v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.
#[serde(rename = "task_complete", alias = "turn_complete")]
TurnComplete(TurnCompleteEvent),

An abridged source excerpt later in the same enum shows side-effect and client-decision events as first-class variants:

ExecCommandBegin(ExecCommandBeginEvent),
ExecCommandOutputDelta(ExecCommandOutputDeltaEvent),
TerminalInteraction(TerminalInteractionEvent),
ExecCommandEnd(ExecCommandEndEvent),

ExecApprovalRequest(ExecApprovalRequestEvent),
RequestPermissions(RequestPermissionsEvent),
RequestUserInput(RequestUserInputEvent),
DynamicToolCallRequest(DynamicToolCallRequest),
ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent),

Another direct source excerpt shows the item/delta vocabulary:

RawResponseItem(RawResponseItemEvent),

ItemStarted(ItemStartedEvent),
ItemCompleted(ItemCompletedEvent),
HookStarted(HookStartedEvent),
HookCompleted(HookCompletedEvent),

AgentMessageContentDelta(AgentMessageContentDeltaEvent),
PlanDelta(PlanDeltaEvent),
ReasoningContentDelta(ReasoningContentDeltaEvent),
ReasoningRawContentDelta(ReasoningRawContentDeltaEvent),

Those source shapes support the central claim: Codex does not expose “the assistant said something” as the only event. It exposes a typed event language for the different facts a long-running agent turn can create.

2. App-Server Projection Contract

2.1 Events Become Client Items

Core events are not the final client contract. The app-server boundary has to turn selected runtime events into client-visible notifications and items. The source makes that mapping explicit in event_mapping.rs. This abridged source excerpt keeps the helper’s documented boundary and omits the body:

/// Build the v2 app-server notification that directly corresponds to a single core event.
///
/// This only covers the stateless event-to-notification projections that have a one-to-one
/// mapping. Callers remain responsible for any surrounding state checks or side effects before
/// invoking this helper.
pub fn item_event_to_server_notification(
    msg: EventMsg,
    thread_id: &str,
    turn_id: &str,
) -> ServerNotification {
    // ...
}

The comment matters. This helper is not the whole app-server state machine. It covers the stateless one-to-one projections. Surrounding code still owns checks and side effects. But this helper pins the projection rule: a core EventMsg can become a ServerNotification without asking clients to parse terminal text.

Event-to-item projection showing core EventMsg families flowing through a mapping helper into ServerNotification item started, item completed, delta, and ThreadItem view
The app-server projection turns selected runtime events into stable client-facing item and delta notifications.

An abridged source excerpt shows three important mappings. Message deltas stay deltas. Item lifecycle events become item notifications. Exec events are converted into command-execution items:

match msg {
    EventMsg::AgentMessageContentDelta(event) => {
        let codex_protocol::protocol::AgentMessageContentDeltaEvent { item_id, delta, .. } =
            event;
        ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification {
            thread_id,
            turn_id,
            item_id,
            delta,
        })
    }
    EventMsg::ItemStarted(item_started_event) => {
        ServerNotification::ItemStarted(ItemStartedNotification {
            thread_id,
            turn_id,
            item: item_started_event.item.into(),
            started_at_ms: item_started_event.started_at_ms,
        })
    }
    EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => {
        let item_id = exec_command_output_delta_event.call_id;
        let delta = String::from_utf8_lossy(&exec_command_output_delta_event.chunk).to_string();
        ServerNotification::CommandExecutionOutputDelta(
            CommandExecutionOutputDeltaNotification {
                thread_id,
                turn_id,
                item_id,
                delta,
            },
        )
    }
    EventMsg::ExecCommandEnd(exec_command_end_event) => {
        ServerNotification::ItemCompleted(ItemCompletedNotification {
            thread_id,
            turn_id,
            item: build_command_execution_end_item(&exec_command_end_event),
            completed_at_ms: exec_command_end_event.completed_at_ms,
        })
    }
    _ => unreachable!("unsupported item event"),
}

This projection is where UI independence comes from. A TUI cell, an app-server client, and an SDK stream can all consume the same item language without reconstructing a command from raw stdout or a patch from a paragraph.

2.2 JSON-RPC-Style Envelope

The app-server is a second boundary around threads, turns, files, processes, MCP, plugins, accounts, permissions, and remote-control flows. Its low-level wire envelope lives in jsonrpc_lite.rs. The source note is precise: Codex does not send or expect the "jsonrpc": "2.0" field, even though the file keeps the familiar request/notification/response split. An abridged source excerpt keeps that note next to the message enum:

//! We do not do true JSON-RPC 2.0, as we neither send nor expect the
//! "jsonrpc": "2.0" field.

/// Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
#[serde(untagged)]
pub enum JSONRPCMessage {
    Request(JSONRPCRequest),
    Notification(JSONRPCNotification),
    Response(JSONRPCResponse),
    Error(JSONRPCError),
}

An abridged source excerpt of the request, notification, response, and error structs omits derive and serde attributes while preserving the field shapes:

/// A request that expects a response.
pub struct JSONRPCRequest {
    pub id: RequestId,
    pub method: String,
    pub params: Option<serde_json::Value>,
    /// Optional W3C Trace Context for distributed tracing.
    pub trace: Option<W3cTraceContext>,
}

/// A notification which does not expect a response.
pub struct JSONRPCNotification {
    pub method: String,
    pub params: Option<serde_json::Value>,
}

/// A successful (non-error) response to a request.
pub struct JSONRPCResponse {
    pub id: RequestId,
    pub result: Result,
}

/// A response to a request that indicates an error occurred.
pub struct JSONRPCError {
    pub error: JSONRPCErrorError,
    pub id: RequestId,
}
App-server message envelope separating request, notification, response, and error objects with id, method, params, trace, result, and error fields
App-server uses a JSON-RPC-style envelope while keeping Codex-specific request, notification, response, and error obligations explicit.

This is why the app-server is not “just HTTP around the runtime.” The core runtime queue knows about submissions and events. The app-server knows about connection-facing requests, responses, notifications, server-to-client requests, resource serialization, experimental gates, and schema export.

2.3 Request Definitions Carry Serialization Scope

The app-server request surface is generated from macro definitions in common.rs. Before the macro, the source defines a serialization scope enum:

#[derive(Debug, Clone, PartialEq, Eq)]
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 },
}

That is a protocol design choice. A request is not only “method plus params.” It can say which resource must be serialized: a thread, a thread path, a command process, an MCP OAuth server, a filesystem watch, global config, or no serialized resource.

An abridged source excerpt from the macro body shows how generated ClientRequest methods expose that scope; the $variant markers are Rust macro variables, not pseudocode:

impl ClientRequest {
    pub fn id(&self) -> &RequestId {
        match self {
            $(Self::$variant { request_id, .. } => request_id,)*
        }
    }

    pub fn method(&self) -> String {
        serde_json::to_value(self)
            .ok()
            .and_then(|value| {
                value
                    .get("method")
                    .and_then(serde_json::Value::as_str)
                    .map(str::to_owned)
            })
            .unwrap_or_else(|| "<unknown>".to_string())
    }

    pub fn serialization_scope(&self) -> Option<ClientRequestSerializationScope> {
        match self {
            $(
                Self::$variant { params, .. } => {
                    let _ = params;
                    serialization_scope_expr!(
                        params, $serialization $( ( $($serialization_args)* ) )?
                    )
                }
            )*
        }
    }
}

The real macro expands across many methods. A few examples in the same file show the variety:

Method familySource serialization clueWhy it matters
thread/resume, thread/forkthread_or_path(...)A request may address a loaded thread or a path on disk.
thread/archive, thread/readthread_id(...)Thread state operations are serialized per thread.
skills/listglobal_shared_read("config")Some reads can share access to global config.
hooks/list, marketplace operationsglobal("config")Config mutation is globally serialized.
command/exec/*optional_command_process_id(...), command_process_id(...)Process control must not race itself.

The protocol boundary therefore carries concurrency policy. If serialization scope lived only in a private handler, client-facing request types would be less auditable.

3. Schema Governance

3.1 Generated Schemas Turn Drift Into a Build Problem

The last piece is governance. App-server protocol types derive JsonSchema and TS, but the repository still needs export code that writes stable artifacts and filters experimental surface area.

The top-level generator in export.rs exports TypeScript and JSON schemas:

type JsonSchemaEmitter = fn(&Path) -> Result<GeneratedSchema>;
pub fn generate_types(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
    generate_ts(out_dir, prettier)?;
    generate_json(out_dir)?;
    Ok(())
}

An abridged source excerpt from the TypeScript path shows requests, responses, notifications, and server-side counterparts being exported before experimental types are filtered unless experimental output is explicitly requested:

pub fn generate_ts_with_options(
    out_dir: &Path,
    prettier: Option<&Path>,
    options: GenerateTsOptions,
) -> Result<()> {
    let v2_out_dir = out_dir.join("v2");
    ensure_dir(out_dir)?;
    ensure_dir(&v2_out_dir)?;

    ClientRequest::export_all_to(out_dir)?;
    export_client_responses(out_dir)?;
    ClientNotification::export_all_to(out_dir)?;

    ServerRequest::export_all_to(out_dir)?;
    export_server_responses(out_dir)?;
    ServerNotification::export_all_to(out_dir)?;

    if !options.experimental_api {
        filter_experimental_ts(out_dir)?;
    }
    // ...
}

An abridged source excerpt from the JSON path shows the envelope schemas, stable/experimental filtering, and root plus v2 bundle writes. The omitted middle collects parameter, response, and notification schemas before bundling:

let envelope_emitters: Vec<JsonSchemaEmitter> = vec![
    |d| write_json_schema_with_return::<crate::RequestId>(d, "RequestId"),
    |d| write_json_schema_with_return::<crate::JSONRPCMessage>(d, "JSONRPCMessage"),
    |d| write_json_schema_with_return::<crate::JSONRPCRequest>(d, "JSONRPCRequest"),
    |d| write_json_schema_with_return::<crate::JSONRPCNotification>(d, "JSONRPCNotification"),
    |d| write_json_schema_with_return::<crate::JSONRPCResponse>(d, "JSONRPCResponse"),
    |d| write_json_schema_with_return::<crate::JSONRPCError>(d, "JSONRPCError"),
    |d| write_json_schema_with_return::<crate::JSONRPCErrorError>(d, "JSONRPCErrorError"),
    |d| write_json_schema_with_return::<crate::ClientRequest>(d, "ClientRequest"),
    |d| write_json_schema_with_return::<crate::ServerRequest>(d, "ServerRequest"),
    |d| write_json_schema_with_return::<crate::ClientNotification>(d, "ClientNotification"),
    |d| write_json_schema_with_return::<crate::ServerNotification>(d, "ServerNotification"),
];

// ...

let mut bundle = build_schema_bundle(schemas)?;
if !experimental_api {
    filter_experimental_schema(&mut bundle)?;
}
write_pretty_json(
    out_dir.join("codex_app_server_protocol.schemas.json"),
    &bundle,
)?;
let flat_v2_bundle = build_flat_v2_schema(&bundle)?;
write_pretty_json(
    out_dir.join("codex_app_server_protocol.v2.schemas.json"),
    &flat_v2_bundle,
)?;
Generated schema governance path from Rust protocol types through schema export, TypeScript, JSON Schema, experimental filter, clients, and drift checks
Generated protocol artifacts make drift visible: Rust types must export through TypeScript, JSON Schema, and experimental filters before clients depend on them.

That is the difference between a local API and a boundary. A private Rust helper can be refactored quietly. A protocol field crosses into generated artifacts and client code. Once it does, compatibility becomes behavior.

Common Misreadings

The first misreading is to treat EventMsg as a fancy chat transcript. It is a runtime event vocabulary. Some events are model output, but many are tool lifecycles, approval requests, patch updates, permission requests, reasoning deltas, hooks, or collaboration records.

The second misreading is to call app-server “the protocol” and ignore the core submission/event queue. There are at least two visible boundaries: the core runtime queue and the app-server client envelope. They overlap, but they do not own the same concerns.

The third misreading is to think generated schemas are passive documentation. They are executable governance. They make protocol drift observable to build checks, generated clients, and compatibility filters.

The fourth misreading is to remove old aliases as if they were dead comments. The v1/v2 turn-event aliases show that compatibility can live directly on source types. Once a client depends on a boundary, cleanup has a different cost.

Apply This

  1. Name boundary nouns in source. If clients depend on a concept, give it a typed protocol shape instead of leaking private runtime state.
  2. Correlate every async handoff. Queue entries, events, responses, and errors should carry enough identity to explain which request they answer.
  3. Translate before rendering. UIs and SDKs should consume typed item and notification shapes, not scrape terminal strings.
  4. Put concurrency in the contract. If requests must serialize by thread, path, process, or global config, make that scope visible in protocol code.
  5. Generate the client boundary. TypeScript and JSON Schema exports turn protocol drift into a reviewable artifact instead of a runtime surprise.

Closing

Part I has now built the outside-in contract. Distribution reaches the Rust router. The router starts only after a constrained envelope exists. Work then crosses typed protocol boundaries instead of private method calls.

Part II can now open the runtime itself. Chapter 5 follows the thread and session model: how durable state, input queues, turn context, history, resume, fork, and rollback keep that protocol vocabulary grounded in a real runtime.

Source Map

ConceptSource anchor
Core Submission queue entrycodex-rs/protocol/src/protocol.rs#L123-L133
Op variants and turn-context orderingcodex-rs/protocol/src/protocol.rs#L403-L470
Approval, permission, and dynamic tool response operationscodex-rs/protocol/src/protocol.rs#L662-L719
Core Event shapecodex-rs/protocol/src/protocol.rs#L1247-L1254
EventMsg enum and v1/v2 aliasescodex-rs/protocol/src/protocol.rs#L1256-L1305
Side-effect and approval eventscodex-rs/protocol/src/protocol.rs#L1350-L1376
Item lifecycle and delta eventscodex-rs/protocol/src/protocol.rs#L1417-L1427
Event-to-notification mapping helpercodex-rs/app-server-protocol/src/protocol/event_mapping.rs#L25-L34
Event-to-item projection casescodex-rs/app-server-protocol/src/protocol/event_mapping.rs#L345-L449
JSON-RPC-style envelope note and message enumcodex-rs/app-server-protocol/src/jsonrpc_lite.rs#L1-L42
Request, notification, response, and error structscodex-rs/app-server-protocol/src/jsonrpc_lite.rs#L44-L88
Client request serialization scopescodex-rs/app-server-protocol/src/protocol/common.rs#L77-L88
Client request macro and serialization scope accessorcodex-rs/app-server-protocol/src/protocol/common.rs#L157-L221
Request definitions and example scopescodex-rs/app-server-protocol/src/protocol/common.rs#L434-L620
Command execution request scopescodex-rs/app-server-protocol/src/protocol/common.rs#L897-L920
Generated type entry pointcodex-rs/app-server-protocol/src/export.rs#L75-L80
TypeScript export and experimental filteringcodex-rs/app-server-protocol/src/export.rs#L101-L124
JSON Schema export, bundle writing, and filteringcodex-rs/app-server-protocol/src/export.rs#L192-L238