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.

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.

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 type | Source-level consequence |
|---|---|
Interrupt | The runtime can emit a typed aborted event instead of relying on a signal side channel. |
UserInput | Legacy input remains accepted, which is a compatibility obligation. |
UserInputWithTurnContext | Input and context changes are ordered together. |
| Approval and permission responses | A client decision re-enters as a typed operation, not as terminal text. |
| Realtime and background-terminal operations | Long-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.

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,
}

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 family | Source serialization clue | Why it matters |
|---|---|---|
thread/resume, thread/fork | thread_or_path(...) | A request may address a loaded thread or a path on disk. |
thread/archive, thread/read | thread_id(...) | Thread state operations are serialized per thread. |
skills/list | global_shared_read("config") | Some reads can share access to global config. |
hooks/list, marketplace operations | global("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,
)?;

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
- Name boundary nouns in source. If clients depend on a concept, give it a typed protocol shape instead of leaking private runtime state.
- Correlate every async handoff. Queue entries, events, responses, and errors should carry enough identity to explain which request they answer.
- Translate before rendering. UIs and SDKs should consume typed item and notification shapes, not scrape terminal strings.
- Put concurrency in the contract. If requests must serialize by thread, path, process, or global config, make that scope visible in protocol code.
- 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.