Skip to content
Codex CLI Adapter

Codex CLI Adapter

The Codex CLI adapter connects Sortie to the OpenAI Codex CLI via a persistent subprocess. It launches codex app-server, communicates over JSON-RPC 2.0 on stdin/stdout (JSONL), and normalizes event notifications into Sortie’s own event vocabulary. Registered under kind "codex".

Unlike the Claude Code and Copilot CLI adapters, the Codex adapter uses a persistent subprocess model. The adapter launches the process when the session starts and keeps it alive across turns. Each turn sends a turn/start request on the existing thread rather than spawning a new process.

See also: WORKFLOW.md configuration for the full agent schema, environment variables for CODEX_API_KEY and related variables, error reference for all agent error kinds, how to write a prompt template for template authoring, Jira + Codex end-to-end tutorial for a step-by-step walkthrough.


Configuration

The adapter reads from two configuration sections in WORKFLOW.md front matter: the generic agent block (shared by all adapters) and the codex extension block (pass-through to the adapter).

agent section

These fields control the orchestrator’s scheduling behavior. They are not passed to the Codex CLI.

FieldTypeDefaultDescription
kindstring-Must be "codex" to select this adapter.
commandstring or listcodex app-serverPath or name of the Codex binary with arguments. Resolved from PATH at session start. As a string, the first space-separated token is the binary name and the remaining tokens are arguments. Read only when codex is the default kind. A dispatch rule that routes to codex while another kind is the default launches codex app-server, not agent.command; the list form is described under agent.command.
max_turnsinteger20Maximum Sortie turns per worker session. The orchestrator runs a turn up to this many times, re-checking tracker state after each turn.
max_sessionsinteger0 (unlimited)Maximum completed worker sessions per issue before the orchestrator stops retrying. 0 disables the budget.
max_concurrent_agentsinteger10Global concurrency limit across all issues.
turn_timeout_msinteger3600000 (1 hour)Total timeout for a single turn. The orchestrator cancels the turn when exceeded.
read_timeout_msinteger5000 (5 seconds)Bounds every wait on the app-server during session start and turn interruption: the initialize, account/read, account/login/start, thread/start, and thread/resume responses, the account/login/completed and thread/started notifications, and, during a turn, the wait for turn/completed after a cancelled turn’s turn/interrupt. Falls back to 30 seconds when unset or not positive.
stall_timeout_msinteger300000 (5 minutes)Maximum time between consecutive events before the orchestrator treats the session as stalled. 0 or negative disables stall detection.
stop_grace_msinteger5000 (5 seconds)How long the adapter waits for the subprocess to exit on its own after a graceful termination signal, before it force-terminates the process group. Must be positive.
max_retry_backoff_msinteger300000 (5 minutes)Maximum delay cap for exponential backoff between retry attempts.
agent:
  kind: codex
  command: codex app-server
  max_turns: 15
  max_sessions: 3
  max_concurrent_agents: 4
  stall_timeout_ms: 300000

codex extension section

These fields are adapter-specific. Most map to a JSON-RPC parameter on thread/start or turn/start; mcp_config is read by the worker instead and never becomes a parameter. The orchestrator forwards them to the adapter as written, except for approval_policy, which is checked before any run starts; see validate-time checks.

FieldJSON-RPC paramTypeDefaultDescription
modelmodel (thread/start, turn/start)string(CLI default)LLM model identifier, forwarded unchanged. See codex --help on your installed version for the accepted values.
efforteffort (turn/start)string(CLI default)Reasoning effort level, sent on every turn and forwarded unchanged. The accepted names depend on the model. See adapter pass-through configuration for how an unset value is read.
approval_policyapprovalPolicy (thread/start)stringneverWhen the app-server asks for a decision before running a command or applying an edit. never is the only value Sortie accepts; which policies Codex itself offers is Codex’s to document. See approval policy and sandbox.
thread_sandboxsandbox (thread/start)stringworkspaceWriteSandbox mode for the thread. The adapter rewrites the four camelCase spellings it recognizes (readOnly, workspaceWrite, dangerFullAccess, externalSandbox) into the kebab-case forms thread/start expects, and forwards any other value untouched. See approval policy and sandbox.
turn_sandbox_policysandboxPolicy (turn/start)map(see below)Per-turn sandbox policy override, merged key-by-key on top of the adapter’s default policy and able to replace any key in it. Setting it also makes the adapter send sandboxPolicy on every turn rather than only the first.
personalitypersonality (thread/start)string(none)Personality preset.
mcp_config(none; read by the worker)string(none)Path to an operator-supplied MCP server configuration file, resolved relative to the WORKFLOW.md directory when not absolute. Its servers are merged into the generated configuration, and the adapter translates the merged result onto the app-server command line on a local launch. See MCP.
codex:
  model: <model-id>
  effort: medium
  approval_policy: never
  thread_sandbox: workspaceWrite
  personality: ""

