Skip to content
Write a Custom Agent Adapter

How to Write a Custom Agent Adapter

This guide shows you how to write an agent adapter: the package that lets Sortie drive a coding-agent CLI it does not bundle. The orchestrator already drives several agents (Claude Code, Codex, Copilot CLI, OpenCode, and any runtime that speaks the Agent Client Protocol) through one Go interface, domain.AgentAdapter.

A new agent is a new package behind that interface, additive only. You add code under internal/agent/<kind>/ and register it; you change nothing in the orchestrator, the retry logic, or the state machine. By the end you will have a registered adapter, unit tests, an env-gated integration test, and a checklist for the rest of what ships with it.

Sortie takes no position on how your adapter is produced: by hand, by a hired developer, or by an AI agent are all fine. What matters is that the person who opens the pull request owns the result and is accountable for it conforming to the project’s conventions, the spec, the tests, and the quality bar.

“The agent decided this” is not an answer to a reviewer’s question, and make lint and make test pass because you ran them and read the output, not because a tool reported success. This is the AI-assisted contributions stance in CONTRIBUTING.md, stated once.

Prerequisites:

  • A Go toolchain set up the project’s way. See CONTRIBUTING.md and the Makefile; this guide verifies steps with make test and go test, so you do not need to memorize build flags.
  • Familiarity with the repository layout: internal/domain holds the contract, internal/agent/ holds adapters and the shared agentcore machinery, and internal/registry wires adapters to kind strings.
  • The target agent’s CLI behavior captured in a research note. Every adapter starts from one (see the docs/*-adapter-notes.md files, for example docs/copilot-adapter-notes.md): the launch command, the output shape, the exit-code and stderr semantics, the auth model, resume support, and whether it reports tokens.
  • The agent adapter model concept for the architecture overview.

Understand the agent adapter contract

The contract is domain.AgentAdapter in internal/domain/agent.go. It has exactly three methods.

MethodWhat it must doWhen the orchestrator calls it
StartSession(ctx, params) (Session, error)Parse params.Settings, validate the workspace, resolve the binary, build per-session state, return an opaque Session. For fork-per-turn agents, start no long-lived process here.Once per issue session, before the first turn.
RunTurn(ctx, session, params) (TurnResult, error)Execute one turn for params.Prompt, deliver events through params.OnEvent, return the outcome.Once per turn; continuation turns reuse the same Session.
StopSession(ctx, session) errorTerminate cleanly and release resources. Safe to call after a failed RunTurn.Exactly once per session, after the last turn.

There is exactly one delivery mode: every event reaches the orchestrator synchronously, through the OnEvent callback on RunTurnParams, during the turn that produced it. Call it as many times as you have events to report, and only while your own RunTurn call is still running.

The orchestrator reacts to a normalized event vocabulary, not to your CLI’s native messages. These are the AgentEventType values you are most likely to emit.

Event typeConstantMeaning
session_startedEventSessionStartedThe session initialized. Carries SessionID and AgentPID.
turn_completedEventTurnCompletedThe turn finished successfully.
turn_failedEventTurnFailedThe turn finished with a failure.
turn_cancelledEventTurnCancelledThe turn was cancelled (context cancellation or stall).
token_usageEventTokenUsageNormalized input, output, total, cache-read, and cache-write counters, plus the same counters per model when the runtime splits its usage by model. Drives token-based budgets and the per-model token series.
notificationEventNotificationAn informational message, surfaced for observability. A frame that carries no text for a person, such as a native event type name, a method name, or a lifecycle marker, is reported with an empty Message, never with the frame’s name or a placeholder. The orchestrator counts it as activity for stall detection and does not keep it in the run’s events.
tool_resultEventToolResultA tool call completed. Carries ToolName, ToolDurationMS, and ToolError, plus ToolKind and ToolArgument for a shell, read, edit, or skill call. See Report tool calls.
malformedEventMalformedAn unparseable line from the agent.

The data flows like this. StartSession receives StartSessionParams (the workspace path, an AgentConfig, the kind’s resolved Settings block, an optional ResumeSessionID, SSH fields, and an MCP config path) and returns a Session whose Internal any field carries your adapter state opaquely.

RunTurn receives that Session plus RunTurnParams (the rendered Prompt, the Issue, and the OnEvent callback) and returns a TurnResult (SessionID, ExitReason, Usage, UsageMeasured, SpendUnaccounted, SpendCoverable, CoversSession, UsageProvenance, ReportedAmounts). FinalizeTurn writes the last five from the turn’s evidence; see Surface capabilities and budgeting.

The orchestrator copies SessionID and token deltas out of the events and the result; it never reads Session.Internal. A non-empty SessionID, whether from a session_started event or from TurnResult, replaces whatever session id the orchestrator has accepted so far, for this run’s dispatch record, its exit result, and what a continuation retry resumes under.

Report the id your runtime would continue the session under as of the end of that turn, never one it has already moved past: Copilot CLI’s adapter, for example, captures the session id off a turn’s own result and returns it as that turn’s TurnResult.SessionID, so a runtime-assigned change reaches the orchestrator without any extra event.

Report UsageMeasured, which your GetUsage hook and agentcore.TurnMeta carry, as true only once the runtime has reported a usage figure for the session: a false value with zero Usage records the spend as unknown rather than as nothing, which is what keeps a token budget from silently treating an unmeasurable agent as free.

Verify: you can state, for your agent, which AgentEventType values its output maps to and when each one fires during a turn.

Choose your execution model

Fork-per-turn is the default: one subprocess per turn, launched fresh, scanned while it runs, and reaped on its own exit. Claude Code, Copilot CLI, and OpenCode all work this way. The shared skeleton in internal/agent/agentcore implements the lifecycle for you, and the rest of this guide uses it.

Every one-process-per-turn kind runs on agentcore.NewForkPerTurnSession. Your adapter keeps only the argv, the line parsing, the usage source, and the environment content. When the runtime needs something the skeleton lacks, add it to agentcore.ForkPerTurnHooks in the same pull request instead of writing around it. A contract test fails a package that runs its own streaming subprocess loop.

The exception is the persistent-subprocess model. Codex keeps one long-lived codex app-server process and talks to it over a JSON-RPC handshake across turns, instead of forking. Choose it only when the CLI requires a persistent server with a protocol handshake. This guide does not cover that model; read internal/agent/codex/ and the Codex adapter reference if your agent needs it.

There is no sidecar or co-process pattern. Agents run as subprocesses in the per-issue workspace, with cwd set to the validated workspace path. If your agent is a CLI you run with a prompt, fork-per-turn fits.

Verify: you have decided fork-per-turn (this guide applies) or persistent-subprocess (follow the Codex reference instead).

Scaffold the adapter package

Create a package under internal/agent/<kind>/. Throughout this guide the placeholder kind is acme; replace it with your kind string. The layout mirrors the existing adapters.

internal/agent/acme/
    acme.go               adapter type, init() registration, the four interface methods
    command.go            passthrough config parsing and per-turn argument construction
    parse.go              output parsing and outcome classification
    acme_test.go          session and turn behavior
    command_test.go       argument construction across config permutations
    parse_test.go         parsing and classification
    integration_test.go   env-gated end-to-end test against the real CLI
FileRole
acme.goHolds the adapter struct, the init() registration, and StartSession / RunTurn / StopSession. Carries the package doc comment.
command.goHolds passthroughConfig, parsePassthroughConfig, buildArgs, and the SSH command builder. All CLI flags live here.
parse.goHolds the output parser (JSONL decode for structured agents, ANSI stripping and stderr classification for plain-transcript agents) and any marker constants.

Generic naming applies everywhere in core, but this package is where the kind string and the CLI flags belong, and nowhere else. Inside internal/agent/acme/ you may name things acme*; outside it, core code speaks only agent_* and session_*.

Verify: go build ./internal/agent/acme/... compiles the empty package.

Register the adapter

Registration runs from init() and binds your kind string to a constructor that takes no arguments. Use RegisterWithMeta so you can declare that the agent needs a launch command, what your adapter does with the MCP configuration Sortie generates for its own tools, which usage sources every session of your kind attaches, and whether any of your own pass-through keys stops the agent resuming a session.

acme.go
package acme

import (
	"github.com/sortie-ai/sortie/internal/agent/usage"
	"github.com/sortie-ai/sortie/internal/domain"
	"github.com/sortie-ai/sortie/internal/registry"
)

var usageSources = usage.Sources{
	Live: usage.Source{
		Category: usage.SourceOutput,
		Strategy: usage.StrategyLive,
		Spend: usage.SpendDeclaration{
			Shape:             usage.SpendPerTurn,
			ReasoningInOutput: true,
			ServedModel:       true,
			DuringTurn:        true,
		},
	},
}

func init() {
	registry.Agents.RegisterWithMeta("acme", NewACMEAdapter, registry.AgentMeta{
		RequiresCommand:     true,
		DefaultCommand:      "acme-cli",
		MCPDialect:           registry.MCPDialectNone,
		MCPDialectNoneReason: "acme-cli has no MCP support",
		UsageSources:        usageSources,
		CredentialEnv:       registry.DeclareCredentialEnv("ACME_API_KEY"),
		EffortForwarding:    registry.EffortNotForwarded,
		VerificationContext: registry.DeclareVerificationContext(),
		UsageLimitRecognition:         registry.UsageLimitNotRecognized,
		UsageLimitNotRecognizedReason: "the adapter does not classify a usage-limit refusal",
	})
}

// Compile-time interface satisfaction check.
var _ domain.AgentAdapter = (*ACMEAdapter)(nil)

// ACMEAdapter holds no settings: each session reads its own.
type ACMEAdapter struct{}

func NewACMEAdapter() (domain.AgentAdapter, error) {
	return &ACMEAdapter{}, nil
}

The kind string "acme" is the exact value an operator writes in agent.kind in WORKFLOW.md. Registry lookup is exact-match and case-sensitive, so acme and Acme are different agents.

RequiresCommand: true tells the orchestrator preflight to reject a workflow that selects this agent when it would launch no command. DefaultCommand is what the agent launches when the workflow gives it none, so it must be the same program you pass to ResolveLaunchTarget below; leave it out and preflight requires an agent.command, which only the default kind reads.

The var _ domain.AgentAdapter = (*ACMEAdapter)(nil) line is a compile-time assertion: if your type stops satisfying the interface, the build fails here with a clear message.

The constructor signature is fixed: registry.AgentConstructor, func() (domain.AgentAdapter, error). It takes no settings and your adapter type holds none, because one adapter instance serves every session of its kind, and a dispatch rule can give one issue its own settings block. Settings reach your code per session, through StartSessionParams.Settings, covered in the next step.

CredentialEnv names the environment variables your runtime reads as the credential for its default provider, in the order a remote launch should carry them. Sortie carries those names from its own environment into a session it starts over SSH, so an operator does not have to place the credential on every build host.

Declare it even when the answer is none: registry.DeclareCredentialEnv() with no arguments says the kind reads no credential variable, which is a different statement from leaving the field unset. Leaving it unset fails a completeness test that walks every registered kind, so CI does not go green. Each name must be a valid environment variable name, must not repeat, and must not be one the SSH carrier reserves for itself.

EffortForwarding declares what your adapter does with the effort key of its own settings block. If it delivers the level to the runtime on every turn of every session it starts, the credential-verification session and the first turn of a resumed session included, declare registry.EffortForwarded, read the key through registry.EffortSetting from the session’s settings, and add a test that calls agenttest.AssertEffortForwarding.

If it does not, declare registry.EffortNotForwarded and read the key nowhere in the package, so neither registry.EffortSetting nor registry.EffortKey appears in a non-test file; a workflow that sets effort for the kind then draws the agent.effort.not_forwarded warning.

Leaving the field unset, a forwarding kind without that test, and a not-forwarded package that reads the key each fail the completeness test in cmd/sortie.

UsageLimitRecognition declares whether your adapter returns domain.ErrUsageLimit for a provider’s usage-limit refusal, the error whose reported reset the retry waits for (see retry behavior); declare registry.UsageLimitNotRecognized with a reason in UsageLimitNotRecognizedReason until it does.

VerificationContext declares which parts of the agent’s context your runtime’s credential-verification request still carries, because it offers no switch that drops them. The components are registry.VerificationKeepsSomeTools or registry.VerificationKeepsAllTools (declare at most one), registry.VerificationKeepsProjectInstructions, registry.VerificationKeepsUserInstructions, and registry.VerificationKeepsSystemPrompt. registry.DeclareVerificationContext() with no arguments says the request keeps nothing, which is a different statement from leaving the field unset. Leaving it unset fails a completeness test that walks every registered kind. The example declares that nothing is kept, which is true only for a runtime whose every context component a launch can drop. Declare what your request still carries, and handle the matching switches as described in resolve the launch target and start the session. fakemodel.AssertConformance proves the declaration on the wire: every component it does not keep must be absent from the verification request, and Binding.UserInstructions must name the runtime’s user-level instruction file when user instructions are dropped.

MCPDialect declares the configuration syntax your runtime reads for the tool servers Sortie generates. The shared tool delivery renders Sortie’s own tool server and the operator’s mcp_config servers in that syntax, so your adapter never parses .sortie/mcp.json itself. Pick the value that names the syntax your runtime parses:

ValueSyntax the shared delivery rendersDelivered in
MCPDialectMCPServersJSONAn {"mcpServers": {...}} document, header values as ${VAR} references.Delivery.Document
MCPDialectMCPServersJSONAlwaysLoadThe same document, with Sortie’s own member marked alwaysLoad.Delivery.Document
MCPDialectMCPServersTOMLOne -c mcp_servers.<name>=<inline table> argument pair per server, header and stdio environment values by variable name.Delivery.Args and Delivery.Env
MCPDialectMCPMemberJSONThe mcp member of the runtime’s inline JSON configuration, header values as {env:VAR} references.Delivery.Document
MCPDialectACPSessionOne Agent Client Protocol McpServer object per server, for the session-creation request.Delivery.Entries
MCPDialectNoneNothing. Your adapter never delivers the configuration to the agent process. Set MCPDialectNoneReason to say why.None

Declare the truth about what your adapter does today, not what the CLI could theoretically be made to do. The declaration is load-bearing in both directions: it decides whether the runtime is pointed at the tool server and whether Sortie writes the first-turn tool advertisement, so a session is never told about a tool it cannot call.

Leaving the field at its zero value, MCPDialectUndeclared, delivers no channel either, and a completeness test over every registered kind fails it. Declare the field explicitly: start at MCPDialectNone and change the value when you wire delivery up, as deliver Sortie’s tools describes. sortie validate reports any kind with no channel as an agent.kind.no_tool_channel warning, which is how an operator finds out.

UsageSources declares where your runtime reports usage and what each report counts. The shared usage component reads that declaration, so describe the reports as the runtime writes them and let the component do the arithmetic:

  • Live is required: the runtime’s own output (usage.SourceOutput) or its protocol messages (usage.SourceProtocol), read as they arrive (usage.StrategyLive). Its Spend must declare a token shape or at least one amount.
  • Records is optional: the runtime’s own session records on its host, one entry per model request (usage.SourceJournal, usage.StrategyRecords, Shape: usage.SpendPerRequest). The component reads them when a turn ends to settle what the live source left open, such as a turn that was stopped before the runtime reported its final usage.

Pick the Spend.Shape from what one live report covers:

ShapeOne report counts
usage.SpendPerTurnThe turn’s own requests, and no other turn’s.
usage.SpendPerRequestOne model request, named by Identity.
usage.SpendProcessCumulativeEverything since the runtime’s process started.
usage.SpendSessionCumulativeEverything the runtime’s session spent, across its processes and every earlier run that resumed it. See wire session continuity.

Then set the flags that describe the counts: CacheInInput when cache reads and writes sit inside the input count, ReasoningInOutput when reasoning tokens sit inside the output count, ServedModel when each report names the model that served it, DuringTurn when the runtime reports after each model request while the turn runs, and LowerBound when the runtime may spend more than it reports. Declare Amounts for a cost the runtime reports beside or instead of tokens: Unit: domain.UnitUSD for a US dollar figure, a vendor unit’s plural display name for anything else, or Currency: true when each report names its own unit, each with an AmountShape.

RegisterWithMeta panics on a declaration that fails usage.Sources.Check, so a kind whose runtime reports neither a token count nor an amount cannot be registered.

You do not declare when figures arrive or what they name. AgentMeta.UsageDisposition() derives that pair from your sources, and every consumer of usage data reads it instead of branching on your kind string: sortie validate, the Usage reporting line of the dashboard’s Workflow screen, and the usage_arrival and usage_attribution fields on each running entry in the JSON API.

Your sourcesArrivalAttribution
A live token shape with DuringTurnincrementalper_model when the live source, or a records source that counts tokens, sets ServedModel; otherwise session_total
A live token shape without DuringTurn, or token counts in the records onlyturn_endThe same rule
No source that counts tokens, such as a live source declaring amounts onlynonenone

The example declares an incremental, per_model kind. A none session is enforced, not merely documented: the orchestrator discards a token figure such a session sends rather than trusting it, exactly as the usage reporting reference describes for every kind.

Nothing about that failure reaches your adapter’s own return values or errors, so catch it yourself: drive a real turn through your fake runtime and check it with agenttest.AssertUsageReporting (see Test the adapter), the same way the built-in claude-code and copilot-cli packages do.

A Warn log Sortie emits once per run, naming your kind (see monitor with logs), is what’s left to catch a runtime shape none of your own tests drove.

Count every session through a usage.Ledger, built in StartSession from the sources you registered:

acme.go
ledger, err := usage.NewLedger(usageSources.Live, usageSources.Records)
if err != nil {
	return domain.Session{}, &domain.AgentError{Kind: domain.ErrResponseError, Message: "usage ledger refused the kind's sources", Err: err}
}
if params.ResumeSessionID == "" {
	ledger.SessionCreated()
}

Call SessionCreated() once, before the first turn, when the runtime creates the session this run counts. For a session the run resumes, call ledger.Boundary(read) instead with a read of your records source, which fixes the requests already recorded as history. A kind without a records source makes neither call on a resumed session. Feed each live report to the ledger as it arrives, ObserveRequest for a per-request or DuringTurn report and ObserveCumulative for a session-cumulative one, and settle the turn once in the SettleUsage hook with agentcore.SettleSpend, as structured output shows.

When the kind settles its figure only after the turn’s work is over (a turn_end arrival), report it through agentcore.TurnEndUsage instead of emitting token_usage yourself. Construct it with agentcore.NewTurnEndUsage() exactly once, directly in StartSession’s own method body outside every function literal, and store the pointer on your session state. GetUsage returns its Snapshot(), ByModel() and Measured(). In SettleUsage, pass the figure agentcore.SettleSpend returns to state.usage.Settle(emit, recovered), which emits the turn’s one token_usage event, and end the turn in OnFinalize with state.usage.Finalize(emit, logger, ev, sessionID, apiDurationMS, nil, *spend) in place of agentcore.FinalizeTurn.

TestUsageDeclarationContractInvariant in agentcore enforces this shape: it fails a package that constructs agentcore.NewTurnEndUsage() and also calls agentcore.FinalizeTurn directly, references domain.EventTokenUsage directly, or constructs it anywhere but that one place. OpenCode and Copilot CLI are the worked examples: both settle in SettleUsage and pass nil to Finalize.

Declare against the code path that always runs, not against a path your runtime only sometimes feeds. A live source declared DuringTurn must report while the turn is still working, because agenttest.AssertUsageReporting fails an incremental kind whose turn settles one figure after its last tool result, and a turn_end kind that reports a figure while still working or reports more than one. Read your own parser before writing the declaration, not the CLI’s documentation. For what complete figures, provenance and lower bounds mean to the operator reading them, see spend measurement.

sortie validate turns a none disposition into operator-facing warnings, agent.kind.no_usage_reporting when a workflow also sets agent.max_tokens, a no_usage_reporting warning on each money ceiling it sets, such as agent.max_cost_usd.no_usage_reporting, and agent.kind.no_cost_estimate when it prices your kind in token_rates, so an operator learns the setting is inert instead of waiting for a ceiling that never arrives.

SessionResumeBlockedBy is optional and reports which of your pass-through keys, under the pass-through Sortie hands it, stops your runtime continuing a session across separate agent launches. Return the operator-visible key name; return the empty string when the configuration resumes normally. Leave the field out entirely when your runtime has no such key at all. That is the safe answer, and it is what the built-in Codex, Copilot CLI, OpenCode, Agent Client Protocol and mock kinds do.

acme.go
SessionResumeBlockedBy: func(passthrough map[string]any) string {
	if typeutil.BoolFrom(passthrough, "keep_history", true) {
		return ""
	}
	return "keep_history"
},

Declare it if the key exists, because Sortie hands an issue its earlier session back on a continuation, including one still queued when Sortie restarts, and on the retry after a stall while Sortie keeps running, and without the declaration a workflow that sets such a key validates cleanly and then fails on every resumed turn.

With it declared, sortie validate and startup preflight refuse the workflow with an agent.kind.session_resume error naming your key. The check is generic: the message text and severity belong to Sortie, and your declaration supplies only the key.

Read the value with the same helper and default your own settings parser uses, so the verdict cannot disagree with the launch your adapter would actually build, and do not modify the map you are handed.

Verify: a one-line test confirms the kind resolves.

acme_test.go
func TestRegistered(t *testing.T) {
	t.Parallel()
	if _, err := registry.Agents.Get("acme"); err != nil {
		t.Fatalf("registry.Agents.Get(acme): %v", err)
	}
}

Deliver Sortie’s tools

Skip this step while your kind declares MCPDialectNone. Once you declare a dialect, the shared tool delivery in agentcore does the rest, and your adapter only places its output.

  1. In StartSession, call agentcore.ResolveToolDelivery(ctx, params, target, dialect, logger). It loads the generated configuration, drops disabled servers, renders the result once to prove it renders, and returns a *agentcore.ToolSession. Close the session on every later error path; Close is idempotent. On a remote launch it also starts Sortie’s HTTP tool server and settles the SSH reverse forward before your runtime launches.
  2. On a local launch, call tools.Delivery(sshutil.ForwardOutcome{}) and place the field your dialect fills, as the table above lists. Add tools.LocalEnv() to the runtime’s environment so header references resolve.
  3. On a remote launch the rendering depends on the remote port sshd allocates, so it cannot be known at build time. Set opts.Forward = tools.Forward(place) on the SSHOptions you hand to sshutil.BuildSSHLaunch, where place is an agentcore.PlaceFunc that turns a mcpconfig.Delivery into the sshutil.LatePreamble carrying it (trailing arguments and environment entries), and read the launch’s stderr through launch.WatchStderr. A fork-per-turn adapter instead sets Tools and ToolPlacement on ForkPerTurnHooks, and the skeleton does all of this.
  4. Set NoToolChannel: tools.NoToolChannel() on the Session you return, and route events through tools.Emit(emit, nil). The first adds the gap to the worker’s decision about the advertisement, and the second emits the once-per-session gap notice. The skeleton wraps emit for you.
  5. A persistent-process adapter calls tools.AwaitConnection(ctx) after it has told the runtime about the servers, so a runtime that never connects is declared a gap within read_timeout_ms.

Per-turn arguments cross an SSH launch, but the remote command string is itself an argument of the local ssh process, readable by every other user of the orchestrator host. Never put a credential in it. A late preamble travels on the launch’s standard input instead.

Verify: call agenttest.AssertMCPInjection(t, dialect, mcpConfigPath, surface) with the launch surface your adapter produced for a local session. It fails a surface that carries a disabled server, a header value outside its reference, or a stdio environment value on an argument list.

Define the passthrough config

The <kind> block in WORKFLOW.md arrives as the raw map[string]any in StartSessionParams.Settings. It is read-only, and nil reads as an empty block. Decode it into a typed struct with the typeutil coercion helpers inside StartSession, before anything launches, so misconfiguration fails before any turn runs. This example is an invented tool-trust config for acme-cli: a model pin and two mutually exclusive trust modes.

command.go
type passthroughConfig struct {
	Model         string
	TrustAllTools bool
	TrustTools    []string
}

func parsePassthroughConfig(settings map[string]any) (passthroughConfig, error) {
	model, fault := registry.ModelSetting(settings)
	if fault != nil {
		return passthroughConfig{}, fault
	}
	pt := passthroughConfig{
		Model:         model,
		TrustAllTools: typeutil.BoolFrom(settings, "trust_all_tools", false),
		TrustTools:    slices.Clone(typeutil.ExtractStringSlice(settings["trust_tools"])),
	}
	if pt.TrustAllTools && len(pt.TrustTools) > 0 {
		return passthroughConfig{}, fmt.Errorf("trust_all_tools and trust_tools are mutually exclusive")
	}
	return pt, nil
}

registry.ModelSetting reads model the way registry.EffortSetting reads effort: a missing, null, or empty value is "", and any non-string value is a *typeutil.TypeFault. BoolFrom and ExtractStringSlice fall back to a default or the zero value for a missing or wrong-typed key. Clone any slice you keep so a later mutation cannot reach back into the settings map, and never write to the map itself.

Keep the parsed struct in the session’s own state, and put nothing parsed from settings on the adapter type. The same adapter instance starts every session of its kind, and the next session may carry a different block, either because a dispatch rule names its own settings for that issue or because an operator edited the block, which applies from the next attempt without a restart. State on the adapter would leak one session’s settings into another.

Report a block you cannot parse by returning agentcore.SettingsError(err.Error(), err) from StartSession. It builds an agent_not_found *domain.AgentError that carries the message and wraps the cause. agentcore lives under internal/, so this guide assumes what the rest of it does: your package sits inside the Sortie module.

Offline validation is separate. Declare ValidateAgentConfig in registry.AgentMeta, a func(registry.AgentConfigFields) []registry.ValidationDiag that sortie validate and preflight run against the settings block, so an operator sees the problem before dispatch. Share the parsing code between the validator and StartSession so both report the same text for the same fault.

Verify: a table-driven test exercises the parse and the validation.

make test PKG=./internal/agent/acme/...

Resolve the launch target and start the session

StartSession does setup, not execution. Parse the session’s settings, resolve the launch target, run any credential preflight, build your session state, wire the hooks, and construct the fork-per-turn session. Start no subprocess.

acme.go
func (a *ACMEAdapter) StartSession(ctx context.Context, params domain.StartSessionParams) (domain.Session, error) {
	pt, err := parsePassthroughConfig(params.Settings)
	if err != nil {
		return domain.Session{}, agentcore.SettingsError(err.Error(), err)
	}

	target, agentErr := agentcore.ResolveLaunchTarget(params, "acme-cli")
	if agentErr != nil {
		return domain.Session{}, agentErr
	}

	if params.CredentialVerification {
		if authErr := checkCredential(ctx, target, params.AgentConfig.StopGraceMS); authErr != nil {
			return domain.Session{}, authErr
		}
	}

	state := &sessionState{target: target, passthrough: pt, agentConfig: params.AgentConfig, sessionID: params.ResumeSessionID}
	hooks := agentcore.ForkPerTurnHooks{ /* BuildArgs, ParseLine, GetUsage, GetSessionID, OnFinalize */ }
	state.forkSession = agentcore.NewForkPerTurnSession(&state.target, hooks, state.baseLogger, state.agentConfig.StopGraceMS)

	return domain.Session{ID: state.sessionID, Internal: state}, nil
}

