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 domain types. Registered under kind "codex".

Unlike the Claude Code and Copilot CLI adapters, the Codex adapter uses a persistent subprocess model. StartSession launches the process and keeps it alive across turns. Each RunTurn 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.
commandstringcodex app-serverPath or name of the Codex binary with arguments. Resolved via exec.LookPath at session start. The first space-separated token is the binary name; remaining tokens are arguments.
max_turnsinteger20Maximum Sortie turns per worker session. The orchestrator calls RunTurn 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 RunTurn call. The orchestrator cancels the turn context when exceeded.
read_timeout_msinteger5000 (5 seconds)Timeout for synchronous operations during StartSession (initialize, account/read, thread/start responses). Defaults to 30 seconds internally if not set.
stall_timeout_msinteger300000 (5 minutes)Maximum time between consecutive events before the orchestrator treats the session as stalled. 0 or negative disables stall detection.
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. The orchestrator forwards them to the adapter without validation. Each field maps to a JSON-RPC parameter on thread/start or turn/start.

FieldJSON-RPC paramTypeDefaultDescription
modelmodel (thread/start, turn/start)string(CLI default)LLM model identifier (e.g., o3, gpt-5.4).
efforteffort (turn/start)string(CLI default)Reasoning effort level. Values: low, medium, high.
approval_policyapprovalPolicy (thread/start)stringneverApproval behavior for tool calls. Values: never, onRequest, unlessTrusted, always.
thread_sandboxsandbox (thread/start)stringworkspaceWriteSandbox mode for the thread. Values: readOnly, workspaceWrite, dangerFullAccess, externalSandbox.
turn_sandbox_policysandboxPolicy (turn/start)map(see below)Per-turn sandbox policy override. Merged on top of the default policy.
personalitypersonality (thread/start)string(none)Personality preset.
skip_git_repo_check(adapter-level)booleanfalseReserved for future use. Codex requires the workspace to be a Git repository.
codex:
  model: o3
  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 RunTurn call 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 invokes RunTurn 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". This auto-approves all tool calls within the workspace sandbox boundary.

The default sandboxPolicy sent on turn/start sets type to workspaceWrite, writableRoots to the workspace path, and networkAccess to false. Operator overrides from turn_sandbox_policy are merged on top.

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.

Session lifecycle

StartSession

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

  1. Validates that WorkspacePath is a non-empty absolute path pointing to an existing directory.
  2. Resolves the command via exec.LookPath (splits on first space to extract binary and arguments). In SSH mode, resolves the local ssh binary instead.
  3. Launches the subprocess with cmd.Dir set to the workspace path and cmd.Env set to the full parent process environment. Process group isolation via procutil.SetProcessGroup.
  4. Wires stdin, stdout, and stderr pipes. Starts a background scanner goroutine on stdout (1 MB max line size).
  5. Initialize handshake: sends initialize request with clientInfo and capabilities.experimentalApi: true. Waits for response. Sends initialized notification.
  6. Authentication check: sends account/read. If account is null and CODEX_API_KEY is set, performs API key login. See authentication.
  7. Thread start: sends thread/start with model, cwd, approvalPolicy, sandbox, and dynamicTools (from ToolRegistry). Records threadId.
  8. Resume path: if ResumeSessionID is non-empty, sends thread/resume instead. Falls back to thread/start if resume fails.
  9. Returns a Session with ID set to the thread ID and AgentPID set to the subprocess PID.

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
Pipe creation failed (stdin, stdout, stderr)port_exit
Initialize handshake failedresponse_error
Authentication failedresponse_error
Thread start/resume failedresponse_error

RunTurn

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.
  2. Sends the request and waits for the matching response.
  3. Enters the event loop, selecting on the message channel and context cancellation.
  4. Dispatches notifications by method name (see event stream).
  5. On context cancellation, sends turn/interrupt using a detached 2-second context.
  6. On turn/completed, emits final token_usage event and returns TurnResult.
  7. Waits for in-flight dynamic tool call goroutines to complete before returning.

StopSession

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

  1. Signals the reader goroutine to stop. Closes the stdin pipe.
  2. Sends SIGTERM to the process group. Waits up to 5 seconds.
  3. Force-kills via SIGKILL if still running.
  4. Waits for the reader goroutine to finish.

EventStream

Returns nil. The adapter delivers all events synchronously through the OnEvent callback in RunTurn.