agent.max_turns and the persistent thread model

The Codex adapter does not have an inner turn limit equivalent to claude-code.max_turns or copilot-cli.max_autopilot_continues. Each turn sends a single turn/start request, and the agent works until it produces a turn/completed notification. The orchestrator controls the total number of turns via agent.max_turns.

FieldControlsScope
agent.max_turnsSortie’s orchestrator turn loopHow many times the orchestrator runs a turn per worker session.

Within a single turn, Codex’s internal agentic loop runs until completion, interruption, or failure. There is no adapter-level cap on the number of agentic steps within a turn. Use turn_timeout_ms to bound wall-clock time per turn.

Approval policy and sandbox

For headless orchestration, the adapter defaults approval_policy to "never" and thread_sandbox to "workspaceWrite". approvalPolicy travels on thread/start only; the adapter sends no turn-level override, so the thread’s policy governs every turn of the session.

never is the only value Sortie accepts. Every run is unattended, so a policy that lets the app-server stop and ask for a decision has nobody to answer it, and the codex.approval_policy.interactive error refuses any other string value before the run starts, so the app-server never sees it.

Which policies Codex itself offers, and what each one does, is Codex’s to document; see external references. A map value is the one exception: it is read as a string, discarded without a diagnostic, and the thread starts under never.

The default keeps the app-server from asking most questions. The adapter refuses the ones that still arrive rather than leaving any of them waiting, and it splits them by what was asked for rather than by which method asked.

What the request asks forWhat the adapter does
Consent to act, such as running a command or changing a fileRefuses in the form that lets the agent try another route, emits a notification, and the turn continues.
An answer only a person could giveEnds the attempt at once with turn_input_required, which releases the claim instead of scheduling a retry. The run is recorded with status needs_person rather than failed.

No reply schema in the second class carries a value that both refuses and lets the turn continue, which is why those attempts end rather than degrade. A request that asks for something a program can supply is reported as an other_message event.

turn/start carries a sandboxPolicy on the session’s first turn, and on every turn when turn_sandbox_policy is set; otherwise later turns send none and the thread’s own sandbox stands. The default policy sets type to the camelCase spelling of thread_sandbox (workspaceWrite when unset), writableRoots to the workspace path, and networkAccess to false. Operator overrides from turn_sandbox_policy are merged on top and may replace any of the three.

The two requests spell the sandbox differently, and the adapter translates between them: thread/start receives the kebab-case form (workspace-write), the turn/start policy’s type receives the camelCase form (workspaceWrite). A value the adapter does not recognize is forwarded to both as written.

The credential-verification session a worker attempt opens before its working session overrides all of this: it starts its thread with sandbox: "read-only" and ephemeral: true regardless of codex.thread_sandbox, and its own sandbox policy is fixed at {type: "readOnly", writableRoots: [], networkAccess: false} regardless of codex.turn_sandbox_policy. ephemeral is the signal that asks the app-server not to keep that thread once the session ends.

The same thread also starts with a short fixed prompt as its baseInstructions, and a config override that sets project_doc_max_bytes to 0, web_search to disabled, and a fixed list of tool features to false. Sortie reads the MCP server names from the app-server’s config/read response and sets mcp_servers.<name>.enabled to false for each; when that read fails, no server is disabled and the session still starts. codex.personality is not sent. A Codex build that does not know one of the configuration keys ignores it, and the request carries more than it would on a newer build.

approval_policy: never allows arbitrary command execution within the sandbox. Use this only in sandboxed environments. Sortie’s workspace isolation does not replace container-level isolation.

Validate-time checks

When agent.kind is codex, the sortie validate pipeline runs a Codex-specific config check in addition to the generic preflight validation. It builds no adapter and makes no network call, and the same check runs at startup and on every workflow reload, so the verdict is identical in all three places.

Errors

CheckConditionMessage
codex.approval_policy.interactivecodex.approval_policy is set to any value other than nevercodex.approval_policy is set to a value that lets the agent stop and ask for approval, and an unattended run has no one to answer; only "never" is supported

An absent approval_policy draws nothing: the adapter sends never for it.


Session lifecycle

Session start

