Skip to content
OpenCode CLI Adapter

OpenCode CLI Adapter

The OpenCode adapter connects Sortie to the OpenCode CLI via subprocess management. It runs OpenCode 2.x, published on npm as @opencode/cli, and checks at the start of every session that agent.command reports a 2.x version; see version check. It launches opencode run --format json, reads newline-delimited stdout envelopes, reads the runtime’s permission warnings from stderr, and normalizes the stream into Sortie’s own event vocabulary. Registered under kind "opencode".

Each turn spawns a fresh subprocess. The adapter emits activity-visible events so the orchestrator stall watchdog can observe progress. Session start does run a version query (see version check), but that query checks no credential and performs no authentication preflight. The CLI accepts no MCP configuration path, so on a local launch the adapter translates the generated configuration into OpenCode’s own form and delivers it in the turn’s environment; see MCP.

See also: WORKFLOW.md configuration for the full agent schema, environment variables for runtime environment behavior, error reference for all agent error kinds, how to write a prompt template for template authoring.


Configuration

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

Version check

Session start runs the configured command with --version, bounded the same way the post-turn export launch is (see read_timeout_ms below), and reads only the first non-empty line of the output. It scans that line’s whitespace-separated fields, dropping one leading v from each, for the first one that matches a bare semantic version, and requires that version’s leading number to be 2.

A version on a later line is never found: if the first non-empty line carries no such field, or the output was truncated, the check fails. The check runs once per session, over SSH when the launch is remote, and is never repeated or cached. A later major than 2 fails the check like any other, so install @opencode/cli@2 to stay on a supported major.

Errors:

ConditionError kindMessage
The version query could not be startedresponse_errorcould not start the agent runtime to read its version
Session start’s own context ended before the query’s process started runningturn_cancelledsession start cancelled
Session start’s own context ended before the query finishedresponse_errorthe session start ended while the agent runtime was reporting its version
The version query did not finish within its boundresponse_timeoutthe agent runtime did not report its version within <N> ms
The command exited unexpectedlyport_exitAn SSH connection failure on a remote launch, otherwise the early exit report
The command’s output carries no version Sortie can readagent_not_foundthe configured OpenCode command reported no version Sortie can read: <line or "no output">
The reported major is not 2agent_not_foundOpenCode <version> is not supported; install OpenCode 2.x, published on npm as @opencode/cli

agent section

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

FieldTypeDefaultDescription
kindstring-Must be "opencode" to select this adapter.
commandstring or listopencodePath or name of the OpenCode binary. Resolved from PATH at session start. agent.command reaches this kind only when it is the default kind; a rule-routed opencode session beside another default kind runs opencode itself. See agent.command for the list form.
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 sessions per issue before the orchestrator stops retrying. 0 disables the budget.
max_concurrent_agentsinteger10Global concurrency limit across all issues.
max_concurrent_agents_by_statemap{}Per-state concurrency limits. Keys are state names, lowercased for matching. See agent.max_concurrent_agents_by_state for how an invalid entry is handled.
turn_timeout_msinteger3600000 (1 hour)Total timeout for a single turn. The orchestrator cancels the turn when exceeded.
read_timeout_msinteger5000 (5 seconds)Bounds the wait for the turn’s first JSON envelope, which is never shorter than 60 seconds, so a value below 60000 does not shorten it. OpenCode prints nothing until the model starts answering, so the wait carries the provider’s latency. The same wait applies to the turn of a credential-verification session. Doubled and capped at 30 seconds, this value also bounds the version query every session start makes, the post-turn export subprocess, and, on a credential-verification session, the session delete cleanup call; those three have no 60-second minimum. It does not bound anything after the first envelope arrives. Falls back to 30 seconds when unset or not positive.
stall_timeout_msinteger300000 (5 minutes)Maximum time between consecutive emitted events before the orchestrator treats the turn 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: opencode
  command: opencode
  max_turns: 5
  max_sessions: 3
  max_concurrent_agents: 4
  stall_timeout_ms: 300000
  max_concurrent_agents_by_state:
    in progress: 3
    to do: 1

opencode extension section

These fields are adapter-specific. Each maps to a CLI flag or to a member of the inline configuration document the adapter delivers in OPENCODE_CONFIG_CONTENT; see managed environment.