agentcore.ResolveLaunchTarget(params, "acme-cli") returns a validated LaunchTarget. It checks the workspace path (this containment check is a security boundary, not a convenience), resolves the binary from the command the workflow gives your kind or your default, splits a multi-token command into Command plus Args (so codex app-server becomes Args: ["app-server"]), and picks local or SSH mode based on params.SSHHost.

Store the returned target in your session state and pass a pointer to it into NewForkPerTurnSession, so per-turn mutations (such as a resume flag) are observed on later turns.

When your runtime lets an environment variable outrank the reasoning level you deliver, as Claude Code does with CLAUDE_CODE_EFFORT_LEVEL, set target.WithheldEnv to that variable’s name right after ResolveLaunchTarget returns, whenever the level is set. A local launch then runs without the variable, and a remote launch neither carries it nor lets the remote shell pass it on.

Before your working session’s first turn, unless the orchestrator holds a fresh proof of the same agent setup, agentcore.VerifyCredential opens a session of its own, through the same adapter and with the same Settings block as the working session, with params.CredentialVerification set, sends one fixed request through it, and closes it again; see credential verification for the mechanism. That request is the credential check every adapter gets.

The request should carry only what proving the credential needs. In a verification session, drop every context component your runtime can drop with a switch that qualifies: it applies to that one launch, persists nothing past the session, cannot change the credential, model, provider, or endpoint, and appears in the runtime’s own help text, configuration reference, or published schema. Put each such switch behind params.ReducedVerificationContext(), not behind params.CredentialVerification alone. Hand a runtime that can replace its system prompt agentcore.VerificationSystemPrompt. Do not pass a setting of your kind whose only effect is prompt text or tool visibility, and pass every other setting unchanged.