Launches the app-server subprocess, performs the JSON-RPC initialization handshake, authenticates if needed, and starts or resumes a thread.

  1. Validates that the workspace path is a non-empty absolute path pointing to an existing directory.
  2. Resolves command from PATH (splits on whitespace to extract the binary and its argument tokens). In SSH mode, resolves the local ssh binary instead.
  3. Resolves the session’s tool delivery from the generated MCP configuration. On a local launch it appends one -c / mcp_servers.<name>=<inline table> argument pair per declared server to the launch arguments. On an SSH launch it starts Sortie’s HTTP tool server, opens one short extra SSH connection to learn whether the worker host accepts the reverse forward, and later sends the same overrides on the SSH session’s standard input. See MCP.
  4. Launches the subprocess in the workspace path, with the full parent process environment, isolated in its own process group.
  5. Wires stdin, stdout, and stderr pipes, and reads stdout one line at a time (1 MB max line size).
  6. Initialize handshake: sends initialize request with clientInfo and capabilities.experimentalApi: true. Waits for response. Sends initialized notification.
  7. Authentication check: sends account/read. If account is null and CODEX_API_KEY is set, performs API key login. See authentication.
  8. Thread start: sends thread/start with model, cwd, approvalPolicy, and sandbox. Records threadId. The adapter registers no client-side tool declarations; Sortie’s tools reach the session through the MCP servers the runtime spawns from the overrides in step 3.
  9. Resume path: if a session ID saved from a previous run is supplied, sends thread/resume instead. Falls back to thread/start if resume fails.
  10. The session records the thread ID as its session ID, and the subprocess PID as the agent’s process ID.

Errors:

ConditionError kind
Empty or non-existent workspace pathinvalid_workspace_cwd
Workspace path is not a directoryinvalid_workspace_cwd
Agent binary not found in PATHagent_not_found
Agent command is empty or whitespace-onlyagent_not_found
SSH binary not found (SSH mode)agent_not_found
Subprocess failed to startport_exit
Session start’s own context ended before the subprocess started runningturn_cancelled
Pipe creation failed (stdin, stdout, stderr)port_exit
Generated MCP configuration unreadable or not expressibleresponse_error
The app-server exits before a startup handshake step is answered: the initialize exchange, the authentication check, thread/start, or thread/resumeport_exit, as the early exit report
Initialize handshake failedresponse_error
Authentication failedresponse_error
Thread start/resume failedresponse_error

A handshake step whose elapsed read_timeout_ms bound fails the session reports the same response_error kind as one the app-server itself rejected: nothing in the reported kind tells the two apart. The logged error text still does: a bound that elapsed names either context deadline exceeded or, for the wait on account/login/completed, timeout waiting for account/login/completed; an app-server rejection instead carries the app-server’s own protocol-level error text.

Turn

Sends a turn/start JSON-RPC request on the existing thread and reads event notifications until turn/completed.

  1. Builds turn/start params with threadId, input (prompt as text), cwd, and optionally sandboxPolicy, model, and effort, the last whenever codex.effort is set.
  2. Sends the request and waits for the matching response.
  3. Waits for event notifications from the app-server or for the turn to be cancelled, whichever happens first.
  4. Dispatches notifications by method name (see event stream).
  5. When the turn is cancelled, writes one best-effort turn/interrupt to the app-server’s stdin, sent independently of the turn’s own cancellation so the write itself still goes through, then keeps reading for read_timeout_ms in case the app-server reports its own turn/completed. Past that bound the turn ends as cancelled.
  6. On turn/completed, emits the terminal turn event carrying the session’s cumulative usage, ending the turn.

Session stop

Terminates the persistent app-server subprocess. Safe to call when no subprocess is active.

  1. Closes the JSON-RPC connection, which fails any request still waiting for a response, then closes the stdin pipe without waiting for that close to finish, so a close stuck at the OS level cannot delay the graceful signal or the escalation that follow it.
  2. Sends the graceful shutdown signal to the process group (POSIX: SIGTERM; Windows: CTRL_BREAK_EVENT). Waits up to stop_grace_ms, or until the orchestrator’s own deadline expires, whichever comes first.
  3. Force-terminates the process tree if still running (POSIX: SIGKILL to process group; Windows: TerminateJobObject). A stop that ends because the orchestrator’s own deadline expired reports that deadline’s error instead of a clean stop, so the orchestrator learns the stop did not finish on its own terms.
  4. Closes the standard-output read end, unblocking any pending read of the app-server’s output.
  5. Waits up to two seconds for output reading to finish, then closes both read ends.
  6. Joins the standard-error drain that closing those ends releases, bounded at five more seconds, so the stop does not return while a collector from this session is still reading. What that drain collected is discarded rather than reported; see runtime standard error.

Process shutdown

Because the subprocess persists across turns, the shutdown sequence runs when the session stops, not at the end of each turn. It closes stdin (EOF signal), sends the graceful shutdown signal to the process group, waits up to stop_grace_ms, then escalates to a force kill. The caller’s deadline is a second bound on that wait: whichever expires first ends the graceful phase.

On Unix, the graceful signal is SIGTERM and the force kill is SIGKILL to the process group. On Windows, the graceful signal is CTRL_BREAK_EVENT to the process group, and the subprocess is assigned to a Job Object with KILL_ON_JOB_CLOSE: the force kill terminates that Job Object, and closing its handle kills the process tree even when nothing reaches the force kill at all, such as on an orchestrator crash.

The subprocess starts suspended and is resumed only after that assignment succeeds, so nothing it spawns can run before the job takes effect. A failed assignment logs WARN process group assignment failed and the app-server subprocess runs without a job; a failed resume logs WARN process resume failed and fails session start with error kind port_exit.