FieldTypeDefaultDescription
modelstring(CLI default)Value forwarded to --model. A non-empty effort or variant is appended after a #.
agentstring(none)OpenCode agent name forwarded to --agent.
effortstring(none)Reasoning level, carried on every turn, credential verification included, in OpenCode’s one model-variant slot. See adapter pass-through configuration for how an unset value is read. Folded into model as a #-separated suffix, so it needs an opencode.model that carries no # of its own; see settings refused at session start. Setting it together with variant is an error; see validate-time checks.
variantstring(none)Fills the same slot as effort, and setting both is an error. Folded into model as a #-separated suffix under the same requirements as effort.
thinkingbooleanfalseAdds --thinking.
dangerously_skip_permissionsbooleantrueAdds --dangerously-skip-permissions when true. When false, the runtime refuses every permissioned tool call instead of performing it, and access outside the workspace is refused unless a tool list names external_directory; see validate-time checks and access outside the workspace.
disable_autocompactbooleantrueWhen true, sets the inline configuration document’s compaction.auto to false.
allowed_toolslist of strings[]Builds an allowlist policy: listed keys become allow, every known key not listed becomes deny, unknown keys are forwarded unchanged. The policy rides in the inline configuration document’s permission member.
denied_toolslist of strings[]Adds deny entries to the same policy allowed_tools builds. Overlap with allowed_tools is refused at session start; see settings refused at session start.
mcp_configstring(none; read by the worker)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 into OpenCode’s own server entries on a local launch. See MCP.

The adapter always adds run --format json --standalone, and delivers the prompt on standard input.

opencode:
  model: <provider>/<model-id>
  effort: high
  dangerously_skip_permissions: true
  disable_autocompact: true
  allowed_tools:
    - read
    - edit
    - glob

Settings refused at session start

Session start refuses the settings below before it launches anything. Each fails with error kind agent_not_found and starts no subprocess.

ConditionMessage
allowed_tools and denied_tools name at least one of the same keysallowed_tools and denied_tools overlap: <keys>, with the shared keys sorted and comma-separated
opencode.pure is trueopencode.pure is not supported by OpenCode 2.x; remove it
opencode.effort or opencode.variant is set without opencode.modelopencode.<key> needs opencode.model on OpenCode 2.x; set opencode.model or remove opencode.<key>, with <key> the one that is set, effort or variant
opencode.model already carries a #-separated suffix and opencode.effort or opencode.variant is also setopencode.model already names a variant after #; remove that suffix or remove opencode.<key>
opencode.effort and opencode.variant are both setopencode.effort and opencode.variant both set the model variant; set one of them

The overlap and the effort/variant conflict are also validate-time checks.

agent.max_turns vs. OpenCode inner turn scope

The adapter exposes no OpenCode-specific inner turn or step-budget field.

FieldControlsScope
agent.max_turnsSortie’s orchestrator turn loopHow many times the orchestrator runs a turn per worker session.
(none)OpenCode inner turn budgetThe adapter does not expose an OpenCode equivalent to claude-code.max_turns or copilot-cli.max_autopilot_continues. Each turn executes one opencode run process and lets the CLI run until it exits.

Use turn_timeout_ms to bound wall-clock time for a single turn. There is no adapter-level cap on OpenCode’s internal step count within that turn.

Permission policy

The adapter synthesizes one managed permission policy from allowed_tools and denied_tools and injects it as the permission member of the inline configuration document (see managed environment). The policy is separate from the --dangerously-skip-permissions flag, but with dangerously_skip_permissions: false the adapter also writes a policy when both lists are empty; see access outside the workspace.

InputAdapter behavior
No allowed_tools, no denied_toolsWith dangerously_skip_permissions: true, sets no policy, and OpenCode falls back to on-disk config and its own defaults. With false, sets only external_directory to ask.
allowed_tools onlySets each listed key to allow, then sets every known key not listed to deny.
denied_tools onlySets the listed keys to deny. With dangerously_skip_permissions: false and external_directory not listed, it also sets external_directory to ask. Other keys fall through to OpenCode defaults or operator config.
Both fields presentStarts with the allowlist behavior above, then applies deny overrides from denied_tools.
Overlap between the two fieldsSession start fails.
Unknown permission keyForwards the key verbatim and logs it at debug level.

The adapter’s known permission-key set is:

KeyIncluded in blanket deny when allowed_tools is non-empty
bashYes
codesearchYes
doom_loopYes
editYes
external_directoryYes
globYes
grepYes
listYes
lspYes
opencode_list_mcp_resourcesYes
opencode_modelsYes
opencode_read_mcp_resourceYes
opencode_session_moveYes
opencode_session_renameYes
questionYes
readYes
skillYes
taskYes
todowriteYes
webfetchYes
websearchYes

This is the set this version of the adapter knows about, not a catalogue of OpenCode’s tools. A key OpenCode adds later is unknown to the adapter until the adapter learns it, and an unknown key you write is forwarded unchanged. The adapter forwards every key unchanged, so a key takes effect only where the runtime itself recognizes it.

Access outside the workspace

With dangerously_skip_permissions: false, the agent cannot read or write files outside the session workspace unless you allow it. The runtime refuses the access and reports it to the model like any other refused tool call. To allow it, name external_directory in allowed_tools.