A runtime build can reject a switch it predates. When the first verification session never reaches the model, because the turn ends with the early exit report or StartSession fails with port_exit or response_error, VerifyCredential logs a warning and runs one more session with params.VerificationFullContext set. Under that flag ReducedVerificationContext() reports false, so a session that gates its switches on it applies none of them. No code beyond that gate is needed.

Map a structured credential refusal from the runtime to domain.ErrCredentialUnverified on a working turn as well as at session start, because a skipped check leaves a credential revoked in the meantime to surface on a working turn. A start-time refusal returns agentcore.CredentialRefusedError. A turn that reports through agentcore.TurnEvidence sets TerminalErrorKind to domain.ErrCredentialUnverified and TerminalMessage to agentcore.CredentialRefusedMessage of the runtime’s text. A runtime that states a refusal only on standard error or by exiting keeps the kind it reports.

Add a guard of your own only for a runtime that would block on an interactive login when no credential is present, because a blocked request never fails and so never reports anything. Gate the guard on params.CredentialVerification rather than on local-versus-SSH, so it runs identically in both modes, and let a working session (params.CredentialVerification false) skip it entirely, because the verification step has already proved the credential. Guard only the wait your runtime would block on, and leave acceptance to the shared request.

When the guard finds the credential absent, return a *domain.AgentError whose Kind is domain.ErrCredentialUnverified. When the guarded command exits on its own, return agentcore.ExitedEarly(target, result).Report(stderr), the shared early exit report, so the runtime’s own exit status and standard error reach the operator instead of a guess about the credential; the OpenCode adapter’s version probe returns it the same way.

