Codex through app-server JSON-RPC
Codex runs as a local app-server child speaking typed JSON-RPC over stdio; T3 translates its volatile native requests and notifications into canonical runtime events without making provider delivery durable.
What this chapter resolves
- Trace the Codex process and JSON-RPC lifecycle
- Distinguish native app-server shapes from canonical T3 events
- Locate resume models plans approvals and live usage
- Separate durable intent from volatile provider state
Codex is not called by a browser or mobile client. T3 starts a local codex app-server child, speaks JSON-RPC over stdio, and owns the translation from its
native protocol to the product’s ProviderRuntimeEvent stream. Process handles,
pending RPC requests, and notification queues remain volatile. Durable truth still
enters through orchestration commands and runtime ingestion.
A typed stdio client separates the child from the product
const dispatchNotification = (
notification: CodexProtocol.CodexAppServerIncomingNotification,
): Effect.Effect<void, never> => {
const schema =
notification.method in CodexRpc.SERVER_NOTIFICATION_PARAMS
? CodexRpc.SERVER_NOTIFICATION_PARAMS[
notification.method as CodexRpc.ServerNotificationMethod
]
: undefined;
const handlers = notificationHandlers.get(notification.method) ?? [];
if (schema) {
return decodeNotificationPayload(notification.method, schema, notification.params).pipe(
Effect.flatMap((decoded) =>
Effect.forEach(handlers, (handler) => handler(decoded), { discard: true }),
),
Effect.catch(() => Effect.void),
);
}
return unknownNotificationHandler
? unknownNotificationHandler(notification.method, notification.params).pipe(
Effect.catch(() => Effect.void),
)
: Effect.void;
};
const dispatchRequest = (
request: CodexProtocol.CodexAppServerIncomingRequest,
): Effect.Effect<unknown, CodexError.CodexAppServerError> => {
if (request.method in CodexRpc.SERVER_REQUEST_PARAMS) {
const method = request.method as CodexRpc.ServerRequestMethod;
const payloadSchema = getServerRequestParamSchema(method);
const responseSchema = getServerRequestResponseSchema(method);
const handler = requestHandlers.get(method);
return decodeOptionalPayload(method, payloadSchema, request.params).pipe(
Effect.flatMap((decoded) => runHandler(handler, decoded, method)),
Effect.flatMap((result) => encodeOptionalPayload(method, responseSchema, result)),
);
}
return unknownRequestHandler
? unknownRequestHandler(request.method, request.params)
: Effect.fail(CodexError.CodexAppServerRequestError.methodNotFound(request.method));
};
const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({
stdio,
...(terminationError ? { terminationError } : {}),
...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}),
...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}),
...(options.logger ? { logger: options.logger } : {}),
onNotification: dispatchNotification,
onRequest: dispatchRequest,
});
const request = <M extends CodexRpc.ClientRequestMethod>(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
): Effect.Effect<CodexRpc.ClientRequestResponsesByMethod[M], CodexError.CodexAppServerError> =>
encodeOptionalPayload(method, getClientRequestParamSchema(method), payload).pipe(
Effect.flatMap((encoded) => transport.request(method, encoded)),
Effect.flatMap(
(
raw,
): Effect.Effect<
CodexRpc.ClientRequestResponsesByMethod[M],
CodexError.CodexAppServerError
> => decodeOptionalPayload(method, getClientRequestResponseSchema(method), raw),
),
);The transport tracks pending requests by JSON-RPC id, queues notifications, routes incoming server requests to handlers, and fails pending work when the stream ends. Spawn, process-exit, protocol, transport, and native request failures stay typed until the adapter maps them into T3’s shared provider-error taxonomy.
Zoom with the controls, +/−, or Ctrl/⌘ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.
Text equivalent
A Codex app-server child communicates over JSON-RPC stdio. The Codex runtime starts or resumes a native thread, sends or interrupts turns, reads or rolls back a thread, and responds to approval or input requests. The adapter maps selected native observations into an in-memory canonical provider-event stream. Runtime ingestion dispatches internal commands. Only the resulting event, projection, and receipt transaction is durable; the child, pending map, adapter queue, and hot stream are process-local.
The stable contract begins at ProviderRuntimeEvent, not at an upstream method
name. A product event is not evidence that T3 persists every native field; an
unknown upstream notification can be ignored or mapped later without changing the
client-facing contract.
Session start is also resume and configuration
const startSession: CodexAdapterShape["startSession"] = (input) =>
Effect.scoped(
Effect.gen(function* () {
if (input.provider !== undefined && input.provider !== PROVIDER) {
return yield* new ProviderAdapterValidationError({
provider: PROVIDER,
operation: "startSession",
issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
});
}
const existing = sessions.get(input.threadId);
if (existing && !existing.stopped) {
yield* Effect.suspend(() => stopSessionInternal(existing));
}
const serviceTier =
input.modelSelection?.instanceId === boundInstanceId
? getCodexServiceTierOptionValue(input.modelSelection)
: undefined;
const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
const runtimeInput: CodexSessionRuntimeOptions = {
threadId: input.threadId,
providerInstanceId: boundInstanceId,
cwd: input.cwd ?? process.cwd(),
binaryPath: codexConfig.binaryPath,
launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment),
...(options?.environment ? { environment: options.environment } : {}),
...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}),
...(isCodexResumeCursorSchema(input.resumeCursor)
? { resumeCursor: input.resumeCursor }
: {}),
runtimeMode: input.runtimeMode,
...(input.modelSelection?.instanceId === boundInstanceId
? { model: input.modelSelection.model }
: {}),
...(serviceTier ? { serviceTier } : {}),
...(mcpSession
? {
environment: {
...(options?.environment ?? process.env),
T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""),
},
appServerArgs: [
"-c",
`mcp_servers.t3-code.url=${mcpSession.endpoint}`,
"-c",
'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"',
],
}
: {}),
};For one thread, startSession validates provider identity, closes any active Codex
context, and creates a runtime with cwd and runtime mode. It forwards a resume
cursor only when it matches Codex’s expected shape. Model and service-tier options,
and later reasoning effort, are forwarded only when selection belongs to the
adapter’s bound provider instance.
The event consumer starts before the runtime start completes. This prevents an
immediate native notification from being missed by the adapter; it does not journal
notifications or make a process crash replay them. The reported
sessionModelSwitch: "in-session" capability only says the adapter can change a
live model; it does not promise every discovered model/option is accepted upstream.
Turns and interactions move in opposite directions
sendTurn forwards text, attachments, model, reasoning effort, and service tier.
Interrupt, read, and rollback address a live native context. A missing adapter
session is not evidence that the durable T3 thread was deleted.
When Codex already has an active turn, the runtime still calls native
turn/start. App-server can accept that follow-up and return a queued native turn
id, while the session deliberately keeps the current active id as the interrupt
target. This is queued native-turn behavior—not the generic adapter SPI acquiring a
steer method or reusing one product turn id.
In the reverse direction, app-server requests approval or structured input. The
adapter emits canonical pending-request observations; client responses return through
respondToRequest or respondToUserInput. The native handler and waiting deferred
are volatile. A product request becomes durable only after runtime ingestion commits
an internal command.
Move one Codex observation across the boundary
The labels deliberately separate upstream JSON-RPC from T3’s stable runtime vocabulary and from durable orchestration state.
Position 1 of 5: Start / resume
Start / resume
- Native app-server
- Spawn codex app-server, initialize it, then start or resume a provider thread.
- Canonical T3 event
- session.started and thread.started identify a live adapter session.
- Durability boundary
- Only the orchestration binding and any saved resume cursor can survive; the child process and event consumer are live state.
All boundary positions
- Start / resume
Native: Spawn codex app-server, initialize it, then start or resume a provider thread.
Canonical: session.started and thread.started identify a live adapter session.
Durable: Only the orchestration binding and any saved resume cursor can survive; the child process and event consumer are live state.
- Turn request
Native: turn/start carries the prompt plus selected model, reasoning effort, and service tier.
Canonical: turn.started and item/content events describe the product-visible turn.
Durable: The earlier turn-start command is durable intent. Native request completion is not a SQL commit.
- Server asks
Native: The app-server sends an incoming approval or user-input JSON-RPC request.
Canonical: request.opened or user-input.requested becomes a pending product request.
Durable: The pending native handler waits in process; its durable counterpart arrives only after runtime ingestion dispatches an internal command.
- Notifications
Native: Items, plan deltas, token usage, reroutes, and errors arrive as app-server notifications.
Canonical: The adapter maps selected shapes into ProviderRuntimeEvent values.
Durable: The adapter queue and hot provider stream are volatile until ingestion commits a derived internal command.
- Stop / failure
Native: Close the runtime or observe child stdio/process/protocol failure.
Canonical: session.exited, runtime.warning, or runtime.error explains the observed outcome.
Durable: Stopping a process does not retroactively prove a provider result was projected; a prior accepted intent may outlive delivery.
Notification normalization is selective and product-shaped
if (event.method === "thread/tokenUsage/updated") {
const payload = readPayload(
EffectCodexSchema.V2ThreadTokenUsageUpdatedNotification,
event.payload,
);
const normalizedUsage = payload ? normalizeCodexTokenUsage(payload.tokenUsage) : undefined;
if (!normalizedUsage) {
return [];
}
return [
{
type: "thread.token-usage.updated",
...runtimeEventBase(event, canonicalThreadId),
payload: {
usage: normalizedUsage,
},
},
];
}
if (event.method === "turn/started") {
const turnId = event.turnId;
if (!turnId) {
return [];
}
return [
{
...runtimeEventBase(event, canonicalThreadId),
turnId,
type: "turn.started",
payload: {},
},
];
}
if (event.method === "turn/completed") {
const payload = readPayload(EffectCodexSchema.V2TurnCompletedNotification, event.payload);
if (!payload) {
return [];
}
const errorMessage = trimText(payload.turn.error?.message);
return [
{
...runtimeEventBase(event, canonicalThreadId),
type: "turn.completed",
payload: {
state: toTurnStatus(payload.turn.status),
...(errorMessage ? { errorMessage } : {}),
},
},
];
}
if (event.method === "turn/aborted") {
return [
{
...runtimeEventBase(event, canonicalThreadId),
type: "turn.aborted",
payload: {
reason: event.message ?? "Turn aborted",
},
},
];
}
if (event.method === "turn/plan/updated") {
const payload = readPayload(EffectCodexSchema.V2TurnPlanUpdatedNotification, event.payload);
if (!payload) {
return [];
}
return [
{
...runtimeEventBase(event, canonicalThreadId),
type: "turn.plan.updated",
payload: {
...(trimText(payload.explanation) ? { explanation: trimText(payload.explanation) } : {}),
plan: payload.plan.map((step) => ({
step: trimText(step.step) ?? "step",
status:
step.status === "completed" || step.status === "inProgress" ? step.status : "pending",
})),
},
},
];
}Codex item activity becomes assistant content, tool/item lifecycle, and activity
observations. Native plan material can become a proposed plan or plan delta;
task/subagent-shaped signals are classified into product activity/task vocabulary.
Model reroute becomes model.rerouted; retryable native error can become
runtime.warning, fatal error runtime.error.
Codex token-usage updates feed live per-thread context telemetry. They are not the Usage page’s transcript scanner, should not be summed with historical accounting, and do not represent subscription billing.
Stopping closes the runtime and interrupts its event fiber; listing sessions reads the adapter’s in-memory map. Tests cover instance-bound options, plans, errors, approval/input, usage, and consumer lifetime. They do not prove process-crash repair between adapter publication and runtime ingestion.