A non-empty allowed_tools denies every known key it does not list, so list each tool the agent needs (read, edit, bash, and so on) alongside external_directory. The other way to allow it is dangerously_skip_permissions: true, which approves every permissioned call and not only this one.

The adapter decides external_directory as follows:

ConfigurationAction written for external_directory
dangerously_skip_permissions: trueNone. The adapter writes no external_directory rule of its own.
false, neither list names the key, allowed_tools emptyask, which the runtime refuses because no one can answer.
false, allowed_tools non-empty and not naming the keydeny, from the blanket deny described above.
false, allowed_tools names the keyallow.
false, denied_tools names the keydeny.

On a local launch, an external_directory rule in your own OpenCode configuration files does not apply in this mode, because the adapter writes a value for the key and its document loads last; see managed environment. On an SSH worker, an OPENCODE_PERMISSION value set in the remote login environment is applied after the adapter’s document and wins for every key it names.

Managed environment

The adapter sets one environment variable on every subprocess it launches, the version check, the usage export, and the credential-verification session delete included:

VariableValue
OPENCODE_DISABLE_AUTOUPDATEtrue

Sharing, compaction, the permission policy, and tool servers ride together in one inline configuration document, delivered through OPENCODE_CONFIG_CONTENT on every turn, local or remote, whether or not the session carries tool servers.

share is always "disabled". compaction.auto is false only when opencode.disable_autocompact is true. permission carries the policy above and mcp carries the translated tool servers, a member present on a local launch only; see MCP. agent.title.disable is true, the title-agent switch described in Token accounting.

A remote turn carries the same document without mcp. The version check, usage export, and session delete invocations never carry it, on either kind of launch.

On a local launch, before adding its managed values, the adapter strips OPENCODE_AUTO_SHARE, OPENCODE_DISABLE_AUTOCOMPACT, OPENCODE_DISABLE_AUTOUPDATE, OPENCODE_DISABLE_LSP_DOWNLOAD, OPENCODE_PERMISSION, and OPENCODE_CONFIG_CONTENT out of the inherited environment, so an operator-side value never reaches the subprocess. It does not remove permission rules from opencode.json, so OpenCode still deep-merges the adapter policy with on-disk configuration, the adapter’s own document loading last.

On a remote launch the adapter strips nothing. It sends its own OPENCODE_CONFIG_CONTENT, which replaces any value of that name in the remote login environment, and leaves every other variable there, OPENCODE_PERMISSION included, as it finds it.


Validate-time checks

When agent.kind is opencode, the sortie validate pipeline runs OpenCode-specific config checks in addition to the generic preflight validation. They build no adapter and launch no subprocess, and the same checks run at startup and on every workflow reload, so the verdict is identical in all three places.

Errors

CheckConditionMessage
opencode.allowed_tools.overlapallowed_tools and denied_tools name at least one of the same keysallowed_tools and denied_tools overlap: <keys>
opencode.effort.conflicteffort and variant are both set to a non-empty valueopencode.effort and opencode.variant both set the model variant; set one of them

Session start reports each of these with the same message, so the two paths can never disagree.

Warnings

CheckConditionMessage
opencode.dangerously_skip_permissions.auto_rejectdangerously_skip_permissions is explicitly falseopencode.dangerously_skip_permissions is set to false, so the runtime refuses every permissioned tool call instead of performing it

This is a warning rather than an error. Warnings leave valid true and the exit code 0. The runtime refuses the request itself, so the setting never leaves a turn waiting for a person; it does stop the agent from using any permissioned tool. A refusal reaches the model as an errored tool result, and the turn continues. An absent or true value draws nothing.


Session lifecycle

Session start

Refuses unusable settings, validates the workspace path, resolves the launch target, checks the OpenCode version, and initializes adapter-owned session state. No turn subprocess is started, but the version query below does start and exit a subprocess of its own.

  1. Refuses the settings listed under settings refused at session start.
  2. Validates that the workspace path is a non-empty absolute path pointing to an existing directory.
  3. Resolves the configured command from PATH, defaulting to opencode when agent.command is empty. In SSH mode, resolves the local ssh binary instead and stores the remote command string for later use.
  4. On a local launch, reads the generated MCP configuration and parses it into OpenCode’s own server-entry shape, holding the result for every turn of the session. Skipped entirely in SSH mode. See MCP.
  5. Queries the resolved command’s version, over SSH when the launch is remote, and refuses the session when it is unreadable or not a 2.x release; see version check.
  6. Builds the inline configuration document every turn of the session will carry, from the settings and the servers parsed in step 4.
  7. The session has no running process ID or subprocess yet, and takes the session ID saved from a previous run as its own when continuation is requested.

Session start performs no provider-auth probe.

Errors:

ConditionError kind
Empty or non-existent workspace pathinvalid_workspace_cwd
Workspace path is not a directoryinvalid_workspace_cwd
Agent command is empty or whitespace-onlyagent_not_found
Local OpenCode binary not found in PATHagent_not_found
SSH binary not found (SSH mode)agent_not_found
Generated MCP configuration unreadable or not expressibleresponse_error
A refused settingagent_not_found; see settings refused at session start
Version query fails, times out, or reports a version other than 2.xSee version check for the full set of conditions, kinds, and messages

Turn

Spawns one OpenCode subprocess, reads its stdout, and delivers normalized events to the orchestrator.

  1. Builds the managed environment and the per-turn argument list.
  2. Adds run --format json --standalone to every invocation and delivers the prompt on the subprocess’s standard input. --standalone keeps the launch from joining a shared background service another client started.
  3. Adds --session <id> when the session already has an OpenCode session ID.
  4. Launches the subprocess locally or through SSH, with the full parent process environment plus the managed environment, which includes OPENCODE_CONFIG_CONTENT on every turn. On a local launch, PWD in that environment names the same verified workspace path the subprocess’s own working directory is set to, so the subprocess lands in the workspace.
  5. Isolates the subprocess in its own process group before start, then arms a graceful process-group signal for cancellation, bounded by stop_grace_ms.
  6. Reads stdout and stderr concurrently while waiting for the subprocess to exit.
  7. Applies a startup timer set to the larger of read_timeout_ms and 60 seconds. A stdout line that fails to parse as a JSON envelope still resets the timer before the first accepted envelope arrives, but does not itself count as the runtime having responded; see early exit report.
  8. On the first JSON envelope with sessionID, adopts the session ID if unset or verifies it matches the resumed session. Emits session_started once per session.
  9. Maps JSON envelopes and tolerated plain-text lines into events.
  10. Once the subprocess has been reaped, gives stderr collection up to five seconds to finish, then gives whatever stdout is still in flight another five seconds to arrive, before recovering final token usage with opencode session export --standalone --sanitize <sessionID>.
  11. The turn ends based on the terminal error envelope, cancellation state, startup timeout, or process exit status.

A turn that instead ends by cancellation, a read timeout, or a stdout read error still attempts this export before reporting its outcome; see Accumulation logic for the one ending that does not.

Session stop

Marks the session closed and terminates the currently running turn subprocess, if any.

  1. Marks the session closed and detaches the active turn runtime from session state.
  2. Sends a graceful process-group signal when a turn is still running.
  3. Waits up to stop_grace_ms for the subprocess to exit.
  4. Force-kills the process group if it is still alive after the grace window.
  5. Reports the orchestrator’s own deadline error instead of a clean stop if that deadline expires first.
  6. On a credential-verification session that reached a session ID, runs opencode session delete --standalone <id> in the workspace before returning. A failed delete, including exit 1 for an identifier the runtime does not recognize, is only logged; it never fails the run.

Safe to call when no subprocess is active.


Process shutdown

Before start, the subprocess is isolated in its own process group. A graceful process-group signal is armed for cancellation, bounded by stop_grace_ms. On Unix, graceful shutdown is SIGTERM and force kill is SIGKILL to the process group.

On Windows, graceful shutdown is CTRL_BREAK_EVENT to the process group, and the subprocess is assigned to a Job Object with KILL_ON_JOB_CLOSE so force termination kills the full descendant tree.

The subprocess starts suspended and is resumed only after that assignment succeeds, so nothing it spawns can run before the job takes effect; this covers the auxiliary launches described below, not only turns. A failed assignment logs WARN process group assignment failed and the launch runs without a job; a failed resume logs WARN process resume failed and reports port_exit. A failure to start the subprocess or to create its stdout or stderr pipe reports port_exit too.

A launch 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 and logs no resume warning. A turn ends this way as turn_cancelled.

Shutdown is turn-scoped, not session-scoped. Session stop performs an explicit graceful-to-force sequence. Turn cancellation is stricter: when the turn is cancelled, the graceful signal fires immediately, and the adapter’s cancellation path also force-kills the process group during teardown if the process is still alive. After the subprocess exits, the adapter performs a best-effort group kill to clean up surviving children.


Event stream

The adapter reads stdout as newline-delimited envelopes. Most lines are JSON objects from opencode run --format json. Permission rejection warnings can also appear as plain text on stdout even in JSON mode. Reading stdout allows lines up to 10 MB long, to accommodate large tool payloads.

What the adapter emits

The adapter maps each envelope 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. OpenCode’s own envelope types and their fields are OpenCode’s to define; see external references.

Two behaviours are the adapter’s own. Every stdout line that fails to parse becomes a malformed event, truncated, rather than failing the turn, and it still resets the startup read timer, but it does not count as the runtime having responded; see early exit report. A permission request the runtime refuses surfaces twice, as a tool_result carrying the tool error and as a notification; no consent was granted, and the refusal does not end the turn. Sortie scans stderr for those refusals only after the process exits.