Process shutdown

Because the subprocess persists across turns, StopSession handles shutdown rather than RunTurn. The shutdown sequence closes stdin (EOF signal), sends SIGTERM to the process group, waits up to 5 seconds, then escalates to SIGKILL. On Windows, a Job Object with KILL_ON_JOB_CLOSE terminates the process tree on shutdown or crash.

RunTurn handles context cancellation by sending turn/interrupt via JSON-RPC, allowing the app-server to complete gracefully.


Event stream

The Codex app-server emits JSON-RPC 2.0 notifications on stdout (JSONL). The adapter parses each line, discriminates between responses (non-zero id, no method) and notifications (method present), and maps notifications to normalized domain events.

Event type mapping

App-server notificationItem type / conditionDomain event typeNotes
turn/startedFirst turnsession_startedCaptures thread ID and agent PID.
turn/startedSubsequent turnsnotification
turn/completedstatus: "completed"turn_completed
turn/completedstatus: "failed"turn_failedIncludes error message from turn.error.
turn/completedstatus: "interrupted"turn_cancelled
turn/plan/updated-notificationAgent plan update.
turn/diff/updated-(debug log only)Not emitted as domain event.
item/startedcommandExecution, fileChange, mcpToolCall, dynamicToolCallnotificationRecords tool name and timestamp in in-flight map.
item/startedOther typesnotification
item/completedMatching in-flight tooltool_resultIncludes ToolName and ToolDurationMS.
item/completedagentMessage with textnotificationText truncated to 200 characters.
item/agentMessage/delta-notificationStall timer reset. No payload.
item/commandExecution/outputDelta-notificationStall timer reset. No payload.
item/tool/callTool found in registrytool_resultDispatched asynchronously. See tool call tracking.
item/tool/callTool not foundunsupported_tool_callError response sent to app-server.
(parse failure)-(logged)Malformed JSONL line logged at debug level.
(unknown method)-other_messageUnrecognized notification method.

Turn completion fields

The turn/completed notification carries the final turn state:

Field pathTypeDescription
turn.idstringTurn identifier.
turn.statusstring"completed", "failed", or "interrupted".
turn.error.messagestringError description (present when status is "failed").
turn.error.codexErrorInfostringError category for retry classification. See error handling.
usage.input_tokensintegerTotal input tokens (includes cached).
usage.output_tokensintegerOutput tokens generated.
usage.cached_input_tokensintegerCached input tokens (subset of input_tokens).

Token accounting

Token usage is reported per turn in the turn/completed notification. Unlike the Claude Code adapter, which accumulates per-request usage across multiple assistant events, the Codex adapter receives a single usage snapshot at turn completion.

Accumulation logic

  1. The turn/completed notification includes a usage object with input_tokens, output_tokens, and cached_input_tokens.
  2. total_tokens is computed as input_tokens + output_tokens. cache_read_tokens is set from cached_input_tokens.
  3. A single token_usage event is emitted after each turn. If usage is absent, all token fields are 0.

Model tracking

The adapter does not extract a model name from event payloads. The Model field on token_usage events is empty. The model is configured via codex.model in WORKFLOW.md but not echoed in turn events.

API timing

The adapter does not track per-request API latency. No APIDurationMS field is populated. For observability, use the Codex CLI’s built-in OpenTelemetry export.


Tool call tracking

The adapter tracks two categories of tool execution: item-level tools (commands, file changes, MCP calls) and dynamic tool calls (tracker_api and other registry tools).

Item-level correlation

  1. An item/started notification with type in commandExecution, fileChange, mcpToolCall, or dynamicToolCall records the tool name and a monotonic timestamp in an in-flight map, keyed by item.id.
  2. An item/completed notification looks up the matching item.id. When found, the adapter emits a tool_result event with ToolName and ToolDurationMS.

Dynamic tool dispatch

When the app-server sends an item/tool/call JSON-RPC request (with both method and id), the adapter looks up the tool in the ToolRegistry and executes it asynchronously in a goroutine. The goroutine acquires state.mu before writing the JSON-RPC response to stdin. If the tool is not registered, an error response is sent immediately and unsupported_tool_call is emitted. A sync.WaitGroup tracks in-flight tool goroutines; RunTurn waits for all to complete before returning.

Tool error detail

Dynamic tool errors include the error message from tool.Execute. Item-level tool errors are not extracted from event payloads.


Error handling

