Files
byre/docs/codex-protocol.md
T

6.6 KiB

Codex app-server protocol

How internal/codex drives codex app-server. The server's own schema is the reference: codex app-server generate-json-schema --experimental --out DIR writes it, and codex_app_server_protocol.schemas.json in DIR is the whole bundle (top-level definitions plus a nested v2 set).

Binary and version

byre runs codex from PATH. On the owner's machine that is the CLI inside the ChatGPT desktop app (ls -la ~/.local/bin/codex shows the link), so the version moves when the app updates itself; codex --version prints it. byre passes -c check_for_update_on_startup=false so the CLI never checks for or offers updates of its own. SchemaVersion in internal/codex/protocol.gen.go names the version the types were generated from; when it differs from codex --version, regenerate and re-record (below).

Generated types

go generate ./internal/codex runs internal/codex/gen: it asks the CLI for the experimental schema, then writes protocol.gen.go with a Go type for every params and response type of the client methods listed in clientMethods (internal/codex/gen/main.go), every server notification and every server request, and everything they reference. The experimental schema is used because the server sends experimental fields (such as environments on a thread) even to clients that don't opt into them.

  • A oneOf/anyOf whose alternatives are all objects becomes one struct with every alternative's fields (a field whose type differs between alternatives is json.RawMessage), plus <Type>Type<Value> constants for its type discriminator, as for ThreadItem.
  • Alternatives that are all strings become a string type with a constant per value; a mix of strings and objects (such as AskForApproval) is json.RawMessage.
  • Optional and nullable fields get omitempty; optional structs are pointers.
  • Method<Name> constants name every method; clientResults, notificationParams, serverRequestParams and serverRequestResponses map methods to their types.

Launch and framing

codex app-server -c check_for_update_on_startup=false

Run in the session's working directory, with GIT_AUTHOR_*/GIT_COMMITTER_* set to the commit identity; shell commands inherit them. NDJSON both ways on stdio. Messages are JSON-RPC 2.0 without the "jsonrpc" member: requests {"id","method","params"}, responses {"id","result"} or {"id","error":{"code","message"}}, notifications {"method","params"}. The server adds emittedAtMs to its notifications. The server also reads the user's ~/.codex/config.toml, so its MCP servers, plugins and notify program apply to byre's sessions too.

Session lifecycle

  1. initialize with clientInfo, then the initialized notification.
  2. thread/start (cwd, model, approvalPolicy never, sandbox danger-full-access, developerInstructions for byre's guidance), or thread/resume with threadId and the same settings. The server picks the thread id, returned as thread.id; resuming keeps it. Session.ID is the thread id.
  3. turn/start with threadId and input, a list of UserInput: {"type":"text","text":...}, and for each image {"type":"image","url":"data:<type>;base64,..."} before the text. model on it switches the model for that turn and every later one (Session.SetModel holds the model for the next Send); thread/read reports the thread's current model. The response carries the new turn; turn/started follows.
  4. turn/interrupt with threadId and the active turnId ends the turn: turn/completed arrives with status interrupted within milliseconds, a running command is killed, and the thread stays usable. Session tracks the active turn from turn/started and turn/completed.
  5. thread/compact/start runs compaction as a turn of its own: turn/started, an item of type contextCompaction, turn/completed. The thread keeps its id and remembers the conversation in summary.

Closing stdin ends the process.

Events

  • turn/started, turn/completed (turn.status completed, interrupted or failed with turn.error). A failed request to the model also sends an error notification first, with willRetry.
  • item/started and item/completed with a ThreadItem: userMessage, agentMessage (text), reasoning, commandExecution (command, aggregatedOutput, exitCode), fileChange, mcpToolCall, contextCompaction and others.
  • item/agentMessage/delta (itemId, delta) streams an agent message; the deltas join to the completed item's text. item/commandExecution/outputDelta and the reasoning deltas stream the same way.
  • thread/tokenUsage/updated carries last and total token counts and modelContextWindow; account/rateLimits/updated the account's rate-limit windows; thread/status/changed, mcpServer/startupStatus/updated, account/updated and remoteControl/status/changed also arrive.
  • Lines that aren't a JSON object, don't parse, or carry a notification method outside notificationParams go to Options.OnBadLine; parsed notifications are still delivered as events. Event.Decode returns the notification's generated params type.

Server requests (approvals, user input, dynamic tool calls) shouldn't arrive with approvals off; if one does, command and file-change approvals are accepted and anything else gets a JSON-RPC error.

Transcripts and contract tests

Options.Transcript records every line in both directions as {"dir":"send"|"recv","msg":...}, deltas included, with any "email" value replaced by [redacted].

The tests in internal/codex/session_internal_test.go are scenarios (commit identity, text deltas, interrupt, image input, resume, model switch, compaction), each run two ways:

  • TestLive runs it against the real CLI with the models named by testModel and switchModel, and writes transcripts to internal/codex/testdata/transcripts/ (gitignored).
  • TestContract runs the same scenario against the committed transcripts in internal/codex/testdata/contract/. A replay server stands in for the CLI: it expects each recorded send line in turn (method and params equal, cwd aside) and writes each recorded recv line, so the client must send exactly what the recording shows and handle exactly what the server sent.

Both then decode every received line of their transcripts strictly into the generated types, so a field or method the types don't know fails the test. After a CLI update: go generate ./internal/codex, go test ./internal/codex -run TestLive, and when it passes, cp internal/codex/testdata/transcripts/*.jsonl internal/codex/testdata/contract/. Check the copies for anything private before committing them.