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

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. 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.
  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. 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

  1. tokenUsage.total is 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 is total minus last.
  2. Each later notification for the running turn reports total minus that baseline as the run-cumulative snapshot, emitted as one token_usage event.
  3. A notification whose turnId belongs to another turn raises the baseline instead of emitting an event, so a foreign turn’s spend never lands in this run’s total.
  4. input_tokens comes from inputTokens, output_tokens from outputTokens, and cache_read_tokens from cachedInputTokens. The notification exposes no cache-write count, so cache_write_tokens is 0. total_tokens is computed as input_tokens + output_tokens rather than read from the notification’s own totalTokens.
  5. A notification carrying no tokenUsage object 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

  1. An item/started notification with type in commandExecution, fileChange, mcpToolCall, or dynamicToolCall records the tool name and a start time, keyed by item.id.
  2. An item/completed notification looks up the matching item.id. When found, the adapter emits a tool_result event 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:

codexErrorInfoError kindDescription
Unauthorizedresponse_errorInvalid or expired API credentials.
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.

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.

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.

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:

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.

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:

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

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

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 reportingPer-request, from the result event’s per-model breakdownRecovered from the runtime’s session-state journal after exitFrom thread/tokenUsage/updated, baseline-subtracted
Model reportingFrom assistant eventsFrom assistant.message/model.message recordsFrom 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 toolsGenerated config path on --mcp-configGenerated config path on --additional-mcp-configGenerated servers re-expressed as -c mcp_servers.<name>=... overrides, local launch only; 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?