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.
| Field | Type | Default | Description |
|---|---|---|---|
kind | string | - | Must be "codex" to select this adapter. |
command | string or list | codex app-server | Path 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_turns | integer | 20 | Maximum Sortie turns per worker session. The orchestrator runs a turn up to this many times, re-checking tracker state after each turn. |
max_sessions | integer | 0 (unlimited) | Maximum completed worker sessions per issue before the orchestrator stops retrying. 0 disables the budget. |
max_concurrent_agents | integer | 10 | Global concurrency limit across all issues. |
turn_timeout_ms | integer | 3600000 (1 hour) | Total timeout for a single turn. The orchestrator cancels the turn when exceeded. |
read_timeout_ms | integer | 5000 (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_ms | integer | 300000 (5 minutes) | Maximum time between consecutive events before the orchestrator treats the session as stalled. 0 or negative disables stall detection. |
stop_grace_ms | integer | 5000 (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_ms | integer | 300000 (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: 300000codex 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.
| Field | JSON-RPC param | Type | Default | Description |
|---|---|---|---|---|
model | model (thread/start, turn/start) | string | (CLI default) | LLM model identifier, forwarded unchanged. See codex --help on your installed version for the accepted values. |
effort | effort (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_policy | approvalPolicy (thread/start) | string | never | When 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_sandbox | sandbox (thread/start) | string | workspaceWrite | Sandbox 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_policy | sandboxPolicy (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. |
personality | personality (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.
| Field | Controls | Scope |
|---|---|---|
agent.max_turns | Sortie’s orchestrator turn loop | How 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 for | What the adapter does |
|---|---|
| Consent to act, such as running a command or changing a file | Refuses in the form that lets the agent try another route, emits a notification, and the turn continues. |
| An answer only a person could give | Ends 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 every 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.
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
| Check | Condition | Message |
|---|---|---|
codex.approval_policy.interactive | codex.approval_policy is set to any value other than never | codex.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.
- Validates that the workspace path is a non-empty absolute path pointing to an existing directory.
- Resolves
commandfromPATH(splits on whitespace to extract the binary and its argument tokens). In SSH mode, resolves the localsshbinary instead. - On a local launch, reads the generated MCP configuration and appends one
-c/mcp_servers.<name>=<inline table>argument pair per declared server to the launch arguments. Skipped entirely in SSH mode. See MCP. - Launches the subprocess in the workspace path, with the full parent process environment, isolated in its own process group.
- Wires stdin, stdout, and stderr pipes, and reads stdout one line at a time (1 MB max line size).
- Initialize handshake: sends
initializerequest withclientInfoandcapabilities.experimentalApi: true. Waits for response. Sendsinitializednotification. - Authentication check: sends
account/read. If account is null andCODEX_API_KEYis set, performs API key login. See authentication. - Thread start: sends
thread/startwith model, cwd, approvalPolicy, and sandbox. RecordsthreadId. 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. - Resume path: if a session ID saved from a previous run is supplied, sends
thread/resumeinstead. Falls back tothread/startif resume fails. - The session records the thread ID as its session ID, and the subprocess PID as the agent’s process ID.
Errors:
| Condition | Error kind |
|---|---|
| Empty or non-existent workspace path | invalid_workspace_cwd |
| Workspace path is not a directory | invalid_workspace_cwd |
Agent binary not found in PATH | agent_not_found |
| Agent command is empty or whitespace-only | agent_not_found |
| SSH binary not found (SSH mode) | agent_not_found |
| Subprocess failed to start | port_exit |
| Session start’s own context ended before the subprocess started running | turn_cancelled |
| Pipe creation failed (stdin, stdout, stderr) | port_exit |
| Generated MCP configuration unreadable or not expressible | response_error |
The app-server exits before a startup handshake step is answered: the initialize exchange, the authentication check, thread/start, or thread/resume | port_exit, as the early exit report |
| Initialize handshake failed | response_error |
| Authentication failed | response_error |
| Thread start/resume failed | response_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.
- Builds
turn/startparams withthreadId, input (prompt as text),cwd, and optionallysandboxPolicy,model, andeffort, the last whenevercodex.effortis set. - Sends the request and waits for the matching response.
- Waits for event notifications from the app-server or for the turn to be cancelled, whichever happens first.
- Dispatches notifications by method name (see event stream).
- When the turn is cancelled, writes one best-effort
turn/interruptto the app-server’s stdin, sent independently of the turn’s own cancellation so the write itself still goes through, then keeps reading forread_timeout_msin case the app-server reports its ownturn/completed. Past that bound the turn ends as cancelled. - 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.
- 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.
- Sends the graceful shutdown signal to the process group (POSIX:
SIGTERM; Windows:CTRL_BREAK_EVENT). Waits up tostop_grace_ms, or until the orchestrator’s own deadline expires, whichever comes first. - Force-terminates the process tree if still running (POSIX:
SIGKILLto 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. - Closes the standard-output read end, unblocking any pending read of the app-server’s output.
- Waits up to two seconds for output reading to finish, then closes both read ends.
- 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. Unlike the Claude Code adapter, which derives its figures from the event stream and the turn’s terminal event, the Codex adapter reads a dedicated thread/tokenUsage/updated notification. For this kind’s declaration beside every other kind’s, see the usage reporting table.
Accumulation logic
tokenUsage.totalis thread-cumulative, so a resumed thread reports spend the current run did not incur. The adapter subtracts a baseline to recover this run’s own contribution: at the first notification matching the running turn, the baseline istotalminuslast.- Each later notification for the running turn reports
totalminus that baseline as the run-cumulative snapshot, emitted as onetoken_usageevent. - A notification whose
turnIdbelongs to another turn raises the baseline instead of emitting an event, so a foreign turn’s spend never lands in this run’s total. input_tokenscomes frominputTokens,output_tokensfromoutputTokens, andcache_read_tokensfromcachedInputTokens. The notification exposes no cache-write count, socache_write_tokensis0.total_tokensis computed asinput_tokens + output_tokensrather than read from the notification’s owntotalTokens.- A notification carrying no
tokenUsageobject emits no event and leaves the session’s measurement state untouched. A session that never receives one 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.
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.
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
- An
item/startednotification withtypeincommandExecution,fileChange,mcpToolCall, ordynamicToolCallrecords the tool name and a start time, keyed byitem.id. - An
item/completednotification looks up the matchingitem.id. When found, the adapter emits atool_resultevent carrying the tool name and how long the call took.
Tool error detail
Item-level tool errors are not extracted from event payloads. A tool_result event from this adapter carries the tool name and duration and never sets the error flag.
Error handling
Error category mapping
When turn/completed carries status: "failed", the turn.error.codexErrorInfo field classifies the failure:
codexErrorInfo | Error kind | Description |
|---|---|---|
Unauthorized | response_error | Invalid or expired API credentials. |
BadRequest | response_error | Malformed request. |
ContextWindowExceeded | turn_failed | Token limit exceeded. |
UsageLimitExceeded | turn_failed | API usage quota exhausted. |
SandboxError | turn_failed | Sandbox enforcement failure. |
HttpConnectionFailed | turn_failed | Upstream API connection failure. |
ResponseStreamConnectionFailed | turn_failed | SSE/WebSocket stream connection failure. |
ResponseStreamDisconnected | turn_failed | Mid-stream disconnect. |
ResponseTooManyFailedAttempts | turn_failed | Internal retry budget exhausted. |
InternalServerError | turn_failed | Server-side error. |
Other | turn_failed | Catch-all. |
| (unknown value) | turn_failed | Unrecognized error info defaults to turn_failed. |
Both response_error and turn_failed are 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.
| Condition | Error kind | Message |
|---|---|---|
| Stdout stream closed during turn | port_exit | subprocess stdout closed unexpectedly |
| Error reading stdout | port_exit | stdout read error: <detail> |
turn/start response error | turn_failed | turn/start error: <app-server message> |
No turn/start response arrives: the turn was cancelled, or the connection ended first | port_exit | turn/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.
| Path | Reports |
|---|---|
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/resume | Yes |
| A turn whose event stream closed, or whose stdout stream ended | Yes |
A turn/start call that failed after the connection had already stopped reading output | Yes |
A turn/start call that failed while output reading was still running | No |
| A malformed stdout line, which does not end the connection: reading continues afterward | No |
| Session stop | No |
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
- Session start resolves the local
sshbinary fromPATH. The agent command is stored for remote execution. - Builds the SSH connection arguments listed under SSH options.
- All JSON-RPC communication flows over the SSH tunnel’s stdin/stdout, including the API-key login described under authentication.
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:
| Option | Value | Purpose |
|---|---|---|
StrictHostKeyChecking | Configurable (default: accept-new) | Host key verification policy. Set via worker.ssh_strict_host_key_checking. Allowed values: accept-new, yes, no. |
BatchMode | yes | Disables interactive prompts (password, passphrase). |
ConnectTimeout | 30 | Connection timeout in seconds. |
ServerAliveInterval | 15 | Keepalive interval in seconds. |
ServerAliveCountMax | 3 | Number 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 mode | Mechanism | Notes |
|---|---|---|
| API key | CODEX_API_KEY in the Sortie process environment | Consumed only when account/read reports no account. The adapter forwards the value verbatim and never inspects it. |
| Credentials the runtime already holds | account/read returns a non-null account | The adapter performs no login and starts the thread. How those credentials were established is Codex’s to document. |
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.
On a local launch, session start appends one -c / mcp_servers.<name>=<inline table> argument pair per declared server 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. The runtime spawns each declared server itself over stdio, which makes sortie-tools a child of the app-server and the same sidecar every other kind reaches.
Environment values
For a stdio server, an environment entry whose variable the adapter’s own process already holds under the same value is delivered by name, through the runtime’s environment-passthrough key, and the runtime resolves it from the environment it hands the spawned server. Every other entry is rendered as a literal value inside the inline table. Credentials that Sortie already holds therefore travel by name and never appear on the command line the host’s process list exposes.
HTTP headers
A header on an HTTP server entry is delivered by variable name, never by value. The adapter looks through its own process environment for a variable holding that header’s value and renders the variable’s name into the entry’s header-passthrough key, leaving the runtime to resolve it. This is the same rule the SSH exclusion rests on: 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.
A header whose value is in none of those variables cannot be delivered that way, and the session fails with response_error naming the header but never its value. This is the one condition on which a file that works for claude-code, copilot-cli, and opencode fails here, because each of those three carries the header’s value as written. An operator moving a header-authenticated HTTP server onto Codex has to put that header’s value in an environment variable of the orchestrator process first.
SSH mode delivers nothing
A remote session receives no overrides at all. The overrides ride on the app-server’s own launch arguments, and an SSH launch has none of its own: the local process is ssh, and the agent command travels to the host inside a remote command string.
Writing the overrides into that string would place the configuration’s credential values on the local ssh process’s own argument list, where any other user of the orchestrator host can read them. The adapter delivers nothing rather than pay that price, so a Codex session on an SSH host reaches none of Sortie’s tools and its first-turn prompt carries no tool advertisement.
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 local Codex session alongside Sortie’s. 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.
Three more conditions fail the session with response_error when the merged configuration reaches the adapter, and the message names the offending server:
| Condition | Also fails on opencode |
|---|---|
An entry carries neither command nor url, or carries both, or declares a type that contradicts the fields it carries | Yes |
An entry carries a key outside the modeled set: type, command, args, env, url, headers, enabled | Yes |
| A server name is not a valid bare segment of the runtime’s dotted-path override grammar | No |
An HTTP entry’s headers carry a fourth condition of their own; see HTTP headers.
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
| Aspect | Claude Code | Copilot CLI | Codex |
|---|---|---|---|
| Kind | claude-code | copilot-cli | codex |
| Default command | claude | copilot | codex app-server |
| Subprocess model | New process per turn | New process per turn | Persistent process across turns |
| Protocol | CLI flags + JSONL stdout | CLI flags + JSONL stdout | JSON-RPC 2.0 over stdin/stdout |
| Session ID source | UUID generated by adapter | UUID generated by adapter | Thread ID from thread/start response |
| Resume mechanism | --resume <UUID> (new subprocess) | --session-id <uuid> on the first turn, --resume <uuid> after; never --continue | thread/resume (JSON-RPC) or automatic within session |
| Input token reporting | Per-request, from the result event’s per-model breakdown | Recovered from the runtime’s session-state journal after exit | From thread/tokenUsage/updated, baseline-subtracted |
| Model reporting | From assistant events | From assistant.message/model.message records | From the thread-open response, updated on reroute |
| Permission mode | --permission-mode or --dangerously-skip-permissions | --autopilot + --no-ask-user + --allow-all | approvalPolicy: "never" (JSON-RPC param) |
| Sandbox enforcement | None at adapter level | None at adapter level | Requested through sandbox on thread/start and sandboxPolicy on turn/start; enforcement is the app-server’s |
| Sortie’s tools | Generated config path on --mcp-config | Generated config path on --additional-mcp-config | Generated servers re-expressed as -c mcp_servers.<name>=... overrides, local launch only; see MCP |
| Authentication | No preflight; the CLI reads the inherited environment | COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN / gh auth | CODEX_API_KEY, or credentials the app-server already holds |
| Inner turn limit | claude-code.max_turns | copilot-cli.max_autopilot_continues | None (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
- Codex Documentation: official OpenAI documentation site for the Codex CLI
openai/codexon GitHub: Codex CLI source repository, releases, and issue tracker- Codex
config.md: sandbox modes, approval policies, and other settings this adapter forwards via JSON-RPC params - JSON-RPC 2.0 specification: wire format used over Codex’s stdin/stdout
- OpenAI API authentication: the
CODEX_API_KEYcredential format - Model Context Protocol specification: the protocol behind the
mcpToolCallitems in the event stream and themcpServer/elicitation/requestapproval request
Related pages
- Jira + Codex end-to-end tutorial: step-by-step walkthrough from Jira issue to pushed branch
- WORKFLOW.md configuration reference: full
agentschema andcodexextension block - Environment variables reference:
CODEX_API_KEYand related variables - Error reference: all agent error kinds with retry behavior
- How to control agent costs: session caps, turn caps, concurrency limits, and model selection
- How to write a prompt template: template variables, conditionals, and built-in functions
- How to scale agents with SSH: remote execution setup and host pool configuration
- State machine reference: orchestration states, turn lifecycle, and stall detection
Was this page helpful?