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. Parses the generated MCP configuration from the resolved configuration path on a local launch only; a remote launch holds no servers regardless of the path, because the protocol’s stdio server declaration names an executable the remote host would have to resolve, and the configuration’s environment block can carry tracker credentials that must not cross to a remote host. A server whose enabled field is present and false is dropped here.
  3. Starts the subprocess with the full parent process environment, places it in its own process group, and wires stdin, stdout, and stderr. A local launch whose command line names a runtime Sortie ships a measurement source for also carries that source’s environment variables.
  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. A response that disagrees with the measurement source the start carried, or that names a runtime a source reads when the start carried none, stops the subprocess and repeats steps 3 and 4 before step 5; see restart to settle the measurement source.
  5. Renders the parsed MCP servers into the wire format session/new (or a continuation call) will carry, omitting any HTTP server when the handshake’s mcpCapabilities.http is not true. A stdio server is never withheld this way; 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 or session/new returned error code -32000, the protocol’s own “authentication required” codecredential_unverified
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.


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

Whether a session on this kind reports token usage depends on the runtime it launches and on where that runtime runs. The protocol carries no spend counter, so a figure can only come from a measurement source Sortie ships for one specific runtime and reads outside the wire.

Where such a source applies, figures arrive when a turn ends and attribute to a model. Where none applies, the session reports nothing and agent.turn_timeout_ms is the bound that applies instead. For this kind’s declaration beside every other kind’s, see the usage reporting table.

A source supplies a figure only when two checks pass before the session is created, plus a third that plays out during it.

  • Claim. A source claims a launch only when the launch is local and agent.command, the program and every argument, names the runtime the source reads. A claim arms the runtime’s environment variables and creates a temporary directory. No source claims a launch over SSH, because a source reads output the runtime writes on the machine that ran it.
  • Handshake. The initialize response’s agentInfo.name has to be the name the source reads. The runtime’s version is not part of this check: no build is refused for its version, and a record counts only when it carries every counter the source maps. A handshake that disagrees with the claim starts the runtime again, as described below.

A session that leaves the first two checks without a confirmed source is unmeasured for the whole of its life, decided before a turn runs.

The third check plays out on whichever turn’s own wait for the source is the first to run to the source’s own conclusion, and only when agent.read_timeout_ms gives that wait enough time to let the source answer at all: a turn whose own wait ends first, before the source has answered, ends unmeasured itself but leaves the source in place for the next turn, since the source was never actually asked and answered within that turn.

Only a turn whose wait does let the source answer, and gets nothing back before any turn has had a figure out of it, drops the source for the rest of the session; a later turn then never gets its own chance to prove the source works. Once one turn has had a figure, the source stays for the session, and a later turn that gets nothing back costs that turn its own figure and nothing more.

Which way any of this went is observable rather than inferred: the once-per-session capability notice names token counts exactly when no figure will arrive, described under capability tracking; a drop that happens after that notice already went out is logged at Warn instead, per the same section. Sortie ships one such source today, for Gemini CLI.

The transport carries no per-turn token counter the orchestrator can record as spend. session/prompt’s response carries a stop reason. The one usage notification the protocol defines, usage_update, reports the tokens currently in context, the context window’s total size, and an optional cumulative session cost, none of which is the per-turn input and output count Sortie records; the adapter logs that notification at debug level and takes no figure from it.

A session no source measures emits no token_usage event. It is recorded unmeasured, contributes nothing to agent.max_tokens, advances no sortie_tokens_total series, is excluded from sortie stats token and cost figures, and is counted in that command’s tokens_unmeasured_runs.

What the dashboard shows for it is the same whichever way it went unmeasured: the session view reads “not measured” for the session’s tokens and cost, never zero, and the totals that include the run say they leave it out. Usage reporting in Workflow states the declaration for the agent kind rather than what a session’s runtime turned out to deliver, so it reads “reports tokens” for this kind even when a local session’s runtime delivers no figure.

Set agent.max_tokens and such a run also closes with run reported no token usage, token ceiling could not bound it; no dispatch-time warning precedes it, because the declaration the session was dispatched under said a figure would arrive.