A session start whose context is already done when the resume is reached, by cancellation or by its deadline, is never resumed: Sortie terminates the subprocess before it runs, logs no resume warning, and ends the start as turn_cancelled. Because the app-server subprocess persists across turns, a resume failure here ends the whole session before any turn runs.

The adapter owns the subprocess’s standard-output and standard-error read ends for the whole session rather than handing them to the process object, so reaping the app-server never closes one while it is still being read. The notifications a runtime sends on its way out reach the turn that was waiting for them instead of being discarded with the reap.

What the reap cannot settle on its own is output reading that does not end with the runtime: a descendant that inherited the output handle can keep it open after the runtime itself is gone, leaving the handshake call or turn with nothing to wait for.

A five-second bound from the reap covers this: past it, the output read end and the JSON-RPC connection are closed, and a warning, agent stdout was not fully collected before the session ended, names the bound as a drain_bound attribute. The waiting call then ends. A turn ended this way reports port_exit naming the runtime’s exit rather than the transport error behind it; see process exit handling.

The same five-second bound also settles standard error, starting at the same reap and running alongside the bound above. Whatever the drain has read by then is kept, and the read end is closed so the drain itself ends instead of blocking for the lifetime of a descendant that inherited the write handle. A failure path that reports the runtime’s standard error afterward reads a settled result rather than waiting again; see runtime standard error.

When a turn is cancelled, the adapter writes one turn/interrupt request to stdin and then reads for at most read_timeout_ms more, so the app-server has a bounded chance to report the turn’s own completion. The app-server acknowledges no client-sent response, so that bound is what keeps an unacknowledged interrupt from holding the turn open.

Every write to the app-server, including the turn/start request that opens a turn and this interrupt, completes once it is queued for delivery, not once the runtime actually reads it. The wait for a response is bounded separately: the turn’s own deadline (turn_timeout_ms) for turn/start, and read_timeout_ms for the interrupt’s follow-up read described above.


Event stream

The Codex app-server emits JSON-RPC notifications on stdout. The adapter reads each line, separates responses from notifications, and maps notifications onto Sortie’s normalized event vocabulary, so what reaches the orchestrator, the logs, and the dashboard is the same set of events every adapter produces. The app-server’s own notification methods and payload shapes are Codex’s to define; see external references.

Two of those mappings decide how a run ends, and both follow from the approval policy. A request that asks for consent to act is refused in a form that lets the agent try another route, reported as a notification, and the turn continues. A request addressed to a person ends the attempt with turn_input_required, which releases the claim instead of scheduling a retry and records the run as needs_person rather than failed.

A server-initiated request arriving outside a turn, whether during the handshake or in the gap between the handshake finishing and the first turn starting, always gets a reply: an mcpServer/elicitation/request is declined, and every other method, including one a turn would otherwise recognize and answer specifically, is answered with a method-not-found error instead. Inside a turn, a request this adapter does not otherwise recognize gets that same method-not-found reply rather than being left open.

A notification is treated differently outside a turn: only mcpServer/startupStatus/updated is acted on there, exactly as startup failures describes, and every other notification arriving before the first turn starts produces no event and is not logged.

Token counts do not travel on the turn-completion notification. They arrive on their own notification; see token accounting.


Token accounting

Reported token counts are cumulative over the whole session the orchestrator opened, across every turn of it, and never decrease. The figure has two sources. The live source is the app-server’s thread/tokenUsage/updated notification, which arrives after each model request while the turn runs. The records source is the thread’s rollout file, which Codex writes on the host that runs the app-server and which Sortie reads when it resumes a thread and when the live notifications leave a turn unsettled. For this kind’s declaration beside every other kind’s, see the usage reporting table; for how a run’s figure, its provenance, and a lower bound fit together across kinds, see spend measurement.

Live notifications

  1. The notification carries two breakdowns. tokenUsage.total is cumulative over the thread, every turn and every earlier run that resumed it included. tokenUsage.last is the request’s own counts. The run’s figure is total less the thread’s history position, the total the thread held before the run’s first request.
  2. A thread the run starts has a history position of zero, including when thread/resume fails and the adapter starts a new thread instead.
  3. After a successful thread/resume, the app-server repeats the thread’s last persisted total once, under an earlier turn’s id, before the first turn starts. That notification counts nothing: it sets the history position. When it never arrives, the first notification of the running turn sets the position to total minus last. A position derived this way that is not zero is an estimate, because the subtraction cannot tell the restored history from spend the turn had already made, so that turn is marked as one whose spend is not fully accounted for.
  4. Each notification of the running turn that adds input or output tokens emits one token_usage event carrying the run-cumulative snapshot. A notification that repeats a total unchanged emits nothing. A notification whose turnId names another turn counts nothing and emits no event.
  5. input_tokens comes from inputTokens, output_tokens from outputTokens, cache_read_tokens from cachedInputTokens, and cache_write_tokens from cacheWriteInputTokens. inputTokens already includes both cache counts, so they are disjoint subsets of input, and output already includes reasoning. total_tokens is computed as input_tokens + output_tokens rather than read from the notification’s own totalTokens.
  6. A count that is absent or null, a negative count, or cache counts that add up to more than inputTokens make the notification no figure rather than zero. A notification carrying no tokenUsage object emits no event.