Build the guard’s own command with target.AuxiliaryCommand, which builds the right one for a local or an SSH launch from the same call and re-verifies the workspace path immediately before building it, returning (nil, *domain.AgentError) if that fails; check that error before looking at the command’s exit status, and return it unchanged rather than reinterpreting it as a credential failure.

Bound the wait with agentcore.CredentialExchangeBound (60 seconds), generous enough for a credential that has to refresh itself over the network. Do this after ResolveLaunchTarget succeeds, because the binary must be resolved first.

The credential itself needs no code from you beyond the guard: your CredentialEnv declaration is what carries it to a remote host, and LaunchTarget.SSHOptions resolves each declared name from the orchestrator’s environment when the launch is built.

A guard you write yourself is only a shortcut for a runtime that would otherwise hang or fail silently. Every adapter gets a baseline check for free: agentcore.VerifyCredential already sends a real request and reports credential_unverified when the runtime cannot answer it, whether or not the adapter’s own StartSession does anything extra. A runtime that exits before it answers keeps the early exit report the skeleton built, port_exit, rather than becoming credential_unverified.

Verify: StartSession returns a Session with no error for a valid t.TempDir() workspace, and a domain.AgentError for a missing credential when params.CredentialVerification is true.

Construct the command

The BuildArgs hook returns the per-turn argument slice that the skeleton appends to LaunchTarget.Args. Keep the real logic in a buildArgs helper and wire the hook to it, so you can test it directly. Pass the prompt after a -- separator as a single positional argument, never interpolated into a shell string.

A runtime that reads the prompt from standard input, because a prompt beginning with @ is taken as a file name or one longer than the argument-size limit does not fit in argv, sets PromptOnStdin instead and leaves the prompt out of BuildArgs. A runtime that needs variables set, or must not inherit some of the parent’s, supplies the environment through TurnEnv, which returns the process environment and the variables sent on an SSH launch.

command.go
func buildArgs(state *sessionState, turn int, prompt string, pt passthroughConfig) []string {
	args := []string{"chat", "--no-interactive"}
	if pt.Model != "" {
		args = append(args, "--model", pt.Model)
	}
	if pt.TrustAllTools {
		args = append(args, "--trust-all-tools")
	} else {
		args = append(args, "--trust-tools="+strings.Join(pt.TrustTools, ","))
	}
	if state.resumeRequested {
		args = append(args, "--resume")
	}
	return append(args, "--", prompt)
}

The hook in StartSession wraps this helper:

BuildArgs: func(turn int, prompt string) []string {
	return buildArgs(state, turn, prompt, state.passthrough)
},

Put the subcommand and flags, model selection, tool-permission flags, and the continuation flag here. Nothing in this hook is SSH-specific: sshutil.BuildSSHLaunch shell-quotes each argument onto the remote command string for you, and the credential travels on the launch’s standard input rather than in any argument.

Verify: command_test.go asserts the argument slice across config permutations (see the testing step).

Handle output: structured vs unstructured

This is the decision that shapes the whole adapter. Read your research note and answer one question: does the CLI emit a machine-readable event stream, or a plain human transcript? The ParseLine hook handles each line of stdout; what it does depends on the answer.

Structured output

When the CLI emits JSONL (Claude Code and OpenCode do, in addition to Codex), ParseLine decodes each line into native events, hands each usage report to the session’s usage.Ledger, drives a ToolTracker to produce EventToolResult, and returns the terminal result line for OnFinalize to consume. The SettleUsage hook then settles the turn’s spend once, after the process is reaped. This sketch follows the Claude Code adapter and the incremental declaration registered above; a turn_end kind such as OpenCode emits no token_usage event from ParseLine and reports through agentcore.TurnEndUsage instead, as Register the adapter describes.

ParseLine: func(line []byte, emit func(domain.AgentEvent), pid string, spend *agentcore.TurnSpend) (any, error) {
	event, err := parseEvent(line)
	if err != nil {
		return nil, err // skeleton emits EventMalformed and continues to the next line
	}
	switch event.Type {
	case "assistant":
		spend.MarkModelReached()
		if reading, ok := requestReading(event); ok {
			provisional, isNew := state.ledger.ObserveRequest(reading)
			snapshot := state.acc.SetTurnProvisional(provisional)
			if isNew {
				state.usageMeasured = true
				emit(domain.AgentEvent{Type: domain.EventTokenUsage, Usage: snapshot, UsageByModel: state.acc.ByModel(), Model: reading.Model})
			}
		}
	case "tool_use":
		state.inFlight.Begin(event.ToolUseID, toolCallOf(event.ToolName, event.ToolInput))
	case "tool_result":
		if call, durationMS, ok := state.inFlight.End(event.ToolUseID); ok {
			emit(domain.AgentEvent{
				Type:           domain.EventToolResult,
				ToolName:       call.Name,
				ToolKind:       call.Kind,
				ToolArgument:   call.Argument,
				ToolDurationMS: durationMS,
				ToolError:      event.IsError,
			})
		}
	case "result":
		state.turnReplied = true
		state.turnReading = resultReading(event) // the turn's own counts, split by model
		captured := event
		return &captured, nil // terminal line: handed to OnFinalize as lastParsed
	}
	return nil, nil
},
SettleUsage: func(ctx context.Context, _ func(domain.AgentEvent), spend *agentcore.TurnSpend, _ bool) {
	end := usage.TurnEnd{Replied: state.turnReplied, ModelReached: spend.ModelReached()}
	recovered := agentcore.SettleSpend(ctx, state.ledger, end, state.turnReading,
		noRecords, agentcore.ReadTimeout(state.agentConfig), spend)
	if recovered != nil {
		state.acc.SetRunCumulative(recovered.Run, recovered.ByModel)
		state.usageMeasured = true
	}
},
GetUsage: func() (domain.TokenUsage, map[string]domain.TokenUsage, bool) {
	return state.acc.Snapshot(), state.acc.ByModel(), state.usageMeasured
},

