Skip to content
Agent Client Protocol Adapter

Agent Client Protocol Adapter

The Agent Client Protocol adapter connects Sortie to any runtime that speaks the Agent Client Protocol, a newline-delimited JSON-RPC 2.0 protocol several coding-agent CLIs implement. It launches the runtime named by agent.command as a persistent subprocess, local or over SSH, performs an initialize handshake, and drives a session that survives across turns. Registered under kind "agent-client-protocol".

Unlike every other agent kind, this one names no default runtime. claude-code launches claude and copilot-cli launches copilot unless agent.command names another program; this kind launches whatever agent.command names, together with the flag or subcommand that puts that binary into protocol mode.

One operator build of this adapter can therefore drive several different vendor runtimes, and this page describes only what the protocol and the adapter guarantee across all of them. What one specific runtime does with a capability the protocol leaves optional, and the launch switches that runtime needs, are covered on that runtime’s own page: see Gemini CLI on the Agent Client Protocol and Kiro CLI on the Agent Client Protocol.

Session start launches the subprocess once and keeps it alive for the whole session; each turn sends one session/prompt request on that same session rather than spawning a new process.

See also: WORKFLOW.md configuration for the full agent schema, environment variables for how a runtime’s credential reaches its subprocess, error reference for all agent error kinds.


Configuration

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

agent section

These fields control the orchestrator’s scheduling behavior. None of them is a protocol parameter; agent.command is the one field this kind reads differently from its siblings.

FieldTypeDefaultDescription
kindstring-Must be "agent-client-protocol" to select this adapter.
commandstring or list(none)The runtime binary together with whatever flag or subcommand puts it into protocol mode, for example gemini --acp or kiro-cli acp -a. Required: this kind has no default binary, so it reads agent.command only as the default kind and a dispatch rule can route to it only when it is also the default kind. See agent.command for the list form. Resolved from PATH at session start; in SSH mode the local ssh binary is resolved instead and this value travels to the remote host as the command to run there.
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.
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 every synchronous wait on the runtime: session/new, session/load, and session/resume responses, the negative-control probe that precedes a continuation attempt, the bounded wait for a replayed chunk after a session/load response, the wait for a session/prompt response after the adapter has sent session/cancel, the wait for a turn’s own usage measurement to resolve once its prompt response arrives or it ends without one, and, once any write to the runtime fails while a turn is active, the wait to see whether the connection’s own end of stream arrives before the turn is finalized as a failed send instead. Falls back to 30 seconds when set to a non-positive value. The initialize handshake, on every session including credential verification, waits the larger of this value and 60 seconds instead, because a runtime may have to establish its own credential with its backend before it answers at all; a short read_timeout_ms therefore never shortens that one wait below 60 seconds.
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 teardown waits for the subprocess to exit on its own after a graceful termination signal, before it force-terminates the process group. Also bounds, at half its value, the session/close call teardown sends when the handshake advertised that capability, and the wait for the session to finish answering every request it had open before teardown continues. Must be positive.
max_retry_backoff_msinteger300000 (5 minutes)Maximum delay cap for exponential backoff between retry attempts.
agent:
  kind: agent-client-protocol
  command: gemini --acp
  max_turns: 15
  max_concurrent_agents: 4
  turn_timeout_ms: 1800000
  stall_timeout_ms: 300000

agent-client-protocol extension section

This kind reads exactly one pass-through field. Every other setting a specific runtime needs, such as a model flag or a trust switch, is part of agent.command rather than a field here, because those settings are the runtime’s own and this kind has no runtime-specific schema.

