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 with the optional ResumeUsage and ResumeUsageByModel that go with it, 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, ResumeUsage, ResumeUsageByModel). FinalizeTurn writes the three spend fields 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.

Set UsageMeasured 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, when your runtime’s token figures arrive and what they attribute to, 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/domain"
	"github.com/sortie-ai/sortie/internal/registry"
)

func init() {
	registry.Agents.RegisterWithMeta("acme", NewACMEAdapter, registry.AgentMeta{
		RequiresCommand:  true,
		DefaultCommand:   "acme-cli",
		MCPInjection:     registry.MCPInjectionUnsupported,
		UsageArrival:     registry.UsageArrivalIncremental,
		UsageAttribution: registry.UsageAttributionPerModel,
		CredentialEnv:    registry.DeclareCredentialEnv("ACME_API_KEY"),
		EffortForwarding: registry.EffortNotForwarded,
	})
}

// 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.

MCPInjection declares what your adapter does with the MCP configuration the worker generates for Sortie’s own tools. Three values name a delivery, and the zero value names an adapter that has declared nothing:

ValueMeaningSortie’s tools reach the session
MCPInjectionSupportedYour adapter hands the generated file’s path to the runtime.Local and SSH
MCPInjectionTranslatedYour runtime accepts no config path, so your adapter re-expresses the declared servers in the form it does parse.Local only
MCPInjectionUnsupportedYour adapter never delivers the configuration to the agent process.Never

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 sidecar 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, MCPInjectionUndeclared, delivers no channel either, so an undeclared adapter silently gets no tools and no advertisement. Declare it explicitly: start at MCPInjectionUnsupported and change the value when you wire delivery up. sortie validate reports any kind with no channel as an agent.kind.no_tool_channel warning, which is how an operator finds out.

UsageArrival and UsageAttribution declare when your runtime’s token figures reach the orchestrator and what they describe. Every consumer of usage data reads the declaration 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.

UsageArrivalPaired UsageAttributionWhat your adapter does
UsageArrivalIncrementalUsageAttributionPerModel or UsageAttributionSessionTotalEmits one token_usage event per model API request, while the turn’s work is still in flight.
UsageArrivalTurnEndUsageAttributionPerModel or UsageAttributionSessionTotalEmits at most one token_usage event per turn, and only after the turn’s work is over.
UsageArrivalNoneUsageAttributionNoneNever emits one. Every turn is unmeasured, token budgets are inert, and agent.turn_timeout_ms is the budget that remains.

UsageArrivalNone is enforced, not merely documented: the orchestrator discards a figure your runtime sends under this declaration 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.

A kind declaring UsageArrivalTurnEnd reports through agentcore.TurnEndUsage instead of emitting the event or calling agentcore.FinalizeTurn 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; return its Snapshot() from GetUsage.

When the figure is due on every ending of a turn, read it in the SettleUsage hook instead and settle it with state.usage.Settle. The skeleton calls SettleUsage once per started turn, after the subprocess is reaped, and Finalize then gets nil for recovered.

When the figure arrives in the stream instead, in OnFinalize, build an *agentcore.RecoveredUsage{Run, ByModel, Model} (ByModel is nil when your source names no model per count) when this turn settled a figure, or leave it nil when it settled nothing, and call state.usage.Finalize(emit, logger, ev, sessionID, apiDurationMS, recovered, *spend) in place of agentcore.FinalizeTurn: it emits the one token_usage event when recovered is non-nil, latches the run’s measured verdict, and then calls FinalizeTurn itself for the turn’s terminal event.

TestUsageDeclarationContractInvariant in agentcore enforces this shape: it fails a turn_end package that calls agentcore.FinalizeTurn directly, references domain.EventTokenUsage directly, or constructs agentcore.NewTurnEndUsage() anywhere but that one place. Copilot CLI and OpenCode are the worked examples of the SettleUsage path: both recover their figure from a read that only completes after the subprocess exits, settle it there, and pass nil to Finalize.

The two fields move together: UsageArrivalNone pairs with UsageAttributionNone and with nothing else. Choose UsageAttributionPerModel when a usage-bearing event names the model that produced the figure, and UsageAttributionSessionTotal when none of them does. Leaving either field at its zero value declares nothing, which the dashboard renders as not declared; declare both explicitly.