Rollout file

The app-server reports the thread’s rollout file in the thread/start or thread/resume response. Sortie reads it under the runtime’s home on the session’s host: CODEX_HOME, or ~/.codex when that variable is unset. It keeps only the part of the reported path below that home’s sessions directory, so the reported path cannot lead the read outside the home; any other path leaves the session without a records source.

Each token_usage_record line is one model request, identified by its response_id, and is served by the model the latest turn_context line names. Codex writes the record when the response completes, before the request’s tool calls run, so the file holds a request whose process was killed before the app-server reported it.

The file is read at session start of a resumed thread, to set the requests already in it aside as history, and at the end of a turn the live notifications left unsettled:

  • the turn was interrupted, whether Sortie cancelled it or not;
  • the turn never reached turn/completed: the process was killed or exited, its output closed or turned unreadable, or the read_timeout_ms bound after a cancelled turn’s turn/interrupt expired;
  • the turn received no usable notification of its own;
  • the resumed thread’s history could not be read at session start.

A completed turn that the live notifications settled reads nothing. Each read is bounded by read_timeout_ms and is not cut short by the turn’s cancellation, so a cancelled turn still reads the requests it completed. When the rollout file holds requests the run has not counted, they replace the turn’s live figure, and the run’s usage_provenance lists source journal with strategy records; a turn the live notifications settle adds source output with strategy live. A read that fails or finds no uncounted request leaves the live figure standing. A figure settled from the rollout file emits no token_usage event; the turn’s terminal event carries it.

Completeness

A turn is fully accounted for when it ends with turn/completed status completed or failed, one of the two sources settled its figure, and that figure rests on no estimated history position. Every other turn that served model output is unaccounted, and counts toward the unaccounted_turns the token-ceiling log records carry:

  • status interrupted, and every turn that never reaches turn/completed, even when the rollout file settled it, because neither source proves it covers the request in flight;
  • a turn whose figure rests on an estimated history position;
  • a completed or failed turn that neither source settled.

A completed turn of the last kind is the only one a later turn clears: the next fully accounted turn of the same thread settles a thread total that holds it, unless the session’s history position was estimated. A run with an unaccounted turn reads lower_bound as its usage_completeness in the run history. A run in which no turn produced a figure from either source is recorded as unmeasured rather than as having spent zero.

Model tracking

The reported model comes from the model field of whichever response opened the session’s thread (thread/start or thread/resume), read once at session start and reused for every token_usage event afterward rather than re-read per turn. It reflects the runtime’s own report, not a mirror of the codex.model value the adapter requested. The field is empty when the response omits it; that is never treated as an error.

The token notification carries no per-model split, so every token the adapter reports from it counts under the tracked model at that moment, as the model label describes. A turn settled from the rollout file counts each request under the model of the turn_context line before it.

A running turn can be moved to a different model by a model/rerouted notification. When its destination model is named, it replaces the tracked model for the rest of the session; a reroute with no named destination leaves the tracked model unchanged. Either way, the adapter emits a notification event describing the reroute, so stall detection still sees activity.

API timing

The adapter does not track per-request API latency. No event this adapter emits reports how long an API request took.


Usage limits

The adapter reports the limits of a Codex account from the stream, with no request of its own. Each account/rateLimits/updated notification carries a primary and a secondary window, and each window the notification fills is one limit.

Window memberReported as
usedPercentThe share used, as a percentage.
windowDurationMinsThe length, which names the limit: 5-hour limit, Weekly limit. A length that is not a whole number of hours or days reads in minutes, such as 45-minute limit.
limitNameThe scope, written before limit: a 5-hour window with the limitName X reads 5-hour X limit.
resetsAtThe reset time, from Unix seconds.

A limit is keyed by the notification’s limitId together with primary or secondary, so each account limit keeps its own entry and a newer notification replaces it. The notification carries no status, so the dashboard shows the share and the reset time. How a reached limit ends a turn is under error category mapping.


Tool call tracking

The adapter routes no tool call of its own. Sortie’s tools reach the session as MCP servers the runtime spawns from the overrides described under MCP, and the runtime carries every call and result. What the adapter does is observe: it correlates the app-server’s item notifications into tool_result events for the orchestrator, the logs, and the dashboard.

Item-level correlation

  1. An item/started notification with type in commandExecution, fileChange, mcpToolCall, or dynamicToolCall records the call and a start time, keyed by item.id. Other item types start no call.
  2. An item/completed notification looks up the matching item.id. When found, the adapter emits a tool_result event carrying the call and how long it took.