sortie validate sees only the first of those two offline, because it reads the workflow and not the runtime: agent.kind.no_usage_reporting and agent.kind.no_cost_estimate fire for a configuration that launches over SSH, and not for the same one running locally.

A runtime may also attach its own vendor-namespaced token figure to a turn’s result. Sortie reads that one only as a presence signal and a lower bound on the turn, never as the spend it records, because it leaves out cache-read and reasoning tokens. Its job is to let the adapter tell a figure that covered the whole turn from one that fell short. What a given runtime publishes there, and what that figure leaves out, is on that runtime’s own page.

Model reporting follows the same split. The protocol carries no model field the adapter reads, so a session no source measures reports no model name, while a measured one attributes its figures to the model its source names.

Restart to settle the measurement source

The first start of the runtime carries the settings of every source that claimed the launch. When the handshake disagrees with that claim, Sortie stops the subprocess and runs agent.command again before the session is created, so the process the session runs on carries the settings of the source its handshake confirms and no other. Each restart logs one Info record with component=clientprotocol-adapter and name, the runtime name the handshake reported, empty when the response carried none.

Handshake resultWhat Sortie doesLog message
A source claimed the launch, and the handshake names no runtime that source readsStarts again without that source’s settings. The session is unmeasured.agent relaunched without usage settings meant for another runtime
No source claimed the launch, the launch is local, and the handshake names a runtime a source readsStarts again with that source’s settings. The session is measured.agent relaunched so its token usage can be measured: agent.command does not name the runtime it starts
  • A session start runs the runtime at most three times.
  • A remote launch is never claimed and never started again.
  • The temporary directory of a source that is no longer used is removed before the next start. The directory of the confirmed source is removed when the session ends.
  • Each start runs agent.command from the beginning, so a wrapper script runs once per start, and the session’s process ID is the last start’s. Workspace hooks do not run again.
  • Each stopped start ends through the same sequence as a session end; see process shutdown. Only the last start produces the session, so the agent implementation record is still logged once per 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.

Every tool call’s declared kind is normalized to a closed, ten-value set for the orchestrator’s tool-call metric: 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. On a local launch, session start parses it and renders its servers into the wire shape session/new carries; on a remote launch, no servers are parsed at all, so a session over SSH reaches none of Sortie’s tools and its first-turn prompt names none, for the same reason the Codex and OpenCode adapters withhold theirs on SSH: the configuration’s credential values would otherwise sit on the local ssh process’s own argument list, readable by any other user of the orchestrator host.

An HTTP server is delivered only when the handshake’s mcpCapabilities.http reports true; when it does not, the server is silently omitted and the toolServers capability entry is lowered for the rest of the session (see capability tracking). A stdio server is always attempted; the protocol’s mcpCapabilities distinguishes HTTP and SSE support, not stdio support. 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.

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 serversDelivered, unless the launch is remoteAn HTTP server is withheld because the handshake did not advertise mcpCapabilities.http.
Token countsAbsent, unless a measurement source claimed the launchThe source is dropped after a drain that produced no record. No per-turn token count reaches this adapter on the wire. 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.

The first time a turn starts, the adapter emits one notification naming every entry then in the gap state, in this fixed order. The token counts entry starts there unless a measurement source claimed the launch, so a session no source measures carries this notice on that entry alone and reads this session started with a declared capability gap in: token counts.

A source that claims the launch and is then turned away by the handshake has the launch started again without it, and the notice reads the same way. 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 worker attempt, 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:

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 generated MCP configuration is never parsed for a remote launch at all; 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.


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: 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 accountingResult event modelUsage, with top-level usage fallbackSession-state journal on diskthread/tokenUsage/updated notificationSeparate export subprocessNothing on the wire; a measurement source Sortie ships for the named runtime, local launch only, and unmeasured wherever none applies
Model reportingFrom assistant eventsFrom assistant.message/model.message recordsFrom the thread-open response, updated on rerouteRecovered from export providerID/modelIDNot available
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 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, local launch onlyGenerated servers re-expressed on session/new, local launch only, and reachable 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?