Declare against the code path that always runs, not against a path your runtime only sometimes feeds. If your authoritative figure only settles after the turn’s work is over, such as a post-exit read or an export subprocess, declare UsageArrivalTurnEnd and report solely through agentcore.TurnEndUsage: a turn_end package may not also emit an interim token_usage event from data glimpsed mid-turn, so there is no streamed fallback to design in. Read your own emission code before writing the literal, not the CLI’s documentation.

Add UsageSessionRules when a pass-through setting or the launch mode narrows the pair for part of your configuration space. Each rule pairs a condition on the resolved pass-through and a remote flag with the pair in force when it matches; the first match wins, and your declared pair holds when none does.

The built-in copilot-cli kind is the worked example: it recovers its authoritative figure from an on-disk journal the adapter never reads over SSH, so one rule narrows a remote session to UsageArrivalNone and UsageAttributionNone. Leave at least one combination matching no rule, so your declared pair stays reachable.

Your condition reads the map and nothing else: it performs no I/O, keeps no reference to the map, and never writes to it.

sortie validate turns a none declaration into operator-facing warnings, agent.kind.no_usage_reporting when a workflow also sets agent.max_tokens 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.

MCPInjectionTranslated is local only for a reason worth knowing before you pick it, and the reason is not the one people expect.

Per-turn arguments do cross an SSH launch: sshutil.BuildSSHLaunch shell-quotes each one onto the remote command string, and the remote agent receives them. That string is itself an argument of the local ssh process, so anything you put in it, per-turn arguments included, lands on an argument list every other user of the orchestrator host can read.

LaunchTarget.Args, the initial-argument slot a translating adapter would otherwise use, is empty in SSH mode for the same reason it has nothing to hold: the local command is ssh, not your agent. An adapter that translates onto the command line therefore delivers nothing on a remote launch.

SSHOptions does give you a second route, the launch’s standard input, which carries environment variables without exposing them; whether your runtime can read the translated form from there is a question about that runtime, and none of the built-in translating kinds delivers it that way today.

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)
	}
}

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

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 its own verification session already proved the credential moments before. 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, drives an agentcore.RunUsage to produce EventTokenUsage, drives a ToolTracker to produce EventToolResult, and returns the terminal result line for OnFinalize to consume. This sketch follows the Claude Code adapter, an incremental kind; a kind declaring UsageArrivalTurnEnd, such as OpenCode, never touches RunUsage from ParseLine at all. See Register the adapter for its pattern.

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 usage, id, ok := parseAssistantUsage(event); ok {
			_, seen := state.turnMessages[id]
			state.turnMessages[id] = agentcore.MaxUsage(state.turnMessages[id], usage)
			snapshot := state.acc.SetTurnProvisional(sumTurnMessages(state.turnMessages))
			if !seen {
				emit(domain.AgentEvent{Type: domain.EventTokenUsage, Usage: snapshot, UsageByModel: state.acc.ByModel(), Model: state.lastModel})
			}
		}
	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.spent = agentcore.AddUsage(state.spent, authoritativeUsage(event))
		state.acc.SetRunCumulative(state.spent) // settles the run's total, superseding every provisional figure
		spend.MarkSettled()                      // the result's figure covers the whole turn
		captured := event
		return &captured, nil // terminal line: handed to OnFinalize as lastParsed
	}
	return nil, nil
}

state.acc is an agentcore.RunUsage, constructed once with agentcore.NewRunUsage() in StartSession and never reset between turns; GetUsage returns its Snapshot(). SetTurnProvisional replaces the turn’s in-flight contribution and returns the raised run-cumulative snapshot to emit; gate the emission on a message id’s first sighting, since a streaming CLI can repeat one id across several deltas of the same API request.

SetRunCumulative replaces the run’s settled total with the figure you pass and drops the turn’s provisional contribution, superseding whatever the provisional figures already reported. This sketch assumes each terminal event reports only that process’s own spend, so the adapter keeps a running sum (state.spent) across turns and settles it. A runtime whose terminal event reports a session-wide figure needs the baseline described under wire session continuity instead. 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 and tool tracker included, at the top of each RunTurn; RunUsage is the one field that survives 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.