Item typeNameKindArgument
commandExecutionnoneshellThe item’s command.
fileChangenoneeditThe path of each entry in the item’s changes, joined with , in order. Empty when changes is not a list.
mcpToolCallThe item’s tool, prefixed with its server and a dot when a server is named.nonenone
dynamicToolCallThe item’s tool, prefixed with its namespace and a dot when a namespace is named.nonenone

The protocol item type is never used as a name. The HTTP API serves these members and the session view shows them.

Tool error detail

The adapter extracts no error text from item payloads. A tool_result event sets the error flag when the item’s status is failed or declined.


Error handling

Error category mapping

When turn/completed carries status: "failed", the turn.error.codexErrorInfo field classifies the failure:

codexErrorInfoError kindDescription
Unauthorizedcredential_unverifiedInvalid or expired API credentials. The message reads the agent runtime refused its credential: followed by the app-server’s own message.
BadRequestresponse_errorMalformed request.
ContextWindowExceededturn_failedToken limit exceeded.
UsageLimitExceededturn_failedAPI usage quota exhausted.
SandboxErrorturn_failedSandbox enforcement failure.
HttpConnectionFailedturn_failedUpstream API connection failure.
ResponseStreamConnectionFailedturn_failedSSE/WebSocket stream connection failure.
ResponseStreamDisconnectedturn_failedMid-stream disconnect.
ResponseTooManyFailedAttemptsturn_failedInternal retry budget exhausted.
InternalServerErrorturn_failedServer-side error.
Otherturn_failedCatch-all.
(unknown value)turn_failedUnrecognized error info defaults to turn_failed.

The adapter compares the value without regard to case and accepts both wire forms: a plain string, and an object with one member named for the value. Whichever form arrives, or when the field is absent or unreadable, the turn keeps the app-server’s own error message.

response_error, turn_failed, and credential_unverified are all retryable with exponential backoff by Sortie’s default agent-error retry classification, same as every other agent error kind above agent_not_found and invalid_workspace_cwd; codexErrorInfo distinguishes only which error kind is reported, not whether the orchestrator retries.

Process exit handling

Because the Codex adapter uses a persistent subprocess, process exit during a turn is abnormal.

ConditionError kindMessage
Stdout stream closed during turnport_exitsubprocess stdout closed unexpectedly
Error reading stdoutport_exitstdout read error: <detail>
turn/start response errorturn_failedturn/start error: <app-server message>
No turn/start response arrives: the turn was cancelled, or the connection ended firstport_exitturn/start failed: <detail>, in the run’s recorded error only; this row emits no turn event

Once the release path has given up on reading the output, each port_exit row reports the agent runtime exited before the session finished collecting its output in place of its own message, and the underlying transport error, where there was one, stays part of the run’s recorded error. See process shutdown. The turn_failed row carries the app-server’s own report and is never replaced.

Stdout read failure

If reading stdout encounters an error or reaches EOF, the turn emits turn_failed and ends with error kind port_exit.

Runtime standard error

The adapter re-emits what the app-server wrote to its standard error at WARN level, one record per line, under the message agent stderr. The rule is that a failure reports only where the runtime is already gone: a session that fails during the handshake, and a turn that ends because the output stream ended, carry the runtime’s own account of itself rather than an exit status alone.

A failure against a runtime that is still running reports nothing, because there is no parting diagnostic to collect and the drain cannot finish while the runtime still holds the write end. A session-start failure that precedes the launch, such as an unreadable MCP configuration, has no subprocess and so no standard error.

PathReports
Any handshake step that fails once the subprocess is running: the initialize exchange, the authentication check, thread/start, or the thread/start fallback taken after a failed thread/resumeYes
A turn whose event stream closed, or whose stdout stream endedYes
A turn/start call that failed after the connection had already stopped reading outputYes
A turn/start call that failed while output reading was still runningNo
A malformed stdout line, which does not end the connection: reading continues afterwardNo
Session stopNo

A session reports at most once, from whichever of those paths reaches the point first. Later failures meet the same dead runtime and have nothing to add. Session stop stays silent throughout: closing the connection ends the event stream, a turn still in flight reads that as its runtime dying, and a stop arriving mid-report suppresses it rather than explaining a runtime the operator stopped on purpose.

The report adds no wait of its own once process shutdown has settled the drain; a report that arrives before it pays the same five-second bound instead. Where that bound expires with output still unread, the lines already collected are reported and a marker after them says later output may be missing.

Every line also reaches the log at DEBUG level as it is read, whatever the session’s outcome, so standard error from a session that succeeded is available under --log-level debug.


Session resume mechanism

Within a session, multi-turn continuation is automatic. Each turn sends turn/start on the same threadId. No resume flag or session ID propagation is needed between turns.