FieldTypeDefaultDescription
mcp_configstring(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; the adapter re-expresses the merged result on session/new. See MCP.
agent-client-protocol:
  mcp_config: ./mcp-servers.json

The block carries no reasoning level: the kind reads no effort key, and one set here has no effect and draws the agent.effort.not_forwarded warning. Write the runtime’s own reasoning option in agent.command instead, with the value that runtime accepts; Sortie checks neither.

The block itself is never forwarded to the runtime; only mcp_config is checked, to reject the wrong YAML type before any session starts. What actually reaches a session is the resolved MCP configuration path, prepared by the worker the same way it is for every other kind.


Validate-time checks

When agent.kind is agent-client-protocol, the sortie validate pipeline runs this kind’s own config check in addition to the generic preflight validation. It builds no adapter and launches no subprocess, and the same check runs at startup and on every workflow reload, so the verdict is identical in all three places.

Errors

CheckConditionMessage
agent-client-protocol.mcp_config.wrong_typeagent-client-protocol.mcp_config is present with a YAML type other than a stringNames the key and the type found.

Building the adapter reports the same fault with the same message, so the two paths can never disagree.

This kind declares no check that would leave the runtime waiting for a person: unlike codex.approval_policy or claude-code.permission_mode, there is no pass-through field here whose value could put the runtime into an interactive posture, because whatever posture the runtime takes is spelled inside agent.command itself. See permission handling for what happens when a runtime asks anyway.


Session lifecycle

Session start

Launches the subprocess, performs the initialize handshake, and creates or continues a session with session/new, session/load, or session/resume.

  1. Resolves the launch target: validates that the workspace path is a non-empty absolute path pointing to an existing directory, and resolves command from PATH with no default to fall back to. In SSH mode, resolves the local ssh binary instead.
  2. Resolves the session’s tool delivery from the generated MCP configuration. A server whose enabled field is present and false is dropped here. On a remote launch the stdio servers are dropped too, because the protocol’s stdio server declaration names an executable the remote host would have to resolve, and Sortie starts its HTTP tool server and opens one short extra SSH connection to learn whether the worker host accepts the reverse forward. See MCP.
  3. Starts the subprocess with the full parent process environment, places it in its own process group, and wires stdin, stdout, and stderr. A launch whose agent.command names OpenCode also carries the setting that turns off its session titles; see token accounting.
  4. Sends initialize with the pinned protocol version and no filesystem or terminal client capability. A response reporting any other protocol version ends the session, because the protocol defines no renegotiation and leaves the decision to disconnect to the client. The response’s agentInfo.name selects the session’s usage declaration; see runtime identification.
  5. Renders the MCP servers into the wire format session/new (or a continuation call) will carry. When the handshake’s mcpCapabilities.http is true, Sortie’s own server is an HTTP entry, served from the orchestrator host. Otherwise any HTTP server is omitted. A stdio server is never withheld by this step on a local launch; the protocol allows omitting a server type, not the whole channel.
  6. Resolves the session: session/new alone when no session ID from a previous run is supplied, or the handshake advertises no continuation method; otherwise the advertised route, confirmed and falling back to session/new within this same call when not confirmed. See session resume mechanism.
  7. Records the identifier to end through the protocol later, only when the handshake advertised a teardown method: a credential-verification session records session/delete when the handshake advertises sessionCapabilities.delete, falling back to session/close when only that is advertised; every other session records session/close when advertised and nothing otherwise. See session close.
  8. The session records the identifier the runtime actually created 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 command is emptyagent_not_found
Local runtime binary not found in PATHagent_not_found
SSH binary not found (SSH mode)agent_not_found
Generated MCP configuration unreadable or malformedresponse_error
Subprocess failed to start, or a stdio pipe could not be createdport_exit
Session start’s own context ended before the subprocess started runningturn_cancelled
The connection ends, including the subprocess exiting or the standard-output wait described under process shutdown giving up, before session/new, session/load, or session/resume receives a responseport_exit
The connection ends before initialize answersport_exit, on a credential-verification session and a working session alike
Either connection loss above, when the runtime process has itself exitedport_exit, as the early exit report carrying the runtime’s exit status and the end of its stderr; a connection that ends while the runtime stays alive reports agent connection ended before responding instead
initialize timed outresponse_timeout
initialize, session/new, or session/prompt returned error code -32000, the protocol’s own “authentication required” codecredential_unverified, on a credential-verification session and a working turn alike
initialize returned any other protocol-level error, or reported a version other than the one this adapter is generated againstresponse_error
session/new, session/load, or session/resume returned a protocol-level error after continuation was not confirmed and the session/new fallback also failed, and the error was not code -32000response_error

Turn

Sends one session/prompt request on the existing session and relays session/update notifications until the response arrives.

  1. Submits the prompt to the session, then waits, bounded by read_timeout_ms, for it to accept or reject the request.
  2. Sends session/prompt with the rendered text. Streams every recognized session/update notification as a normalized event for as long as the request is outstanding. See event stream.
  3. When the turn is cancelled, sends session/cancel once and keeps waiting, bounded by read_timeout_ms, for the runtime’s own response to the prompt it already sent.
  4. Ends the turn from the session/prompt response’s stopReason, from a protocol-level error on that response, from the runtime’s own cancellation acknowledgment, or from the bounded wait above elapsing. See turn disposition.

Only one turn may be in flight per session. A second turn started while one is still active is refused with response_error before anything reaches the runtime.

Session stop

Runs a fixed teardown order regardless of how far the session progressed, so a session that never finished starting is torn down the same way as one that ran turns: answer any request the session is still holding open, send session/close when the handshake advertised it, signal the process group to exit gracefully, close standard input, wait for the process to exit, force-terminate the process group unconditionally as a backstop, close standard output and the connection, collect stderr and reap, and release the pipes last. See process shutdown.


Process shutdown

StepWhat it does
Answer any open requestAnswers any protocol request the session received but had not yet replied to, then waits, bounded the same way session/close is, both for that answer to finish being written and for it to actually reach the runtime.
Close the sessionSends session/close for the recorded session identifier, bounded by half of whatever remains of the graceful window, only when the handshake advertised sessionCapabilities.close. Does nothing otherwise.
Signal graceful terminationSends a catchable termination signal to the launched process group. On a remote launch this reaches the local ssh relay’s group, not the runtime itself.
Close standard inputHands the runtime end-of-input immediately behind the signal, without waiting for that close to finish, so a close stuck at the OS level cannot delay the wait for exit or the force-terminate step that follows.
Wait for exitWaits for the process to exit and be reaped, bounded by stop_grace_ms and by the orchestrator’s own deadline, whichever is nearer.
Force-terminate the process groupRuns unconditionally after the wait, whatever it observed. This is the backstop for a descendant that escaped the direct child or a runtime that ignored every signal.
Close standard output and the connectionReleases the connection’s own parked read and write. The standard-error read end stays open, because the drain below still needs it.
Drain stderr and reapCollects diagnostics and reaps the process, bounded so this step never holds teardown open indefinitely.
Release the pipesCloses both read ends the session owns. It runs last because the session owns them for its whole lifetime: closing either one earlier would cut off the stderr drain above while it is still reading buffered output, and a session that failed during startup reports its collected stderr after teardown finishes.

On Unix, graceful termination is SIGTERM and force kill is SIGKILL to the process group. On Windows, graceful termination 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.

A failed assignment logs WARN process group assignment failed and the runtime subprocess runs without a job; a failed resume logs WARN process resume failed and fails session start with error kind port_exit, the same row the errors table above gives for any subprocess that fails to start.

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. Since this adapter launches the runtime once and keeps it alive for the whole session, a resume failure here means no turn for that session ever reaches the runtime.

A runtime that has already received its answer by the time the graceful signal reaches it can act on that answer and exit inside stop_grace_ms on its own, rather than still waiting on it once the force-terminate step runs. A runtime that never reads the answer still leaves teardown on schedule: the answer step completes within its own bound regardless, and the rest of the order runs as listed.

The runtime process can exit while a descendant it spawned still holds its standard-output handle open. Reaping the process does not close that handle, so a handshake call or a turn already waiting on the runtime’s output keeps waiting; Sortie gives it up to five seconds past the reap, then stops waiting and closes standard output and the connection itself.

A turn ended this way reports port_exit with the message the agent runtime exited before the session finished collecting its output; a turn started after this has already happened is refused with the same message before it reaches the runtime. Sortie logs agent stdout was not fully collected before the session ended at WARN, with the five-second bound in its drain_bound attribute.

On a remote launch, the process watched for exit is the local ssh relay rather than the runtime on the far end. Stopping the session afterward still runs the fixed order above in full. Ending the relay also starts the shutdown of the runtime on the worker host.


Event stream

The Agent Client Protocol declares eleven session/update notification variants. The adapter maps each 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.

session/update variantNormalized event
agent_message_chunk (text content)notification with an empty message, at once. The text accumulates into one agent message, reported as a second notification; see agent messages.
agent_message_chunk (non-text content)malformed, message agent sent a message chunk this client does not render, at once. The chunk belongs to the message being accumulated and does not end it.
agent_thought_chunkother_message with an empty message
user_message_chunkNo event. This variant exists only as replay evidence for session continuation; the adapter observes it directly rather than through a normalized event. It ends the agent message being accumulated.
tool_callNo event yet; the call is recorded internally, keyed by its own identifier, until its terminal update arrives.
tool_call_update, status completed or failedtool_result, carrying the tool name, the duration between the call’s begin and end, the error flag set when the status is failed, and the update’s own title as the message, truncated to 500 runes and empty when the update carries none. The name is the call’s tool kind, and the kinds other and switch_mode report none; see tool call tracking.
tool_call_update, any other statusNo event; the internal record is updated, nothing more.
planother_message with an empty message
available_commands_update, current_mode_update, config_option_update, session_info_update, usage_updateNo event; logged at debug level only
Any other, or unrecognized, discriminator valuemalformed

Agent messages

A runtime streams one reply as many agent_message_chunk updates. Chunks of one message share a messageId; a chunk with a different messageId starts a new message. Each text chunk produces a notification with an empty message, which resets the stall timer without replacing the last message shown for the running agent. The text itself accumulates, capped at 500 runes. A message cut at the cap ends with ….

The accumulated message is reported once as a notification carrying the whole text, when any of these happens:

  • A chunk with a different messageId arrives.
  • A user_message_chunk arrives.
  • Any other update that maps to an event arrives, including a tool_result, an other_message, and a malformed event for an unrecognized update.
  • A turn starts or ends, or the stream to the runtime ends.

Updates that produce no event, such as usage_update, do not end a message.

The reported message carries the time its last text chunk arrived. A reply that session/load replays between turns is therefore not dated into the next turn.

The last message shown for a running agent, including the last_message field of the HTTP API, is the latest completed message, subject to the same 500-rune cap.

A notification naming a session identifier other than the one this session is running is dropped rather than normalized, once the session’s own identifier is known.


Turn disposition

The session/prompt response’s stopReason decides how the turn ends, unless the adapter itself induced a cancellation or the runtime asked for something only a person could answer, either of which overrides whatever stopReason the response goes on to carry.

stopReasonOutcomeError kind
end_turnturn_completed(none)
refusalturn_failedturn_refused
max_tokensturn_failedturn_token_limit
max_turn_requeststurn_failedturn_request_limit
cancelled, and this adapter or the orchestrator asked for the cancellationturn_cancelledturn_cancelled
cancelled, and neither side asked for itturn_failedturn_failed, message agent reported a cancelled stop reason without a cancellation on either side
Any other valueturn_failedturn_outcome_unknown

Whether a runtime’s own implementation actually produces every one of these five values, and what each one means for that runtime specifically, is that runtime’s to document; see the runtime-specific pages linked at the top of this page.

refusal and max_tokens are treated as non-retryable classification decisions rather than transport failures: a retry of the same input would meet the same refusal or the same limit. max_turn_requests is retryable with exponential backoff, since a fresh turn starts a new request budget on the runtime’s side.

Two conditions precede a stopReason read at all: a protocol-level error on the response reports response_error, except code -32000, which reports credential_unverified; and a lost connection reports port_exit.

A lost connection covers the subprocess exiting, the stream ending, and a write to the runtime failing while a turn is active, when the connection’s own end of stream does not follow within read_timeout_ms of that failure; a line exceeding the connection’s bound reports turn_outcome_unknown instead, because it is a different failure from losing the process.

A lost connection also covers a runtime that exits while a descendant still holds its standard output open: rather than run until stall_timeout_ms or turn_timeout_ms catches it, this ends within a five-second bound; see process shutdown for the mechanism and message.


Token accounting

The protocol defines no per-turn token counter, so what a session on this kind measures comes from the usage declaration it runs under. Sortie ships a declaration for some runtimes, and every other runtime runs under the default declaration. A declaration names the members of the runtime’s protocol messages that carry spend and, where the runtime keeps them, the per-request records Sortie reads when a turn ends. It applies the same way locally and over SSH: on an SSH launch, Sortie reads the records on the worker host. How a run’s spend is measured across every agent kind, and which ceilings reach a run, is on spend measurement.

RuntimeHandshake nameToken figure from the protocolRecords read when a turn endsAmounts recorded beside the tokens
Gemini CLIgemini-cliThe usage member of the session/prompt result, as that turn’s countsThe runtime’s chat journalNone
Copilot CLICopilotThe usage member of the session/prompt result, as counts cumulative over the runtime processThe runtime’s session-state journalpremium requests and AI credits, from the journal
OpenCodeOpenCodeThe usage member of the session/prompt result, as that turn’s countsThe session’s export, and the export of every subagent session the turn startedUS dollars, from the export
Kiro CLIKiro CLI Agent; none on --agent-engine v3NoneNonecredits, per turn, from the runtime’s credit notification
Any other runtimeAny other nameThe usage member of the session/prompt result, always a lower boundNoneThe cost member of a usage_update, in the unit its currency names

The runtime-specific detail of Gemini CLI and Kiro CLI is on Gemini CLI on the Agent Client Protocol and Kiro CLI credits.

The default declaration reads inputTokens, outputTokens, cachedReadTokens, and cachedWriteTokens from the prompt result’s usage member, and never thoughtTokens or totalTokens. The protocol does not settle whether those counts are per turn or cumulative, and the declaration reads them so that neither reading counts a token twice. The figure is therefore exact for some runtimes and short for others, and Sortie always records it as a lower bound. A member that is absent, null, or not an integer is unknown, never zero.

How a turn settles:

  • A turn the protocol figure settles keeps that figure. Where the runtime’s records name the model that served each request, Sortie splits the figure by that model; otherwise the figure is a session total that names no model.
  • A turn that ends without a prompt result, such as a cancelled or killed turn, is settled from the records when they can be read.
  • A model request counts once in the run total, whichever source reports it.
  • When a session is continued with session/load or session/resume, Sortie reads the records before the first prompt, and the requests already in them count as the session’s history, not as this run’s spend.
  • A records read that fails, a dropped SSH connection, or a read that outlasts agent.read_timeout_ms is a failed read, never a complete one.
  • A turn that reached the model and ends with no figure is recorded as spend that occurred with its amount unknown, never as zero.

Sortie records a turn’s figure as a lower bound when the default declaration measured it, when no figure covered the turn, and in these runtime-specific cases:

  • A Gemini CLI session whose current model is an automatic selector makes a model request no source records.
  • An OpenCode subagent that fails, goes unnamed, or whose export cannot be read leaves its turn short.
  • An OpenCode session titles itself with a model request no source records. Sortie turns that request off on every launch whose agent.command names OpenCode, locally and over SSH, by adding agent.title.disable to OPENCODE_CONFIG_CONTENT and keeping every member the operator set. An operator’s own agent.title.disable other than true, a value that is not a JSON object, or a command that does not name OpenCode leaves the request on.

A prompt result may also carry a vendor-namespaced _meta.quota.token_count block. Sortie never records it as spend, because it leaves out cache-read and reasoning tokens; a non-zero count only tells the adapter that the turn reached the model.

The protocol’s usage_update session update reports the tokens currently in context and the context window’s size, and neither enters a figure. Under the default declaration, its optional cost member is read as a running total of the session’s cost, amount in the unit currency names. A three-letter currency code is recorded uppercased, and any other unit, such as credits, is recorded as reported. An empty currency, or an absent or null cost, records nothing. An amount that is not a finite non-negative number is unknown in that unit and makes the recorded amount a lower bound. A created session counts the amount from zero, and the first reading of a continued session counts nothing and makes the amount a lower bound. A runtime with its own declaration has this member ignored.

Sortie keeps every amount in the unit it was reported in. An amount in a unit other than US dollars, such as Kiro CLI credits or Copilot CLI premium requests, is recorded with the run and enters no token or money total. A US dollar amount prices a model that no price table prices: an amount reported for a model prices that model’s share, and an amount that names no model prices the run only when the run’s figure is attributed to a single model.

A session that settles no token figure, such as every Kiro CLI session and a session whose runtime sends no usage member, emits no token_usage event, is recorded unmeasured, and contributes nothing to agent.max_tokens. With agent.max_tokens set, such a run closes with run reported no token usage, token ceiling could not bound it. sortie validate reports no agent.kind.no_usage_reporting warning for this kind, local or over SSH, because a session on it can carry a figure. How a run with no figure, or with a figure marked as a lower bound, reads on each surface is on the HTTP API, CLI, and dashboard references.

Whether a session measures tokens is visible in the capability record: the once-per-session notice names token counts when the declaration reads no token figure, and the entry is lowered when the first turn that reached the model ends with no figure while no earlier turn had one; see capability tracking.

Runtime identification

Sortie selects a session’s declaration from the agentInfo.name of the initialize response:

  • A name Sortie ships a declaration for selects that declaration.
  • Any other name selects the default declaration.
  • A response that carries no name selects the declaration of the runtime agent.command names, and the default declaration when it names none. agent.command names a runtime when one of its words is the runtime’s executable or npm package; on an SSH launch Sortie reads the remote command, never the ssh executable.

Before the handshake, the command alone decides what the launch carries for measurement: the OpenCode title setting, and the initialize subscription that lets Sortie read a Copilot CLI usage-limit refusal.

When the handshake names a runtime other than the one agent.command names, the session runs under the handshake’s declaration and logs one Warn record, with component=clientprotocol-adapter:

Log messageFields
agent runtime differs from its configured commandconfigured_runtime, the declaration the command named; handshake_runtime, the name the handshake reported; usage_declaration, the declaration the session runs under

Sortie does not start the runtime again. A command that names no runtime Sortie ships a declaration for logs nothing.


Usage limits

The protocol carries no usage-limit report, so a session reports the limits of the plan or account behind the runtime only through a source Sortie ships for one specific runtime. The runtime is recognized from the name its handshake reports or, for Kiro CLI on one engine, from the launch command. A session whose runtime has no source reports no limits. A limit reaches the dashboard and the rate_limits member of GET /api/v1/state under the key agent-client-protocol.

RuntimeHow the limits are readReference
Copilot CLIPulled when a turn ends, from GitHub with the first GitHub token the runtime’s environment carries, or, without one or when GitHub refuses it, by asking the runtime’s own command on its host.Copilot CLI usage limits
Gemini CLIPulled when a turn ends, from the endpoint the runtime itself calls, with the runtime’s Google sign-in.Gemini CLI usage limits
Kiro CLIPulled when a turn ends, over the session’s own connection.Kiro CLI usage limits
OpenCodePulled when a turn ends, by running the runtime’s own command on its host.OpenCode usage limits

A pull runs after a turn ends and not after a turn that was cancelled or timed out. The credential-verification session pulls nothing. A pull that fails reports nothing and leaves the limits already reported in place. A pull that finds the runtime cannot answer in this session, such as a Gemini CLI on an API key, stops the pulls for the rest of the session.


Permission handling

An operator neither answers a permission request nor configures the answer. The adapter’s refusal posture is the one every agent adapter shares, applied here to session/request_permission:

What the request asks forWhat the adapter does
Consent to act, selecting from a runtime-offered set of optionsSelects the first offered option whose own kind refuses (reject_once, or reject_always when no reject_once option is offered), emits a notification, and the turn continues.
Consent to act, with no refusing option offered at allAnswers with the cancelled outcome, emits a notification, and ends the attempt with turn_input_required; the option set gave the adapter no way to decline without granting something.
An answer only a person could give, elicitation/createAnswered method not found at the protocol level, emits a notification, and ends the attempt with turn_input_required.
Any other method this client does not implementAnswered method not found; the turn continues and a malformed event is recorded.

A request arriving between turns is answered the same way, and an ending it produces is held until the next turn: that turn starts, delivers the notification, and ends immediately without sending a prompt. A request ending the attempt releases the claim instead of scheduling a retry, and the run is recorded with status needs_person rather than failed.

A session that delivered at least one declared tool server, and then meets a permission request answered by refusal, is told once per session that any tool gated the same way cannot be called: the adapter emits one notification and logs one Warn record.

A posture that never asks in the first place leaves no such signal on this path; see the runtime-specific pages for what each runtime’s own configuration needs to reach that posture, and what a delivered tool server needs to be callable.


Tool call tracking

Sortie’s tools reach the session as MCP servers declared on session/new (or a continuation call), and the runtime carries every call and result. The adapter observes rather than routes: it correlates a tool_call update with its own terminal tool_call_update by the call’s own identifier and emits one tool_result event per completed or failed call, carrying the tool name, the duration between begin and end, the error flag, and the terminal update’s own title as the message. The protocol’s optional tool name is not read. The call is named by its kind, so the name is one of read, edit, delete, move, search, execute, think, fetch, and a call of kind other, switch_mode, an absent kind, or an unrecognized kind reports no name. A read call is classed as a read, edit, delete, and move calls as an edit, and an execute call as a shell call; the other kinds carry no class. The call’s main argument is the path of its first reported location for a read, edit, delete, or move, and the call’s title for an execute, because the protocol defines no command member. The other kinds carry none. The served members are listed in the HTTP API reference, and the dashboard reference shows how they read in the session view.

Every tool call’s declared kind is normalized to a closed, ten-value set: read, edit, delete, move, search, execute, think, fetch, switch_mode, and other, substituting other for an absent or unrecognized value.

Delivery and discovery are not the same as callability. The session-creation request carries the declaration and the runtime launches the server and reads its tool list, but a runtime that asks for consent before invoking a tool meets this adapter’s refusal, and the tool is never invoked.

The protocol’s stdio server declaration carries no trust or pre-authorization field, so a declared server cannot be marked pre-authorized on the wire; the only lever is the runtime’s own configuration, reached through agent.command and whatever configuration file that runtime reads. See the runtime-specific pages for what each runtime’s own switches grant.


MCP

The worker writes .sortie/mcp.json for every agent kind. Session start reads it and renders its servers into the wire shape session/new carries.

A runtime whose handshake reports mcpCapabilities.http as true receives Sortie’s own tool server as an HTTP entry, on a local launch and on an SSH launch. Sortie starts the server on the orchestrator host. The entry carries the server’s loopback address, which on an SSH launch is the remote end of the launch’s reverse forward, and a per-session bearer as an Authorization header. The protocol has no reference form for a header value, so the bearer and every operator HTTP header value travel literally inside the session/new request, and a runtime that logs its requests can write them down. See delivery over SSH.

A runtime that accepts only HTTP servers is why the HTTP route applies to local launches too: copilot --acp discards every stdio entry without an error or a notification, so Sortie’s tools reach it only over HTTP, and an operator stdio server never does.

A runtime that does not report HTTP support receives the generated stdio entries on a local launch. On an SSH launch it receives no server, runs without Sortie’s tools, and the session declares a gap. An operator HTTP server is omitted for such a runtime, and the tool-servers capability entry is lowered for the rest of the session (see capability tracking).

On an SSH launch, stdio servers from mcp_config are never sent. A server whose enabled field is explicitly false in the generated configuration is dropped before rendering, because the protocol carries no disabled state of its own.

After session/new returns, session start waits up to read_timeout_ms for the runtime to connect to Sortie’s HTTP server. When none connects, the session declares the no_connection gap and its first-turn prompt carries no tool advertisement. This is the one signal Sortie has for a runtime that drops declared servers without saying so.

Whether a declared server’s tools are actually reachable once the session starts depends on the runtime’s own workspace-trust and approval configuration, not on delivery; see tool call tracking.

mcp_config

agent-client-protocol.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 this adapter renders the merged result the same way it renders the generated configuration on its own. 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.


Capability tracking

Every session keeps a record of four capabilities this transport can deliver, each starting at a resolved state before the handshake and only ever lowering, never rising, within the session:

CapabilityStarts atLowered when
Tool serversDeliveredSortie withheld a server or never reached its own: the handshake did not advertise mcpCapabilities.http, the worker host refused the reverse forward, the tool server did not start, or the runtime never connected. SSH launches also lower it for operator stdio servers. See when a session runs without the tools.
Token countsAbsent, unless the session’s usage declaration reads a token figureThe first turn that reached the model ends with no figure while no earlier turn had one. See token accounting.
Session continuationDeliveredThe handshake advertises neither session/load nor session/resume, or an attempted continuation call is not confirmed. See session resume mechanism.
Agent versionDeliveredThe handshake’s initialize response carries no agentInfo.

Once per session, the adapter emits one notification naming every entry then in the gap state, in this fixed order. It emits it right after the first turn’s session_started event. Session start has already settled the tool-server outcome by then, on a local launch with a stdio runtime as on any other, because it waits up to read_timeout_ms for an HTTP runtime’s first connection. The token counts entry starts there unless the session’s usage declaration reads a token figure, so a Kiro CLI session carries this notice on that entry alone and reads this session started with a declared capability gap in: token counts.

A capability lowered afterward is logged at Warn instead of producing a second notice, so an unfamiliar line in the run log after that point is recognizable as a declared limit rather than as an unreported error.


Session resume mechanism

Supplying a session ID saved from a previous run at session start attempts continuation; the route depends on what the handshake advertised, in this fixed order: session/load when the handshake advertises loadSession: true, otherwise session/resume when the handshake advertises sessionCapabilities.resume, otherwise no continuation is attempted and a fresh session is created with session/new.

Before either continuation call, the adapter sends one request naming a vendor-namespace method no protocol release can claim, and records the error code the runtime answers it with, if any. This negative control lets the adapter tell an unimplemented continuation method apart from a genuinely broken one by comparing that code against the one the continuation call returns, though both outcomes still lower the capability and fall back the same way.

session/resume’s own response is enough to confirm it: a successful response means the identifier resumed. session/load needs more: a successful response alone is not treated as confirmation, because a runtime can answer success while replaying nothing. The adapter also requires at least one replayed message chunk for the loaded identifier, observed either before the response arrives or within read_timeout_ms after it; without one, the load is treated as unconfirmed.

Whichever continuation route is attempted, an error response, a timeout on that call or on the negative control before it, or (for session/load) no observed replay lowers the session continuation capability entry and falls back to session/new within the same session-start call; none of these outcomes fails the session on their own account.

A session/load call for a session this adapter’s own process created is held until the wall clock leaves the UTC minute in which that creation happened, bounded at one minute.

This spacing exists to protect against a class of runtime defect where a load issued too soon after a session’s own creation destroys that session’s resumability outright, including every later attempt to load it; a runtime with this defect is documented on its own page.

The wait is measured in process memory, so it does not cover a session created by a previous process, and on an SSH launch it is measured on the orchestrator host’s clock rather than the runtime host’s.


Session close

session/close is sent during teardown when the handshake’s initialize response advertises sessionCapabilities.close. A handshake advertising no sessionCapabilities object at all, or one that omits close, means this adapter never selects it: the session then ends only through process termination, described in process shutdown. The call is bounded at half of whatever remains of the graceful teardown window, so it can never itself consume the whole window at the expense of the signal that follows it.

A credential-verification session’s own teardown sends session/delete in its place, on the same bound, when the handshake advertises sessionCapabilities.delete; a runtime that advertises close but not delete still gets an ordinary session/close for its verification session.

A failed delete is logged at Warn rather than Debug, since a failed close only means an already-going-away process is a little slower to notice, while a failed delete means the verification conversation is left stored on the runtime’s side.

A runtime whose handshake advertises neither method leaves that conversation in its own store on every check that runs, whatever the outcome. Both documented runtimes on this kind are in that position: neither Gemini CLI nor Kiro CLI on this route advertises a close or delete method. Gemini CLI’s own page names where that leaves the conversation; no location is established for Kiro CLI on this route.


SSH remote execution

When the worker configuration includes ssh_hosts, the adapter launches the local ssh binary and runs the configured command on the remote host instead of locally. The subprocess model stays persistent per session: one SSH invocation wraps the runtime for the session’s whole lifetime, the same as a local launch keeps one subprocess alive across turns.

SSH options

The adapter uses the shared sshutil transport defaults, the same ones every other SSH-capable adapter uses. A launch that delivers Sortie’s tools adds a reverse forward and the options that go with it; see delivery over SSH.

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

What an SSH launch does not carry

No configured environment reaches the remote runtime beyond what OpenSSH itself forwards and the OpenCode title setting described under token accounting, and the generated configuration’s env block never leaves the orchestrator host; see MCP. A credential a specific runtime needs on the remote host has to already be present there, or forwarded by whatever mechanism that runtime’s own page describes; this kind manages none of its own.

Exit codes

SSH exit code 255 (a connection failure) and exit code 127 (the remote binary not on PATH) both reach the session as the loss of its connection and report port_exit, whether the session was still starting or running a turn. A 127 during startup is the runtime exiting before its handshake completes, so session start reports the early exit report, carrying exit status 127 and the remote shell’s own message. A missing local ssh binary is the one launch failure reported as agent_not_found.


The credential-verification request

The protocol’s session creation carries no member that drops a runtime’s instructions, tools, or skills, so the generic kind has no reduction of its own. A credential-verification session on a local launch gets one only when the command line names a runtime Sortie has switches for: the executable, or its npm package, appears in agent.command. The switches are added after your command line, for that session only.

RuntimeSwitches addedThe request still carries
Gemini CLIGEMINI_SYSTEM_MD, naming a temporary file that holds a short fixed system promptAll tools, project instructions, and user instructions
Copilot CLI--available-tools view and --no-custom-instructionsThe built-in system prompt, the read-only view tool, and both instruction sets, because the runtime loads its instruction files over the protocol whatever the switch says
OpenCodeOPENCODE_CONFIG_CONTENT, denying every toolProject instructions, user instructions, and the built-in system prompt
Kiro CLIKIRO_AGENT_CONFIG_DIR and --agent, selecting an agent with no tools that is defined in a temporary directoryProject instructions, user instructions, and the built-in system prompt

Sortie creates the temporary file or directory on the launching host and removes it when the session ends. A launch carries everything in these cases:

  • The launch is remote, because the command line is not visible there and a file Sortie writes exists only on the launching host.
  • The command line names no runtime from the table, or names more than one.
  • The command line or Sortie’s environment already sets one of the switches the runtime would get. Replacing your own setting could change the model, provider, or credential the runtime uses.
  • The runtime is Kiro CLI and the command line has no --model. Selecting an agent replaces your default agent, which could carry the model, and the verification agent sets none.

A runtime that exits before it responds on the first check is checked once more with none of these switches; see credential verification.


Authentication

Sortie manages no credential for this kind. A local subprocess inherits the full parent process environment, and whichever runtime agent.command names reads its own credential from it, exactly as every other agent adapter’s subprocess does. There is no adapter-specific preflight, canary, or environment variable, because there is no fixed runtime to preflight.

Whether the credential actually works is still settled, like every kind, by the credential-verification step unless Sortie proved the same setup recently: a runtime whose initialize or session/new answers -32000 there fails the run with credential_unverified before any working turn, and one that exits before it answers fails it with the early exit report under port_exit. See the session start errors table for the exact mapping.

A remote session inherits the build host’s environment instead. This kind declares no credential variable, so a remote launch carries nothing on its own account: put what the named runtime reads on the host, or name it under worker.ssh_pass_env.

See Gemini CLI on the Agent Client Protocol and Kiro CLI on the Agent Client Protocol for what each of those two runtimes actually reads, and what a stored login versus an API-key credential changes about what the session can do.


Key differences from other adapters

AspectClaude CodeCopilot CLICodexOpenCodeAgent Client Protocol
Kindclaude-codecopilot-clicodexopencodeagent-client-protocol
Default commandclaudecopilotcodex app-serveropencodeNone; agent.command names both the runtime and its protocol-mode switch
Subprocess modelNew process per turnNew process per turnPersistent process across turnsNew process per turn, plus an export subprocessPersistent process across turns
ProtocolCLI flags + JSONL stdoutCLI flags + JSONL stdoutJSON-RPC 2.0 over stdin/stdoutCLI flags + newline-delimited stdout envelopesAgent Client Protocol: newline-delimited JSON-RPC 2.0 over stdio
Session ID sourceUUID generated by adapterUUID generated by adapterThread ID from thread/start responseDiscovered from the first JSON envelopesession/new response, or the resumed identifier when continuation is confirmed
Resume mechanism--resume <UUID>--session-id <uuid> on the first turn, --resume <uuid> after; never --continuethread/resume or automatic within session--session <sessionID>session/load or session/resume, whichever the handshake advertises, confirmed by a negative control and, for load, by observed replay
Token accountingThe result event’s model_usage_since_last_result, with the runtime’s session journal settling what it leaves opensession.usage_checkpoint events on stdout, with the session-state journal’s session.usage_record lines settling what they leave openthread/tokenUsage/updated notification, with the thread’s rollout file settling what it leaves openstep_finish reports, settled by a separate export subprocessThe session/prompt result’s usage member and the runtime’s own records, read on the runtime’s host, under the usage declaration the handshake selects; locally and over SSH alike
Model reportingPer served model, from model_usage_since_last_result and the journalPer served model, from the usage checkpoints and the journal’s session.usage_record linesFrom the thread-open response, updated on rerouteRecovered from export providerID/modelIDFrom the runtime’s records, where they name the serving model; none otherwise
Permission control--permission-mode or --dangerously-skip-permissions--autopilot + --no-ask-user + tool scopingapprovalPolicy and sandbox policy--dangerously-skip-permissions plus a synthesized policy in the inline configuration documentsession/request_permission, refused in a form that lets the turn continue, or ends the attempt when only a person could answer
Sortie’s toolsDerived config file on --mcp-config, an inline document over SSHDerived config file on --additional-mcp-config, an inline document over SSHGenerated servers re-expressed as command-line overrides; over SSH they ride the session’s standard inputGenerated servers re-expressed as an inline configuration document; over SSH it names HTTP servers onlyGenerated servers re-expressed on session/new; over SSH a runtime that accepts HTTP MCP servers reaches them, and any tool is callable only when the runtime’s own configuration authorizes the call without asking
AuthenticationANTHROPIC_API_KEY (+ Bedrock, Vertex)COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN / gh authCODEX_API_KEY or cached Codex authOpenCode-managed provider authNone managed by Sortie; the named runtime authenticates itself

External references


Related pages

Was this page helpful?