Populate every counter the runtime exposes. InputTokens includes prompt-cache reads and writes, while CacheReadTokens and CacheWriteTokens preserve those disjoint subsets for pricing. Set a missing cache counter to zero, and compute TotalTokens as input plus output. Use agentcore.MaxUsage, AddUsage, and SubtractUsage instead of local arithmetic 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 and no token reporting, ParseLine strips ANSI, captures the text into an EventNotification for observability, and reports no usage. 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, _ *agentcore.TurnSpend) (any, error) {
	text := agentcore.SanitizeLine(string(line))
	if strings.TrimSpace(text) != "" {
		state.work.ObserveAssistantOutput()
	}
	if text != "" {
		emit(domain.AgentEvent{
			Type:     domain.EventNotification,
			Message:  redact.Truncate(text, 500),
			AgentPID: pid,
		})
	}
	return nil, nil
},
GetUsage: func() domain.TokenUsage { return domain.TokenUsage{} },

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.

GetUsage returns the zero TokenUsage, which is how the orchestrator learns this agent has no token data. 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; 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 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 declaring UsageArrivalTurnEnd 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 agentcore.FinalizeTurn(emit, state.logger(), ev,
		agentcore.TurnMeta{SessionID: state.sessionID}, *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 once.

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. Settle such a counter with agentcore.ResumeBaseline rather than a baseline of your own:

  1. In StartSession, build it with agentcore.NewResumeBaseline(resume, resumeByModel). Pass a zero TokenUsage and a nil map for a session the run creates, including one a failed resume falls back to, and params.ResumeUsage and params.ResumeUsageByModel for one it resumes. A nil resume gives an unknown baseline. The baseline uses the per-model map only when it sums to resume in every class.
  2. On each reading of the counter, call Observe(reading, readingByModel, own) and report Run() as the run-cumulative figure, and its per-model split as the figure’s UsageByModel. Pass a nil readingByModel when the runtime reports no split. own is the part of the reading the runtime attributes to the current request or turn, or nil. A reading of all zeros has no effect. When the baseline is unknown, or a reading falls below the previous one, the baseline is estimated as the reading less own, the run’s total so far is kept, and Observe returns estimated as true.
  3. Call spend.MarkEstimated() on a turn whose reading was estimated, because own may not cover everything the reading added, such as calls a sub-agent made. Observe returns estimated as false when the re-derived baseline is zero in every counted component, since nothing was lost.
  4. Report Resume(), the latest reading and nil while the baseline is unknown, as TurnResult.ResumeUsage on every turn, and its second result, the reading’s split by model, as TurnResult.ResumeUsageByModel. The orchestrator keeps the figures of the working session’s last turn with the run’s history and hands them to the next run that resumes the session as StartSessionParams.ResumeUsage and StartSessionParams.ResumeUsageByModel. A turn that ends without a usable reading must report nil, not the figure of an earlier turn, because the runtime may have saved calls that figure omits. The credential-verification session receives no ResumeUsage and its figure is not kept.

A run that resumes a session whose previous run kept no figure, or kept one before Sortie recorded it, starts with an unknown baseline, so that one turn is estimated and flagged.

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, it also asserts that a resumed session started with ResumeUsage reports only what its own turns added.

Surface capabilities and budgeting

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

Token-usage emission is optional. If the CLI reports tokens while a turn is still in flight, drive an agentcore.RunUsage and emit EventTokenUsage as figures arrive. If your authoritative figure only settles after the turn’s work is over, report it through agentcore.TurnEndUsage instead, covered under Register the adapter.

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 CLI reports no tokens at all, leave TurnResult.Usage at the zero value, emit no EventTokenUsage, and the agent is budgeted by time only, through agent.turn_timeout_ms. Token budgets are then inert, and agent.turn_timeout_ms is the time-based budget that remains.

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, and TestUsageDeclarationContractInvariant fails an adapter package that assigns one of them. You supply the evidence. The skeleton creates one TurnSpend per RunTurn call and hands it to ParseLine and OnFinalize; a persistent-subprocess adapter declares its own at the top of each turn. Mark it as the turn unfolds, and pass it to FinalizeTurn or TurnEndUsage.Finalize:

  • MarkModelReached() 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 you hold a figure the runtime reported as covering the whole turn. Mark it only when the runtime’s completion signal for the turn implies that, so a turn cut short is never settled.
  • 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 resume baseline, 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: the conformance suite in internal/agent/agenttest/fakemodel interrupts a turn once the fake model has served output and expects SpendUnaccounted true, and expects it false when the interrupt comes before any output.

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 UsageArrival and UsageAttribution 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 no token usage; 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 your registered usage-reporting declaration with agenttest.AssertUsageReporting against the events and result a real turn produced. For an arrival of UsageArrivalNone, 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 declaring UsageArrivalTurnEnd. 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 the zero TokenUsage 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?