When the orchestrator hands back an earlier session, as it does for a continuation, including one still queued when Sortie restarts, the adapter sends thread/resume with the saved thread ID. History is restored from Codex’s on-disk rollout file. If resume fails, the adapter falls back to thread/start (new thread, previous context lost).

The session ID is the Codex thread ID, read from the thread/start response and never generated by the adapter. A thread/start response carrying an empty thread ID fails the session.


SSH remote execution

When the worker configuration includes ssh_hosts, the adapter launches the app-server on a remote host via SSH.

How it works

  1. Session start resolves the local ssh binary from PATH. The agent command is stored for remote execution.
  2. Builds the SSH connection arguments listed under SSH options.
  3. All JSON-RPC communication flows over the SSH tunnel’s stdin/stdout, including the API-key login described under authentication.

Token accounting works the same over SSH. The live notifications arrive on the SSH session’s standard output, and each rollout file read runs as one extra command over the session’s SSH launch, where the remote shell resolves CODEX_HOME. A killed or interrupted remote turn therefore settles from the worker host’s rollout file as a local one does.

This kind declares no credential variable, so a remote launch carries none on its own account. It carries what worker.ssh_pass_env names and nothing else.

SSH options

The adapter uses these SSH options via the shared sshutil package. A launch that delivers Sortie’s tools adds a reverse forward and the options that go with it; see delivery over SSH.

OptionValuePurpose
StrictHostKeyCheckingConfigurable (default: accept-new)Host key verification policy. Set via worker.ssh_strict_host_key_checking. Allowed values: accept-new, yes, no.
BatchModeyesDisables interactive prompts (password, passphrase).
ConnectTimeout30Connection timeout in seconds.
ServerAliveInterval15Keepalive interval in seconds.
ServerAliveCountMax3Number of missed keepalives before disconnect.

Shell quoting

The workspace path and each per-turn argument are single-quoted with embedded single-quote escaping ('\'') before being placed in the remote command string. A string agent command itself is not quoted this way; each element of a list command is single-quoted. No credential is placed in that string; see authentication.

Exit codes

SSH exit code 255 indicates a connection failure (refused, timeout, unreachable) and maps to port_exit. Exit code 127 means the remote agent binary is not in PATH; the app-server never answers its handshake, so session start fails with the early exit report under port_exit, carrying exit status 127 and the remote shell’s own message.


Authentication

Sortie does not manage Codex CLI credentials. The adapter spawns the subprocess with the full parent process environment, and the Codex CLI reads its authentication variables directly.

Authentication sequence at session start: sends account/read. If result.account is non-null, authentication is valid. If null and CODEX_API_KEY is set, sends account/login/start with type: "apiKey". Waits for account/login/completed. If CODEX_API_KEY is not set, the adapter proceeds without login (the app-server may use cached credentials).

Auth modeMechanismNotes
API keyCODEX_API_KEY in the Sortie process environmentConsumed only when account/read reports no account. The adapter forwards the value verbatim and never inspects it.
Credentials the runtime already holdsaccount/read returns a non-null accountThe adapter performs no login and starts the thread. How those credentials were established is Codex’s to document.
The adapter never prompts for credentials, and a missing CODEX_API_KEY is not by itself an error. With no key set and no account reported, session start proceeds to thread/start and the failure surfaces there or on the first turn. A remote session logs in the same way: Sortie reads CODEX_API_KEY from its own environment and sends it as an account/login/start parameter over the SSH connection, so the key never enters the remote agent’s environment and never has to exist on the build host.

MCP

codex app-server accepts no MCP-config path argument. The adapter delivers the servers rather than the file: it reads the generated .sortie/mcp.json and re-expresses each declared server as configuration the runtime parses for itself.

Each declared server becomes one -c / mcp_servers.<name>=<inline table> argument pair, which a local launch appends to the app-server command line. One pair per server rather than one for the whole table, so an operator’s own [mcp_servers] entries in their own Codex configuration merge with Sortie’s instead of being replaced.

On a local launch the runtime spawns each declared server itself over stdio, which makes sortie-tools a child of the app-server. On an SSH launch the pairs are not on a command line at all; see SSH mode delivers over HTTP.

Environment values

On a local launch, for a stdio server, Sortie adds each environment entry to the app-server’s own process environment and lists the variable’s name in the entry’s env_vars key. The runtime resolves the value from the environment it hands the spawned server, so the value never appears on the command line the host’s process list exposes.

An entry stays a literal env value inside the inline table in two cases. The app-server’s environment already holds that name with a different value, or two stdio servers declare it with different values. env_vars passes a variable only under its own name, so neither can travel by name. Sortie then logs tool server env value left on the command line at WARN, naming the server and the variable and never the value.

HTTP headers