Token accounting

The adapter does not trust step_finish.part.tokens as the final turn total. It recovers authoritative usage from a second subprocess once the turn’s subprocess is gone, not only when the turn ran to completion; see Accumulation logic for which endings recover a figure. Reported counts are cumulative over the whole session the orchestrator opened, across every turn of it, and never decrease.

The adapter disables OpenCode’s title agent by setting agent.title.disable to true in the OPENCODE_CONFIG_CONTENT document, so a turn makes no session-title request. That request is billed by the provider but recorded in no assistant message, so the per-message sum below would not have counted it. The session keeps OpenCode’s default title. A remote turn carries the same document, so it makes no title request either.

For this kind’s declaration beside every other kind’s, see the usage reporting table.

Accumulation logic

  1. Once the main opencode run subprocess has exited or been killed and waited for, the adapter launches a second subprocess to recover usage, when a session ID is known: opencode session export --standalone --sanitize <sessionID>, in the same workspace. This runs on every turn ending: normal completion, a non-zero exit, a stdout error envelope, cancellation, a read timeout, and a stdout read error. A session ID mismatch is the one ending that does not run it, because the work ran in a session other than the one the export would read.
  2. A session ID, once learned, persists for the rest of the session: either from a resumed session’s saved ID, or from the first JSON envelope, in this turn or an earlier one, that carries one. A read timeout recovers a figure only on a resumed session or a later turn, because it fires only before that turn’s own first JSON envelope arrives, so it never recovers one on the first turn of a new session. Cancellation and a stdout read error on that same first turn recover a figure only if an envelope carrying the session ID had already arrived before the turn ended.
  3. The export subprocess runs with only OPENCODE_DISABLE_AUTOUPDATE=true. It never carries OPENCODE_CONFIG_CONTENT, so it never carries the tool policy or the tool servers either.
  4. The export subprocess timeout is min(2 * read_timeout_ms, 30s), where an unset or non-positive read_timeout_ms counts as 30 seconds. With the workflow default read_timeout_ms: 5000, the export timeout is 10 seconds.
  5. The adapter reads the export JSON and sums every assistant message whose finish field is present and non-empty, not just the most recent message, provided the document’s own info.id matches the current session. A message with an empty or absent finish is excluded regardless of its token counts. Each message carries type, finish, tokens, model, and cost at its own top level. When the run resumed an existing session, messages created before the run started are excluded, so a resumed session’s earlier spend never lands in this run’s total.
  6. From each remaining message it reads tokens.input, tokens.output, and the optional tokens.reasoning, tokens.cache.read, and tokens.cache.write. A message with no tokens object is skipped.
  7. input_tokens is input + cache.read + cache.write; output_tokens is output + reasoning. cache_read_tokens and cache_write_tokens carry the two cache counts separately as disjoint subsets of input. total_tokens is computed as input_tokens + output_tokens rather than read from tokens.total, which counts cache and reasoning tokens on a different basis.
  8. If export setup fails, the subprocess exits non-zero, the JSON is malformed, or no message satisfies the session, creation-time, and finish conditions above, the adapter logs the warning no assistant token usage found in opencode export, emits no token_usage event, and leaves the previously reported snapshot in place rather than lowering it to zero.

The adapter emits at most one token_usage event per turn, when the export recovers a figure. A genuine zero still counts as recovered, so that session is recorded as measured rather than unmeasured; only a session for which no export ever recovered a figure, for any of the reasons above, is recorded as unmeasured.

A turn that served model output counts toward unaccounted_turns when its export recovers no figure. It also counts when the turn did not run to its own finish, because it was stopped, failed, or cut short, even if the export returned a figure: that figure is not proven to cover the request in flight. A turn that finished and recovered a figure is covered.

Model tracking

The main stdout stream does not supply a stable final model identifier. The adapter reconstructs the reported model only from the export payload, using providerID + "/" + modelID from the last counted assistant message, when both fields are present. Those fields are the message’s own model.providerID and model.id.

Each counted message also adds to a per-model count keyed providerID/modelID. A message whose payload lacks either value adds to no entry, so per-model attribution works only when the export includes both (see the model label). Sortie ignores the export payload’s vendor cost field and derives estimates from the normalized token counters and the rates in force (token_rates over the price table) instead.

API timing

The adapter does not emit per-request API timing and does not report how long an API request took on completion, failure, or token events. The export subprocess runs after the main turn exits inside its own timeout window, but its duration is not surfaced as a separate metric.


Tool call tracking

Correlation

OpenCode’s CLI envelope already carries terminal tool state. The adapter does not correlate a start event with a later completion event.

  1. Parses the tool_use envelope.
  2. Reads the tool name from part.tool.
  3. Computes duration from part.state.time.end - part.state.time.start.
  4. Marks the tool result as an error when part.state.status equals error, compared case-insensitively.