requestReading and resultReading are your own parsers. Each returns a usage.SpendReading holding the counts exactly as the runtime reported them: Input, Output, CacheRead and CacheWrite as pointers, Model for the model that served the request, and Identity for the request’s id. resultReading returns a *usage.SpendReading that is nil when the result line carries no usage, and a runtime that splits the turn by model puts one entry per model in ByModel. A nil count is unknown, never zero, so leave a count nil when the report lacks it: a reading missing a count settles nothing, and the turn is reported as not covered rather than as free. The ledger applies your CacheInInput and ReasoningInOutput declarations, so InputTokens comes out including cache reads and writes and TotalTokens as input plus output. noRecords is a function returning an empty usage.RecordsRead; SettleSpend calls it only when the ledger needs the records, which a kind without a records source never does. With a records source, pass a function that reads them within the context it receives.

ObserveRequest keeps the largest reading per request id and returns the turn’s sum and whether the id is new; gate the emission on a request id’s first sighting, since a streaming CLI can repeat one id across several deltas of the same API request. state.acc is an agentcore.RunUsage, constructed once with agentcore.NewRunUsage() in StartSession and never reset between turns: SetTurnProvisional replaces the turn’s in-flight contribution and returns the raised run-cumulative snapshot to emit, and SetRunCumulative replaces the settled total with the figure the ledger settled, dropping the provisional contribution.

SettleSpend settles the turn from the result’s reading and marks the turn’s TurnSpend from the settlement: whether the figure covers the whole turn, whether it is a session total, whether it rests on an estimate, and the provenance and amounts the turn carries. A turn with no result line, or one cancelled before it, is not settled, so a turn that reached the model is reported as not covered. Register tool starts with ToolTracker.Begin(id, call) and close them with End(id), which returns the call and its duration. See Report tool calls for how to build the call.

Reset the per-turn state, message ids, turnReplied, turnReading and the tool tracker included, at the top of each RunTurn; the ledger and RunUsage are the fields that survive across turns. Return the terminal event reference so the next step can read its status.

Set FirstEventTimeout for a runtime that can hang before it writes its first line, valued with agentcore.ReadTimeout or agentcore.FirstResponseTimeout from the agent config. A turn that gets no accepted line within it ends as response_timeout. When ParseLine sees a stream that is not this turn’s, such as a session id that does not match, return agentcore.EndTurn(kind, message): the skeleton ends the turn with that kind and message and passes it no further lines.

Keep token arithmetic out of the adapter: the ledger normalizes, sums and subtracts every reading. Where you still combine domain.TokenUsage values yourself, use agentcore.MaxUsage, AddUsage, and SubtractUsage so a newly added counter is not dropped by one adapter’s fold. A contract test fails an adapter that sums token fields by hand.

Report tool calls

A tool_result event names the tool and, for four kinds of tool, shows what it worked on. Set these fields from what the runtime sent and nothing else.

  1. Map the tool. Declare a table from the runtime’s tool names to an agentcore.ToolMapping, which pairs a domain.ToolKind with the member of the tool’s JSON input that holds its main argument. The kinds are ToolKindShell (the command), ToolKindRead and ToolKindEdit (the path), and ToolKindSkill (the skill name). A tool that fits none of them stays out of the table.
var toolMappings = map[string]agentcore.ToolMapping{
	"run_shell":  {Kind: domain.ToolKindShell, Key: "command"},
	"read_file":  {Kind: domain.ToolKindRead, Key: "path"},
	"write_file": {Kind: domain.ToolKindEdit, Key: "path"},
	"use_skill":  {Kind: domain.ToolKindSkill, Key: "skill"},
}
  1. Build the call when the tool starts. agentcore.MapToolCall(toolMappings, name, input) returns an agentcore.ToolCall with the name, the kind, and the argument. A name absent from the table gives a call with the name only, and an input without the expected string member gives the kind with no argument. Name a tool the runtime serves from an MCP server or another namespace with agentcore.QualifiedToolName(server, tool), which gives {server}.{tool}, and leave its kind and argument empty.
func toolCallOf(name string, input json.RawMessage) agentcore.ToolCall {
	return agentcore.MapToolCall(toolMappings, name, input)
}
  1. Store the call in the tracker with Begin, and copy Name, Kind, and Argument from the call End returns into the tool_result event, as the sketch above does.

Do not put a command line, a title, or a protocol item type in ToolName, and do not report an argument for any other tool: file contents and tool output stay out of the event. Do not clean the text either. Sortie unwraps a bash -lc or sh -c shell wrapper, removes the workspace path from a path, replaces control characters, masks registered secrets, and cuts long text once, for every adapter, before it stores, streams, or serves the event. Pass the argument as the runtime sent it, and join several paths of one call with , . A kind outside the four is discarded together with its argument.

Unstructured output

When the CLI emits a plain transcript with no event stream, ParseLine strips ANSI, captures the text into an EventNotification for observability, and reads usage from the transcript line that reports it. The registration must still declare a live source that counts tokens or a reported amount: RegisterWithMeta panics otherwise, and a records source cannot stand in for the live one, so a CLI whose output never reports a token count or a cost cannot be registered as a kind. This sketch assumes the transcript reports each turn’s token counts on a closing summary line, declared as a live output source that counts the turn and reports only once the work is over, which derives a turn_end, session_total kind:

acme.go
var usageSources = usage.Sources{
	Live: usage.Source{
		Category: usage.SourceOutput,
		Strategy: usage.StrategyLive,
		Spend:    usage.SpendDeclaration{Shape: usage.SpendPerTurn, ReasoningInOutput: true},
	},
}

There is no terminal line on stdout, so ParseLine always returns (nil, nil) and the outcome is decided later from the exit status and stderr.

ParseLine: func(line []byte, emit func(domain.AgentEvent), pid string, spend *agentcore.TurnSpend) (any, error) {
	text := agentcore.SanitizeLine(string(line))
	if reading, ok := summaryReading(text); ok {
		state.turnReading = &reading
		return nil, nil
	}
	if strings.TrimSpace(text) != "" {
		state.work.ObserveAssistantOutput()
		spend.MarkModelReached()
	}
	if text != "" {
		emit(domain.AgentEvent{
			Type:     domain.EventNotification,
			Message:  redact.Truncate(text, 500),
			AgentPID: pid,
		})
	}
	return nil, nil
},
SettleUsage: func(ctx context.Context, emit func(domain.AgentEvent), spend *agentcore.TurnSpend, _ bool) {
	end := usage.TurnEnd{Replied: state.turnReading != nil, ModelReached: spend.ModelReached()}
	recovered := agentcore.SettleSpend(ctx, state.ledger, end, state.turnReading,
		noRecords, agentcore.ReadTimeout(state.agentConfig), spend)
	state.usage.Settle(emit, recovered)
},
GetUsage: func() (domain.TokenUsage, map[string]domain.TokenUsage, bool) {
	return state.usage.Snapshot(), state.usage.ByModel(), state.usage.Measured()
},

Truncate captured text with redact.Truncate, which masks every credential Sortie knows before it cuts, so a long line does not bloat the event and a token the runtime echoes does not reach the log. agentcore.EmitNotification(emit, text) is the helper for the simpler case where you do not need to attach the PID.

summaryReading is your own parser for the summary line, and state.turnReading is reset at the top of each RunTurn. state.ledger and state.usage are the usage.Ledger and agentcore.TurnEndUsage built in StartSession as Register the adapter describes, so OnFinalize ends the turn with state.usage.Finalize. A turn that ends without its summary line settles nothing: GetUsage keeps measured false until a figure arrives, which records the spend as unknown rather than as nothing, and a turn that printed output is reported as not covered. state.work is the per-turn work observer covered under classify the outcome; a transcript line that is not blank is the only work signal this runtime offers, so that is the one signal the adapter declares and the one it records here.

In both modes, GetSessionID returns your current session id and GetUsage returns the token snapshot, its split by model and whether the session is measured; the skeleton calls them when it builds the TurnResult on the cancellation paths. EmitSessionStartID is the one optional hook: leave it nil if you emit session_started from inside ParseLine (Claude Code does this on its init line), or set it to a closure returning the session id to emit session_started before the scan loop (Copilot CLI does this).