A header on an HTTP server entry is delivered by variable name, never by value. Sortie sets SORTIE_MCP_HEADER_<n> in the app-server’s environment for each header, with <n> counting from 1 in byte order of server name and then header name, and the entry’s env_http_headers table maps each header to its variable. The bearer of Sortie’s own HTTP server uses the runtime’s bearer-token key instead, bearer_token_env_var, naming SORTIE_TOOL_SERVER_BEARER. A header value written into the inline table would sit on the app-server’s argument list, which any other user of the host can read. Every header value is delivered this way, so a header does not need to be in a variable of the orchestrator process first.

SSH mode delivers over HTTP

A remote session receives the overrides on the SSH session’s standard input, beside the variables the launch carries, rather than on the local ssh process’s argument list. They depend on the outcome of the launch’s reverse forward, so they are rendered after it resolves. sortie-tools is an HTTP server at the forwarded address with its bearer carried by bearer_token_env_var. Servers from mcp_config that run as a local program (stdio) are left out. HTTP servers appear with their URLs and header references. See delivery over SSH.

A refused or unconfirmed forward starts the app-server without Sortie’s own override. The session declares a gap and its first-turn prompt carries no tool advertisement. The app-server connects to the tool server at thread/start, so session start waits up to read_timeout_ms for that first connection and declares a gap when none arrives.

Startup failures

The runtime reports each declared server’s startup outcome on its own notification. A failure status is logged at WARN naming the server and the reported reason, whenever it arrives: during the handshake, in the gap before the first turn starts, or during a turn. It fails neither the turn nor the session: a session that lost its tools this way still runs to completion, and the log is the only place that records it.

mcp_config

codex.mcp_config names an operator-supplied MCP server configuration file. The worker reads it, merges its servers with the sortie-tools entry into the generated copy, and the merged result is what this adapter translates, so an operator’s own servers reach a Codex session alongside Sortie’s. Over SSH only the HTTP servers reach it. A relative path resolves against the directory containing WORKFLOW.md. An unreadable path, a file that is not valid JSON, or a file already declaring a server named sortie-tools fails the attempt before the session starts.

These conditions fail the session with response_error when the merged configuration reaches the adapter, and the message names the offending server:

ConditionAlso fails on opencode
An entry carries neither command nor url, or carries both, or declares a type that contradicts the fields it carriesYes
An entry carries a key outside the modeled set: type, command, args, env, url, headers, enabledYes
A server name is not a valid bare segment of the runtime’s dotted-path override grammarNo

Codex also reads its own MCP server list from configuration files of its own, entirely outside anything this adapter writes; which files it consults, and under what trust conditions, is Codex’s to document. See the external references. Because the adapter runs the app-server with the per-issue workspace as its working directory, whatever project-scoped configuration behavior Codex has applies to that workspace like any other Codex working directory.


Key differences from other adapters

AspectClaude CodeCopilot CLICodex
Kindclaude-codecopilot-clicodex
Default commandclaudecopilotcodex app-server
Subprocess modelNew process per turnNew process per turnPersistent process across turns
ProtocolCLI flags + JSONL stdoutCLI flags + JSONL stdoutJSON-RPC 2.0 over stdin/stdout
Session ID sourceUUID generated by adapterUUID generated by adapterThread ID from thread/start response
Resume mechanism--resume <UUID> (new subprocess)--session-id <uuid> on the first turn, --resume <uuid> after; never --continuethread/resume (JSON-RPC) or automatic within session
Input token reportingProvisional per request from assistant events, settled by the result events’ model_usage_since_last_result; the session journal settles a turn they do not coverSession totals from session.usage_checkpoint lines on the stream, less the session’s position before the turn; the session-state journal settles a turn they leave open; see usage checkpointsFrom thread/tokenUsage/updated, less the thread’s history; from the rollout file when a turn ends unsettled
Model reportingFrom assistant events; each model_usage_since_last_result key counts as its own modelFrom the modelMetrics keys of each session.usage_checkpoint; a journal record names its own request’s modelFrom the thread-open response, updated on reroute
Permission mode--permission-mode or --dangerously-skip-permissions--autopilot + --no-ask-user + --allow-allapprovalPolicy: "never" (JSON-RPC param)
Sandbox enforcementNone at adapter levelNone at adapter levelRequested through sandbox on thread/start and sandboxPolicy on turn/start; enforcement is the app-server’s
Sortie’s toolsDerived config file on --mcp-config, an inline document over SSHDerived config file on --additional-mcp-config, an inline document over SSHGenerated servers re-expressed as -c mcp_servers.<name>=... overrides; over SSH they ride the session’s standard input; see MCP
AuthenticationNo preflight; the CLI reads the inherited environmentCOPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN / gh authCODEX_API_KEY, or credentials the app-server already holds
Inner turn limitclaude-code.max_turnscopilot-cli.max_autopilot_continuesNone (agent runs to completion per turn)

For Claude Code configuration, see Claude Code adapter reference. For Copilot CLI configuration, see Copilot CLI adapter reference.


External references


Related pages

Was this page helpful?