callID is parsed but not used for cross-event correlation.

Tool kind and argument

Each tool_result event also carries the kind of work the tool did and its one main argument, read from part.state.input, for five tools:

OpenCode toolKindInput member read as the argument
shellshellcommand
readreadpath
writeeditpath
editeditpath
skillskillid

For a skill call the argument is the skill’s name. Any other tool name carries the name alone, with no kind and no argument. A call whose input is not an object, or whose named member is absent or not a string, carries the kind and no argument. The adapter reads no other input member, so the argument never carries the call’s other inputs.

The served members are in the HTTP API reference, and how they show is in the session view.

Tool error detail

When part.state.status is error, the adapter copies part.state.error into the normalized event message and truncates it to 500 runes. It does not strip XML wrappers, ANSI sequences, or stderr text.

A rejected permission request reaches the message field as whatever the runtime wrote into part.state.error; the adapter neither recognizes nor rewrites that text. The separate notification for a rejection comes from a stderr line beginning ! permission requested:, matched after the process exits and after the escape sequences the runtime styles that line with are stripped.


Error handling

Turn outcome

An error kind is absent only on a turn_completed outcome; every other outcome carries one.

ConditionExit reasonError kindDescription
No JSON envelope arrived within the larger of read_timeout_ms and 60 seconds of launchturn_failedresponse_timeoutMessage is timed out waiting for first event. The subprocess gets the graceful process-group signal and is force-killed after stop_grace_ms; its stderr is re-emitted at WARN level.
A JSON envelope carried a sessionID other than the one already adoptedturn_failedresponse_errorMessage is session id mismatch: expected "...", got "...". The turn is aborted rather than reconciled, and the subprocess is stopped the same graceful way, force-killed after stop_grace_ms.
Stdout error envelope observed, whatever the process exit statusturn_failedturn_failedStructured logical failure, authoritative over the exit code. Message is the envelope’s own detail; see masked failures. On OpenCode’s hosted free tier, a refusal naming a denied bash or read tool gains a trailing clause naming the setting that denied it; see free-tier refusals.
Turn cancelled, or the session stopped, while the subprocess was still runningturn_cancelledturn_cancelledMessage is turn cancelled. Cancellation outranks the process-exit classification. A subprocess that had already exited on its own is classified by its own exit status instead, however long the wait for its output ran.
No error envelope, and the process exited before writing a line the adapter decodes as a JSON envelope, whatever its exit statusturn_failedport_exitThe early exit report: the agent runtime exited before responding: exit status N, followed by the end of the process’s stderr. A stdout line the adapter cannot decode as an envelope does not count as a response, even if the process later exits 0.
No error envelope, exit 0, at least one text, reasoning, or tool_use part parsedturn_completed(none)Normal completion.
No error envelope, exit 0, output written but no such part parsedturn_failedturn_failedThe model produced nothing this turn. Message is agent exited without producing output: no message from the agent and no tool call.
No error envelope, exit code 127 after writing outputturn_failedagent_not_foundThe agent binary was not found. Message is exit code 127. Not retried.
No error envelope, non-zero exit other than 127 after writing outputturn_failedport_exitProcess-level failure. Message is exit code N; when a signal Sortie did not send ended the process, the error text also names the signal, for example signal: killed.

The adapter never trusts exit code 0 as sufficient proof of success. A terminal stdout error envelope is authoritative.

Free-tier refusals

OpenCode’s hosted free-tier model refuses any request whose tool set lacks its shell tool or its read tool, and that refusal reaches the adapter as the stdout error envelope row above rather than as the permission-refusal notice.

When the session’s own opencode.allowed_tools or opencode.denied_tools is what denied one or both of bash and read, the adapter appends a clause naming the denied tool (or tools) to the vendor’s own refusal text; a refusal from a model that is not free-tier limited, or from tool settings that do not touch either required tool, gets no such clause.

The recognition depends on the vendor’s current error wording, so an upstream rewording can make the clause stop appearing without changing the underlying refusal.

Masked failures

One failure can surface as two stdout error envelopes: one carrying the actionable diagnostic and one carrying OpenCode’s generic placeholder, Unexpected server error. Check server logs for details.. Their order on the stream is not guaranteed, so the adapter keeps whichever carries detail. When the placeholder is the only failure detail on the stream, it reaches the operator unchanged, and the adapter does not try to name the cause.

Stdout read failure

If reading stdout encounters an error while the turn is still active, the adapter:

  1. Stops reading and kills the process group.
  2. Recovers token usage from the session export, the same as any other turn ending; see Accumulation logic.
  3. Re-emits collected stderr lines at WARN level.
  4. Emits turn_failed with message stdout read error: <detail> and reports an error of kind port_exit with the text stdout scanner error.