Verify: parse_test.go decodes testdata/ fixtures into the expected events for a structured agent, or asserts ANSI stripping and stderr classification for an unstructured one.

Classify the outcome and pick the right error kind

OnFinalize reports what it observed; it does not decide the turn. It receives emit, lastParsed (the last non-nil value ParseLine returned), exitCode, waitErr, stderrLines, earlyExit, and spend (the turn’s *agentcore.TurnSpend, which ParseLine and SettleUsage marked), fills in an agentcore.TurnEvidence, and returns agentcore.FinalizeTurn(emit, logger, ev, meta, *spend). FinalizeTurn applies the disposition rule every adapter shares, emits the terminal event, and builds the paired (domain.TurnResult, *domain.AgentError). A kind that reports through agentcore.TurnEndUsage returns state.usage.Finalize(...) instead, described under Register the adapter; it wraps this same call.

That indirection is enforced, not advisory. Constructing a domain.AgentEvent or a domain.AgentError for your own turn outcome, or calling agentcore.EmitTurnCompleted, EmitTurnFailed, or EmitTurnCancelled directly, breaks the shared decision; a test in agentcore walks every adapter package and fails on it. OnFinalize must not call EmitWarnLines either: the skeleton does that for you when FinalizeTurn returns a non-nil error.

The skeleton handles the hard cases before OnFinalize ever runs. It owns context cancellation, the stdout scan-error path, and the SSH connection failure.

It also watches whether the process wrote a line your ParseLine hook accepts (returns without error): when the process exits on its own before one, the skeleton builds the early exit report from the exit status and the collected stderr and hands it to OnFinalize as earlyExit, whatever the exit status.

A ParseLine that never rejects a line, because it treats every line as valid plain text, counts every readable line as a response; a ParseLine that rejects a line it cannot decode does not count that line, so an agent that prints only unreadable output before exiting takes this report too.

Otherwise the skeleton owns exit code 127 (binary not found, mapped to ErrAgentNotFound). It reports a turn as cancelled only when a stop Sortie issued began while the process was still running; a process that exited on its own reaches OnFinalize with its own exit status, however long the wait for its output ran.

OnFinalize covers every other ending, including a process that exited 127 before writing an accepted line, which arrives with earlyExit set, and a process that died from a signal Sortie did not send, which arrives as a non-zero exitCode with the signal in waitErr. Copy earlyExit and waitErr onto the evidence unchanged, and do not try to detect 127, signals, or an empty stdout here.

These fields carry the evidence.

FieldWhat you set it to
TerminalWhat the runtime reported: TerminalSuccess, TerminalFailure, TerminalCancelled, or TerminalAbsent when it reported nothing. Pair a failure with TerminalErrorKind and TerminalMessage.
ExitObserved, ExitCode, WaitErrThe turn’s own process exit, and the waitErr the skeleton passed, copied unchanged. The rule attaches WaitErr to the non-zero-exit error, so an exit by a signal names the signal in the error text. A persistent-subprocess adapter leaves ExitObserved false, because its process outlives the turn.
EarlyExitThe earlyExit argument, exactly as the skeleton passed it. The rule consults it only when it is a report the shared package built, so a value you construct yourself is ignored.
Work, WorkDetailPer-turn evidence that the model produced something. Do not fill the pair in by hand. Construct an agentcore.WorkObserver at the top of each turn from an agentcore.WorkSignals declaration, naming which of AssistantOutput and ToolActivity your runtime reports; call ObserveAssistantOutput and ObserveToolActivity as the stream reports them; then set ev.Work, ev.WorkDetail = observer.Report(). The detail is a compile-time constant the observer picks from your declaration, so every adapter declaring the same signals prints the same message. A runtime that reports neither signal builds no observer and leaves both fields at their zero value.

The rule reads those fields in order and stops at the first match: a terminal report wins outright, then a missing process exit, then the early exit report, then a non-zero exit, then the work evidence. A positive report from the runtime is never second-guessed by counting output, and exit code zero is never a success signal on its own, so an adapter with nothing positive to report gets a failed turn.

That holds for an adapter that declared no signal at all: it takes a row of its own, and a clean exit on that row is still a failed turn, because a kind with nothing to observe has produced no evidence either.

For a structured agent, read lastParsed and set Terminal from the result line. For an unstructured agent, derive it from the exit status, stderr, and the observer. Take a CLI whose exit-0 case is ambiguous: the process exits 0 whether or not a turn actually ran, and it prints a completion trailer on stderr when one did. The adapter opens each turn with state.work = agentcore.NewWorkObserver(agentcore.WorkSignals{AssistantOutput: true}), and the trailer is the runtime’s own success report, ranking above whatever that observer saw.

If you read stderrLines for evidence rather than only to hand it to the operator, test it for procutil.AbandonedMarker before you trust it. The slice carries that marker at the end when the drain could not finish inside its bound, which happens when a descendant of the agent inherited the standard-error handle and outlived it.

Everything collected up to that point is real, but a transcript ending there proves nothing about what the runtime went on to write, so a marker you find should disqualify whatever positive signal you were looking for and leave the turn to the shared rule.

Apply exactly this test to a completion trailer. A signal that reports a failure survives the same cut, because a line you read is a line the runtime wrote.

OnFinalize: func(emit func(domain.AgentEvent), _ any, exitCode int, waitErr error, stderrLines []string, earlyExit *domain.AgentError, spend *agentcore.TurnSpend) (domain.TurnResult, *domain.AgentError) {
	trailerSeen := classifyStderr(stderrLines)

	ev := agentcore.TurnEvidence{ExitObserved: true, ExitCode: exitCode, WaitErr: waitErr, EarlyExit: earlyExit}
	ev.Work, ev.WorkDetail = state.work.Report()

	if exitCode == 0 && trailerSeen {
		ev.Terminal = agentcore.TerminalSuccess
		state.resumeRequested = true
	}

	return state.usage.Finalize(emit, state.logger(), ev, state.sessionID, 0, nil, *spend)
},

The error kind is a control-flow decision, not a label. The orchestrator reads it to decide whether to retry. Here is what the rule produces for the completion-trailer case above.

EvidenceExitReasonError kindRetry behavior
exit 0, completion trailer on stderrEventTurnCompletednonesuccess
exit 0, no trailer, a non-blank stdout lineEventTurnCompletednonesuccess
no trailer, no non-blank stdout line, any exit statusEventTurnFailedErrPortExit, the early exit reportretryable, exponential backoff
non-zero exit after a non-blank stdout lineEventTurnFailedErrPortExitretryable, exponential backoff

Only the first row comes from evidence the adapter sets itself. The others are what the shared rule assigns to a zero exit with work, to an exit before any output, and to a non-zero exit after output, and every adapter gets them for free.

Such an adapter’s working turns read no authentication signal of their own at all, exit 0 with empty output included: a rejected credential on a working turn lands in the shared early-exit row above, carrying the runtime’s own stderr, because by the time a working turn runs, the credential-verification step has already proved the credential, either in this run or within the last 15 minutes.

The one failure kind shown here, ErrPortExit, is retryable. Other kinds are not: ErrAgentNotFound, ErrInvalidWorkspaceCwd, ErrTurnInputRequired, and ErrTurnCancelled are non-retryable, so the orchestrator releases the claim instead of scheduling another attempt. Choose the kind that reflects what the orchestrator should do next, and confirm its retry semantics in the agent errors reference.

Verify: a table test feeds exit codes and stderr fixtures to your adapter and asserts both ExitReason and the error kind with errors.As. dispositiontest.AssertDispositionContract pins each case against the shared rule for you.

Wire session continuity

StartSessionParams.ResumeSessionID carries the session id from a previous worker attempt for the same issue when that attempt is resumed: a continuation, and a stall retry that fires before Sortie restarts. A fresh dispatch and a retry after an error leave it empty. An adapter that cannot resume ignores the field; the orchestrator still functions, and each turn starts fresh. If your CLI supports resume, choose the strategy that matches how it identifies sessions.

A session-id-based resume fits a CLI that owns an addressable identifier. Claude Code generates a UUID, threads it through every turn, and passes --resume <id> when ResumeSessionID is set.

A cwd-scoped resume fits a CLI whose headless session id is not enumerable. Such a CLI cannot name its session, so the adapter adds a bare flag that continues the most recent conversation in the workspace directory, and sets it only after a turn the runtime itself reported complete (the resumeRequested field in the OnFinalize sketch above flips to true). Pick based on what your CLI exposes; both are valid.