Error category mapping

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

codexErrorInfoError kindRetryableDescription
Unauthorizedresponse_errorNoInvalid or expired API credentials.
BadRequestresponse_errorNoMalformed request.
ContextWindowExceededturn_failedNoToken limit exceeded.
UsageLimitExceededturn_failedNoAPI usage quota exhausted.
SandboxErrorturn_failedNoSandbox enforcement failure.
HttpConnectionFailedturn_failedYesUpstream API connection failure.
ResponseStreamConnectionFailedturn_failedYesSSE/WebSocket stream connection failure.
ResponseStreamDisconnectedturn_failedYesMid-stream disconnect.
ResponseTooManyFailedAttemptsturn_failedYesInternal retry budget exhausted.
InternalServerErrorturn_failedYesServer-side error.
Otherturn_failedYesCatch-all.
(unknown value)turn_failedYesUnrecognized error info defaults to turn_failed.

Process exit handling

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

ConditionError kind
Stdout channel closed during turnport_exit
Stdout scanner errorport_exit
turn/start response errorturn_failed
Context cancelled before responseport_exit

Stdout reader failure

If the reader goroutine encounters an error or EOF, it delivers the error to the message channel. RunTurn emits turn_failed and returns with error kind port_exit.


Session resume mechanism

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

Across sessions (after an orchestrator restart), 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 (e.g., thr_abc123), assigned by the app-server in the thread/start response.


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. StartSession resolves the local ssh binary via exec.LookPath. The agent command is stored for remote execution.
  2. Prefixes CODEX_API_KEY inline in the remote command if set, since OpenSSH does not forward local environment variables.
  3. Constructs SSH arguments via sshutil.BuildSSHArgs.
  4. All JSON-RPC communication flows over the SSH tunnel’s stdin/stdout.

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

All arguments in the remote command string are single-quoted with embedded single-quote escaping ('\''). The CODEX_API_KEY value is quoted using the same mechanism.

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 and maps to agent_not_found.


Authentication

Sortie does not manage Codex CLI credentials. The adapter spawns the subprocess with the full parent process environment (cmd.Env = os.Environ()), and the Codex CLI reads its authentication variables directly.

Authentication sequence at StartSession: 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 key (recommended for CI)CODEX_API_KEY environment variableStandard OpenAI API key. Billed at API rates.
ChatGPT managedBrowser-based OAuth via codex loginRequires prior interactive login; credentials cached in ~/.codex/auth.json.
CODEX_API_KEY must be set in the Sortie process environment. The adapter does not prompt for credentials. In SSH mode, CODEX_API_KEY is injected inline in the remote command because OpenSSH does not forward local environment variables by default.

Concurrency safety

The adapter is safe for concurrent use. One CodexAdapter instance serves all sessions. Per-session state (workspace path, thread ID, subprocess handle, stdin/stdout pipes) is isolated in the opaque Session.Internal field.

A mutex (state.mu) guards the subprocess handle, stdin pipe, and stdout pipe for concurrent access from StopSession and dynamic tool call goroutines that write JSON-RPC responses to stdin. Within a session, RunTurn calls are serialized by the orchestrator.


Adapter registration

The adapter registers itself under kind "codex" via an init function in internal/agent/codex. Registration metadata declares:

PropertyValue
RequiresCommandtrue

The orchestrator’s preflight validation uses this to produce a specific error message if the binary cannot be found before attempting session creation.


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 adapterDiscovered from result eventThread ID from thread/start response
Resume mechanism--resume <UUID> (new subprocess)--resume <sessionId> or --continuethread/resume (JSON-RPC) or automatic within session
Input token reportingPer-request cumulativeNot available (always 0)Per-turn from turn/completed
Model reportingFrom assistant eventsNot availableNot available
Permission mode--permission-mode or --dangerously-skip-permissions--autopilot + --no-ask-user + --allow-allapprovalPolicy: "never" (JSON-RPC param)
Sandbox enforcementNone (external container)None (external container)OS-level (Seatbelt/bwrap/seccomp) + configurable policies
Dynamic tools--mcp-config (MCP sidecar)--additional-mcp-config (MCP sidecar)dynamicTools on thread/start (no sidecar)
AuthenticationANTHROPIC_API_KEY (+ Bedrock, Vertex)COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN / gh authCODEX_API_KEY or ~/.codex/auth.json
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.


Related pages

Was this page helpful?