If a cancellation or stop had already begun when the adapter killed the process, the turn ends as turn_cancelled instead: stderr is not re-emitted, but the process is still killed and usage is still recovered the same way.

Stall detection

The adapter does not run its own inter-event stall timer. The first-envelope wait, the larger of read_timeout_ms and 60 seconds, only covers startup, although plain-text stdout lines reset that timer before the first JSON line arrives.

After the first JSON envelope, stall detection is orchestrator-owned. The adapter emits notification or malformed events for plain-text warnings, unknown JSON types, and normal OpenCode envelopes so the orchestrator’s stall_timeout_ms watchdog can observe output activity. When the orchestrator cancels a stalled turn, the adapter tears down the process and the turn ends as turn_cancelled.


Session resume mechanism

OpenCode continuation is flag-based. The adapter persists the OpenCode session ID and passes it back on the next subprocess launch.

Turn stateStored session IDCLI flag
Fresh session before first JSON envelopeEmpty(no --session flag)
Subsequent turn in the same worker sessionKnown--session <sessionID>
Continuation after worker restartThe session ID saved from a previous run--session <sessionID>

If a resumed turn emits a different sessionID from the one already stored, the adapter aborts the turn with response_error, stops the subprocess gracefully, and emits turn_failed. session_started is emitted only once per session, on the first accepted JSON envelope.


SSH remote execution

When the worker configuration includes ssh_hosts, the adapter launches the local ssh client and runs OpenCode on the remote host. The process model stays launch-per-turn: each turn is a separate SSH invocation that wraps one remote opencode subprocess, and the export recovery step uses a second SSH invocation.

How it works

  1. Session start resolves the local ssh binary, then runs the remote command’s version query over that same SSH path to check its OpenCode version; see version check. A version query failure fails session start the same way it does on a local launch, and needs the remote host’s dd utility (step 4).
  2. Each turn sends OPENCODE_DISABLE_AUTOUPDATE and the inline configuration document in OPENCODE_CONFIG_CONTENT to the remote shell on the SSH session’s standard input, together with any variable worker.ssh_pass_env names. The document carries everything a local turn’s does except the tool servers; see managed environment and MCP.
  3. The remote shell enters the workspace, exports what the launch sent it, and only then runs the configured command with the turn’s arguments, each step chained on the success of the one before it.
  4. Because every remote launch on this kind sends the managed variables, every remote host needs the standard dd utility. A host without it fails the launch with sortie: dd is required on the remote host to receive environment variables on standard error.
  5. The export recovery step uses the same SSH path with the export arguments described in Token accounting.

SSH options

The adapter uses the shared sshutil transport defaults:

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.
ConnectTimeout30Connection timeout in seconds.
ServerAliveInterval15Keepalive interval in seconds.
ServerAliveCountMax3Number of missed keepalives before disconnect.

Shell quoting

The workspace path and the adapter-generated OpenCode arguments are single-quoted with standard POSIX escaping before they are embedded in the remote shell command. A string agent.command is treated as a pre-formed shell fragment, so quoting inside it is the operator’s responsibility; each element of a list command is single-quoted. Environment-variable values are not part of that command string.

Exit codes

SSH exit code 255 indicates a connection failure and maps to port_exit unless OpenCode already emitted a terminal stdout error envelope. Exit code 127 means the remote binary is not in PATH; the process wrote nothing to stdout, so the turn takes the early exit report under port_exit, carrying exit status 127 and the remote shell’s own message. Exit code 0 is still not sufficient to prove success, because OpenCode can emit a terminal error envelope and still exit 0.


Authentication

Sortie does not manage OpenCode credentials and runs no authentication preflight for this adapter. The subprocess inherits the Sortie process environment, so whichever provider credentials OpenCode expects must already be present there. Which providers OpenCode supports, and which variable each one reads, is OpenCode’s to document; see external references.

A remote session carries no provider credential by default. This kind declares none, so a remote launch sends no credential unless you name one. What it sends of its own is OPENCODE_DISABLE_AUTOUPDATE and the configuration document. The remote host must already be authenticated for the model you select, or the variables that authenticate it must be listed under worker.ssh_pass_env. A run that works locally can fail on a remote host for this reason alone.

MCP

The OpenCode CLI accepts no MCP configuration path as an argument, and it does not read the mcpServers key the generated .sortie/mcp.json is written under.

The adapter delivers the servers rather than the file: on a local launch, session start reads the generated configuration and parses its servers into OpenCode’s own server-entry shape, keyed under mcp, with a stdio server becoming a local entry and an HTTP server a remote one. A server entry that omits its enable flag is rendered enabled, matching the runtime’s own default.

The turn sets those entries on the turn subprocess through the runtime’s inline-configuration environment variable, OPENCODE_CONFIG_CONTENT. The mcp entries live inside the one inline document that also carries the tool policy, sharing, and compaction (see managed environment), so the variable is set whether or not the session carries servers.