A runtime whose own usage counter is cumulative over its session, earlier runs included, reports more than the run spent when a later run resumes that session. Declare it as such and let the ledger separate the run from the session’s history, rather than keeping a baseline of your own:

  1. Declare the live source with Shape: usage.SpendSessionCumulative. Hand each reading to state.ledger.ObserveCumulative(reading, inTurn), with inTurn true when the runtime attributes the reading to the turn in flight, and set the reading’s Own to the part the runtime attributes to the request that produced it, when it does.
  2. Give the ledger the session’s history position. A session the run creates has none: call ledger.SessionCreated(). When the runtime repeats the session’s last figure once, outside any turn, before a resumed session’s first turn, declare Replay: usage.ReplayLastFigure, and the ledger takes that reading as the history. Otherwise attach a records source, and the ledger.Boundary(read) call on a resumed session fixes the history at the total of the records it reads.
  3. Settle each turn through agentcore.SettleSpend as usual. When the history is still unknown, or a reading falls below the previous one, the ledger estimates the history as the reading less Own, keeps the run’s total so far, and marks the turn estimated, which flags it SpendUnaccounted unless the estimate came out zero.

Verify: a test asserts that turn two of a resumed session includes your continuation flag and turn one does not. For a session-cumulative counter, fakemodel.AssertConformance runs a session continuation and expects the resumed session’s figure to equal what the fake model served that session, not the session’s lifetime total.

Surface capabilities and budgeting

Make the agent’s capabilities and limitations visible to operators, because they change how a workflow must be configured.

Declare every usage report your runtime writes, as Register the adapter describes; the declaration, not your code, decides what each Sortie surface tells the operator. A token figure that arrives while the turn is still working goes out as EventTokenUsage through agentcore.RunUsage, and one that settles only after the turn’s work is over goes out through agentcore.TurnEndUsage.

If the runtime reports usage split by model, carry the split through as AgentEvent.UsageByModel instead of summing it into one total under one model name. Each entry is the run-cumulative count of one model, in the same input, output, cache-read, and cache-write classes and the same scope as Usage, and together the entries cover at most Usage class by class. The orchestrator credits each named model its share of what the event adds, which is what splits sortie_tokens_total by model. Report model names as the runtime spells them, because they also label metrics and run history; mapping them to price table or token_rates keys is not the adapter’s job. A runtime that names no model per count leaves UsageByModel nil and the whole sample counts under the event’s Model.

If the runtime reports a cost but no token count, declare the live source with Amounts and no Shape. Such a kind carries no token figure: its arrival is none, GetUsage reports measured false, token budgets are inert, and agent.turn_timeout_ms is the time-based budget that remains. The amounts it reports still settle with each turn, each in its own unit. A runtime that reports neither cannot be registered.

A third case cuts across both: the turn reached the model and you cannot prove your figures covered it. That happens when no figure came back for the turn at all, when one came back that fell short of the turn, and when the turn ended before the runtime reported its final usage, whether it was stopped, crashed, lost its connection, or paused for a person’s answer.

You do not set TurnResult.SpendUnaccounted, SpendCoverable, or CoversSession. agentcore.FinalizeTurn writes all three from the turn’s agentcore.TurnSpend, together with UsageProvenance and ReportedAmounts, and TestUsageDeclarationContractInvariant fails an adapter package that assigns one of the three. You supply the evidence. The skeleton creates one TurnSpend per RunTurn call and hands it to ParseLine, SettleUsage and OnFinalize; a persistent-subprocess adapter declares its own at the top of each turn. Mark MarkModelReached() yourself as the turn unfolds, and let agentcore.SettleSpend mark the rest from the ledger’s settlement. Then pass the TurnSpend to FinalizeTurn or TurnEndUsage.Finalize:

  • MarkModelReached(), yours to call, when the runtime served the turn output: a message or delta, a reasoning item, a tool call, a sub-agent task event, or a usage figure of this turn. A request-start announcement, the user’s own or a replayed message, and session setup are not output. A sub-agent event’s own token count proves output was served and never feeds your figure.
  • MarkSettled() when the settled figure covers the whole turn, which takes a turn that ended with the runtime’s reply, was not cancelled, and has a live source declaring no LowerBound.
  • MarkSessionTotal() as well when that figure is the runtime’s running session total, which holds what every earlier completed turn of the session spent, read or not.
  • MarkEstimated() when the figure rests on an estimated history position, as described under Wire session continuity.

A turn is flagged SpendUnaccounted when it is estimated, or when it was served output and never settled. It does not latch, so a later turn your figures do cover reports false again, which is what separates it from UsageMeasured. A flag does not change Usage: report whatever partial figure you hold, since a turn that produced nothing contributes no number and emits no token_usage event while a turn whose figure fell short reports and emits it as any measured turn does.

The orchestrator sums the flags across the run and stores the count. A completed turn that was flagged for lack of a figure stops counting when a later turn of the same session settles with a session total, because that total includes it. A stopped or failed turn keeps counting, and so does one with an estimated baseline. The stored count is what turns used_tokens_complete false on the cost_budget tool and adds unaccounted_turns to the token-ceiling log records.

Verify: set Binding.Spend and call fakemodel.AssertConformance. The suite cancels a turn once the fake model has served output and kills the runtime mid-turn, locally and, when the binding sets SSH, over SSH, and expects SpendUnaccounted true, with the figure settled from your records source when you declared one; it expects SpendUnaccounted false when the interrupt comes before any output, and checks a resumed session’s figure as described under Wire session continuity.

Tool permissions are surfaced through the passthrough config. Every run is unattended, so the default has to be a posture the runtime can carry through a turn without stopping to ask, and a pass-through value that reopens the interactive path is refused through the shared configuration-diagnostic channel rather than accepted.

Claude Code is the worked example: with no permission_mode it passes --dangerously-skip-permissions, and sortie validate refuses any value other than bypassPermissions with a claude-code.permission_mode.interactive error, because a mode that asks for approval leaves an unattended run waiting on someone who is not there. Expose only the flags your CLI actually has, and declare a diagnostic for each one that could let the agent stop and wait.

State these capabilities and limitations in three places so operators find them: the UsageSources declaration on registration, which is the one every Sortie surface reads; the adapter package doc comment; and the agent’s docs-site reference page. An operator who reads “this agent reports its cost in credits, not tokens; budget it with turn_timeout_ms” before they deploy avoids a confusing first run.

Verify: the package doc comment names the token-usage support and the tool-permission model, and sortie validate accepts a WORKFLOW.md with your agent block and extension block.

Test the adapter

Write unit tests with the project’s conventions: table-driven, t.Parallel() at test and subtest level, assertions through errors.As and errors.Is rather than string matching, fixtures in testdata/, and the standard library only.

  • command_test.go asserts buildArgs output across config permutations (model set or not, trust modes, resume on or off).
  • parse_test.go asserts parsing and classification: JSONL decode against testdata/ fixtures for a structured agent, ANSI stripping and stderr classification for an unstructured one.
  • acme_test.go starts two sessions from one adapter with different Settings blocks and asserts each keeps its own, and covers session and turn behavior against a fake acme-cli, built with agenttest.FakeRuntime, and proves the disposition your registered UsageSources derive with agenttest.AssertUsageReporting against the events and result a real turn produced. TestEveryAgentKindHasUsageReportingCoverage, in cmd/sortie, fails the build for a registered kind with no test file in its own package calling it. For sources that count no tokens, that call fails unless the turn emitted no token_usage event, no event carrying a non-zero usage figure, and a result with UsageMeasured false.
  • A test calling credentialtest.AssertCredentialVerification (internal/agent/agenttest/credentialtest) against your adapter, with cases driving it through agentcore.VerifyCredential. This one is not optional once you register a kind: TestEveryAgentKindHasCredentialVerificationCoverage, in cmd/sortie, fails the whole build for every kind registry.Agents lists that has no test file anywhere in its own package calling that function, your new kind included. Give it at least one case wanting WantVerified and one wanting WantUnverified, and add a WantSSHConnectionFailed case too if your kind’s registration sets RequiresCommand.
command_test.go
func TestBuildArgs(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name string
		pt   passthroughConfig
		want []string
	}{
		{"trust allowlist", passthroughConfig{TrustTools: []string{"read"}},
			[]string{"chat", "--no-interactive", "--trust-tools=read", "--", "do it"}},
		{"trust all", passthroughConfig{TrustAllTools: true},
			[]string{"chat", "--no-interactive", "--trust-all-tools", "--", "do it"}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			got := buildArgs(&sessionState{}, 1, "do it", tt.pt)
			if !slices.Equal(got, tt.want) {
				t.Errorf("buildArgs() = %v, want %v", got, tt.want)
			}
		})
	}
}

Give the package a TestMain that calls agenttest.Main(m, scenarios) with a map of named scenarios. agenttest.FakeRuntime(t, dir, name, scenario, params) then builds the fake executable: it links (or copies) a staged copy of the test binary itself to dir/name and writes the scenario name and its JSON-encoded params next to it. When something later runs that path, the process is the test binary starting up again; TestMain finds that sibling file and dispatches straight to the named scenario instead of running the package’s tests.

acme_test.go
// turnCounterScenario answers its first invocation with a fixed stdout
// payload and every later invocation with nothing, tracked by
// CounterFile's existence, so a test can pin a per-turn signal that a
// session's second turn must not inherit from its first.
const turnCounterScenario = "acme.turn-counter"

type turnCounterParams struct {
	CounterFile string
	Stdout      string
}

func runTurnCounter(_ []string, p turnCounterParams) int {
	if _, err := os.Stat(p.CounterFile); err == nil {
		return 0
	}
	if err := os.WriteFile(p.CounterFile, nil, 0o600); err != nil {
		return 1
	}
	return agenttest.Output{Stdout: p.Stdout}.Run()
}

func TestMain(m *testing.M) {
	agenttest.Main(m, map[string]agenttest.Scenario{
		turnCounterScenario: agenttest.Typed(runTurnCounter),
	})
}

A test builds the fake and hands its path to the adapter the same way internal/agent/claude/claude_test.go does:

dir := t.TempDir()
bin := agenttest.FakeRuntime(t, dir, "acme-cli", turnCounterScenario, turnCounterParams{
	CounterFile: filepath.Join(dir, "turn-count"),
	Stdout:      "pong",
})
session, err := adapter.StartSession(context.Background(), domain.StartSessionParams{
	WorkspacePath: t.TempDir(),
	AgentConfig:   domain.AgentConfig{Command: bin},
})

Reach for the built-in agenttest.OutputScenario instead, with no registration, when a fixed stdout, stderr, exit code, or an indefinite hang (Output.Hang) is all a turn needs. Register a scenario of your own, wrapped with agenttest.Typed as above, only when the reply must depend on the arguments or change across turns.

This package’s tests run on Windows in CI the same as every other package (.github/workflows/ci.yml), so a fixture built as a POSIX shell script breaks there; a fake runtime built with agenttest.FakeRuntime does not.

The sequentialOutputScenario in internal/agent/claude/claude_test.go is the fuller worked example: it returns one output on its first invocation and another on every later one, from the same fake binary.

The integration test runs against the real CLI and stays gated behind an environment variable. Put it in the external acme_test package, blank-import your adapter so init() registration runs, and guard it with SORTIE_ACME_TEST=1 plus the credential. Name the test so it contains Integration, which is how the release pipeline selects it with -run 'Integration'. Use no build tag: the env guard alone makes it skip cleanly when the variable is absent, so a normal make test never runs or fails it.

integration_test.go
package acme_test

import (
	"os"
	"testing"

	_ "github.com/sortie-ai/sortie/internal/agent/acme" // blank import triggers registration
	"github.com/sortie-ai/sortie/internal/registry"
)

func skipIfNotEnabled(t *testing.T) {
	t.Helper()
	if os.Getenv("SORTIE_ACME_TEST") != "1" {
		t.Skip("set SORTIE_ACME_TEST=1 to run acme integration tests")
	}
	if os.Getenv("ACME_API_KEY") == "" {
		t.Skip("set ACME_API_KEY to run acme integration tests")
	}
}

func TestACMEAdapter_Integration(t *testing.T) {
	skipIfNotEnabled(t)
	_ = registry.Agents // resolve the adapter, StartSession, RunTurn, assert ExitReason.
}

internal/agent/claude/integration_test.go has the full StartSession-RunTurn-assert body.

Verify: unit tests pass, and the integration test skips when its env var is unset.

make test PKG=./internal/agent/acme/...
go test -run 'Integration' ./internal/agent/acme/...   # prints SKIP without SORTIE_ACME_TEST

Ship checklist

A finished adapter is more than the package. Split the work by who can do it: a contributor owns everything in the pull request, and a maintainer handles the few steps that need repository access.

What you ship in the pull request

  • Research note docs/<agent>-adapter-notes.md capturing the CLI’s real behavior.
  • The adapter package under internal/agent/<kind>/ with init() registration.
  • Unit tests: command_test.go, parse_test.go, and <kind>_test.go.
  • The env-gated integration_test.go (external <kind>_test package, blank import, SORTIE_<AGENT>_TEST gate, a test name containing Integration, no build tag).
  • An agent Dockerfile examples/docker/<agent>.Dockerfile, if the agent is containerized.
  • A sample examples/WORKFLOW.<agent>.md, verified with sortie validate.
  • In-repo docs: the agent adapter contract section under docs/architecture/, its entry in the docs/architecture.md index, and docs/workflow-reference.md for the kind, the extension block, and the env vars.
  • Docs-site pages: concepts/adapter-model.md, reference/environment.md, reference/workflow-config.md, a dedicated reference/adapter-<agent>.md, a getting-started/<tracker>-<agent>-end-to-end.md tutorial, and guides/use-sortie-in-docker.md if you added a Dockerfile.
  • A README.md mention.
  • A release-pipeline job in .github/workflows/release.yml following the test-integration-<agent> pattern, wired into the final release job’s needs: list. The job stays inert until the secret it reads is provisioned.
  • make lint and make test pass locally, and the integration test skips cleanly without its env var.

What a maintainer does (coordinate, do not attempt)

These steps need repository access an outside contributor does not have. Make the integration job correct, then ask a maintainer to provision the secret and run it. The job stays skipped in your own fork because the secret is absent.

  • Provision a test account and credential for the new agent.
  • Add the corresponding repository secret (for example <AGENT>_API_KEY) in the project’s GitHub Actions settings so the integration job can authenticate.
  • Trigger the release pipeline that runs the gated job.

Avoid common mistakes

Importing another adapter package or the orchestrator. Adapters reach core through internal/domain and internal/registry only. agentcore itself imports no adapter package; neither should you import a sibling adapter.

Putting <agent>_* names or CLI flags in core packages. The kind string and the flags live in your package. Core code uses generic agent_* and session_* vocabulary.

Adding a dependency or anything that needs CGo. modernc.org/sqlite is the only SQLite driver, the binary is statically linked, and tests use the standard library. A new third-party dependency needs prior discussion.

Storing parsed settings on the adapter type. The adapter is constructed once and starts every session of its kind, so a field set in one StartSession is visible to the next. Parse params.Settings in StartSession and keep the result in the session’s state.

Retaining or calling OnEvent after RunTurn returns. The callback is valid only during the turn. Emit while the turn runs; do not stash the function for later.

Calling EmitWarnLines inside OnFinalize. The skeleton calls it for you when OnFinalize returns a non-nil error. Calling it yourself double-logs the stderr.

Handling cancellation, exit 127, or signal kills inside OnFinalize. The skeleton owns cancellation and 127. A 127 that still reaches OnFinalize comes with earlyExit set, and a signal Sortie did not send arrives as a non-zero exit with waitErr; the shared rule already decides both, so testing exitCode for either there replaces its outcome with one of your own. In particular, do not map a signal death to TerminalCancelled: only a stop Sortie issued is a cancellation, and the skeleton has already decided that.

Deciding the turn disposition yourself. Report evidence through TurnEvidence and let FinalizeTurn decide. Emitting a terminal event or building a domain.AgentError for your own outcome fails the conformance test in agentcore and re-forks a rule every other adapter shares.

Emitting your own token_usage event, or calling agentcore.FinalizeTurn directly, from a package that constructs agentcore.TurnEndUsage. Route through agentcore.TurnEndUsage.Finalize instead, covered under Register the adapter; TestUsageDeclarationContractInvariant catches this, a separate check from the TestDispositionContractInvariant that catches a hand-rolled turn outcome.

Copying another adapter’s turn loop or helpers instead of adding a skeleton hook. A private copy drifts from the skeleton’s timeout, stop, and usage handling. Add the missing capability to agentcore.ForkPerTurnHooks so every kind gets it.

Inventing a structured stream or fabricating token usage where the CLI provides neither. If there is no token data, GetUsage returns a zero figure with measured false and you emit no token_usage event. Do not synthesize numbers.

Weakening the workspace validation that ResolveLaunchTarget performs, or setting cmd.Dir directly instead of routing through LaunchTarget.BindWorkspace. Path containment and cwd validation are security boundaries, and they are re-checked immediately before every subprocess launch, not just once at session start.

ForkPerTurnSession and AuxiliaryCommand call BindWorkspace for you; if you construct a launch outside those paths, call BindWorkspace yourself rather than copying target.WorkspacePath onto cmd.Dir, or a workspace swapped for a symbolic link after ResolveLaunchTarget ran once at session start goes uncaught.

BindWorkspace also sets cmd.Env (to os.Environ() when it is nil) and rewrites PWD in it to the same verified path, so a runtime that trusts PWD over its own working directory still lands in the workspace. Assign cmd.Env before calling BindWorkspace, never after: an assignment that comes later silently overwrites the PWD it just set, and the runtime is back to trusting whatever PWD it inherited.

Integration tests that fail instead of skipping when the gating env var is absent. Skip with t.Skip. A normal make test must never fail because a credential is missing.

Shipping a change you cannot explain. You own the diff regardless of how it was produced. “The agent decided this” is not a rationale a reviewer accepts.

Trusting a tool’s claim that checks pass. Run make lint and make test and read the output yourself before you call the work done.

Listing maintainer-only actions as contributor steps. A contributor cannot add a repository secret or trigger the gated release job. Write those as steps to coordinate, not steps to perform.

Related guides and references

Was this page helpful?