The runtime merges the document with whatever project or global configuration the operator already has, rather than replacing it, and the auxiliary export and session delete invocations the adapter also runs rebuild their environment without it, so none of them spawns a tool sidecar of its own. Any OPENCODE_CONFIG_CONTENT inherited from the orchestrator’s own environment is stripped first, on every one of those.

SSH mode delivers no tool servers

A remote turn carries the configuration document, but without the mcp member. The adapter renders the generated servers into that document on a local launch only, so an OpenCode session on an SSH host reaches none of Sortie’s tools and its first-turn prompt carries no tool advertisement. The rest of the document, the permission policy, sharing, compaction, and the title-agent switch, applies there as it does locally. OPENCODE_DISABLE_AUTOUPDATE reaches the session too, and so does anything worker.ssh_pass_env names.

Startup failures

The run projection this adapter reads carries no MCP startup signal, so a server that fails to start produces no distinct diagnostic here. It surfaces only indirectly, as the agent’s own tool calls failing. OpenCode starts every configured server without waiting for a tool call first: it starts each server’s connection in the background while it prepares the session, without waiting for either the process to spawn or the connection to finish before the turn proceeds.

mcp_config

opencode.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 adapter translates the merged result, so an operator’s own servers reach a local OpenCode 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.

Two more conditions fail the session with response_error when the merged configuration reaches the adapter, and the message names the offending server: an entry that carries neither command nor url, carries both, or declares a type contradicting the fields it carries; and an entry carrying a key outside the modeled set, which is type, command, args, env, url, headers, and enabled.

Both come from shared validation, so a file that fails here fails a codex session the same way. A header on an HTTP entry is carried into the document as written, which a codex session does not do; see the Codex adapter reference.


Key differences from other adapters

AspectClaude CodeCopilot CLICodexOpenCode
Kindclaude-codecopilot-clicodexopencode
Default commandclaudecopilotcodex app-serveropencode
Subprocess modelNew process per turnNew process per turnPersistent process across turnsNew process per turn, plus a usage-recovery subprocess after each turn
ProtocolCLI flags + JSONL stdoutCLI flags + JSONL stdoutJSON-RPC 2.0 over stdin/stdoutCLI flags + newline-delimited stdout envelopes
Output format flag--output-format stream-json--output-format jsonJSON-RPC notifications--format json
Session ID sourceUUID generated by adapterUUID generated by adapterThread ID from thread/start responseDiscovered from the first JSON envelope, or resumed via --session
Resume mechanism--resume <UUID>--session-id <uuid> on the first turn, --resume <uuid> after; never --continuethread/resume or automatic within session--session <sessionID> only
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-subtractedRecovered from opencode session export --standalone --sanitize
Model reportingFrom assistant eventsFrom the session-state journal’s session.shutdown record, after the subprocess exitsFrom the thread-open response, updated on rerouteRecovered from export providerID/modelID
Token accounting sourceResult event modelUsage, with top-level usage fallbackSession-state journal on disk, with stream output tokens as the in-turn estimatethread/tokenUsage/updated notificationSeparate export subprocess after main turn exit
Permission control--permission-mode or --dangerously-skip-permissions--autopilot + --no-ask-user + explicit tool scopingapprovalPolicy and sandbox policy in JSON-RPC--dangerously-skip-permissions plus a synthesized policy in the inline configuration document
Sandbox enforcementNone at adapter levelNone at adapter levelOS-level sandbox plus configurable policyNo adapter-level sandbox; permission policy only
Sortie’s toolsGenerated config path on --mcp-configGenerated config path on --additional-mcp-configGenerated servers re-expressed as command-line overrides, local launch onlyGenerated servers re-expressed as an inline configuration document in the turn environment, local launch only; a remote turn carries the document without them (see MCP)
AuthenticationANTHROPIC_API_KEY and provider routing flagsGitHub token variables or gh authCODEX_API_KEY or cached Codex authOpenCode-managed provider auth from env, auth store, .env, or opencode.json; a remote session uses the host’s own, or whatever worker.ssh_pass_env names
Provider multiplexingAnthropic direct, Bedrock, VertexGitHub onlyOpenAI or cached Codex authMulti-provider through OpenCode model/provider config
Inner turn limitclaude-code.max_turnscopilot-cli.max_autopilot_continuesNoneNone exposed by the adapter
Exit-code reliabilityStructured result event plus process exitStructured result.exitCode plus process exitJSON-RPC turn statusProcess exit alone is unreliable. Terminal stdout error can still exit 0.
Non-JSON stdout toleranceNot requiredNot requiredNot applicableRequired. Permission warnings can appear as plain text in --format json mode.

External references


Related pages

Was this page helpful?