agent-swarm.devagent-swarm.dev
Guides

Adding a Harness Provider

Implement a harness provider — the adapter contract, reference implementations, live steering capabilities, and wiring checklist

This guide documents the harness-provider contract used by agent-swarm workers, the six reference implementations, and every hook that must be wired when adding a new provider.

A harness provider is the runtime that actually drives an LLM: it owns the subprocess (or in-process SDK) that reads the user prompt, talks to the model, invokes tools, and streams events back. The swarm treats providers as plug-ins behind a single TypeScript interface (ProviderAdapter). Workers select one at boot via HARNESS_PROVIDER.

For configuring an existing provider, see Harness Configuration. This guide is for implementing a new one.

For an operator-facing comparison of the install choices, see the Provider Capability Matrix.

Supported today: claude (Anthropic Claude Code CLI), pi (pi-mono, in-process via @earendil-works/pi-coding-agent), codex (a fresh Codex app-server inside each isolated task subprocess), devin (Cognition Devin via /sessions), claude-managed (Anthropic Managed Agents. Sessions execute in Anthropic's cloud sandbox), opencode (OpenCode 2 embedded in-process via @opencode/sdk, mapping its session events), acp (a curated OpenCode, Gemini CLI, or GitHub Copilot CLI preset, or a custom Agent Client Protocol command), dsh (DeepSeek's @deepseek-ai/dsh CLI, routed through OpenRouter or the DeepSeek API; see Provider Auth: DeepSeek Harness), amp (the Amp CLI in execute mode; see Provider Auth: Amp), cursor (Cursor's agent through @cursor/sdk; see Provider Auth: Cursor), grok (the xAI Grok CLI on the swarm's ACP client; see Provider Auth: Grok).


Codex model availability

The direct Codex picker includes gpt-6-sol and gpt-6-luna, verified against the official Codex catalog on September 23, 2026. Use bare model IDs with the CLI and app-server. Both models support text and image input, tools through Responses, a 1,050,000-token context window, and 128,000 output tokens. Their API reasoning efforts are none, low, medium, high, xhigh, and max; the swarm displays none as off. Ultra is an orchestration mode, not a model reasoning effort.

The picker also includes gpt-6.1-sol, verified against the official API and Codex catalogs on September 29, 2026. It has the same context, output, input, and tool limits as GPT-6 Sol, but its API reasoning efforts are low, medium (default), high, xhigh, and max. It does not support none, so the swarm shows no off level for it.

Standard API prices, in USD per million tokens, are:

ModelInputCached inputCache writeOutput
GPT-6.1 Sol20.102.5010
GPT-6 Sol20.202.5010
GPT-6 Luna0.100.010.1250.50

Above 272K input tokens, input and cache rates double and output rates multiply by 1.5 for the full request. The snapshot preserves these tiers; the existing worker estimate and pricing-table seeder use base rates, so long-context costs remain estimates. Defaults, aliases, and portable tiers remain on their existing models while account availability rolls out.

Codex access depends on account, client, and sign-in method. GPT-5.5 retires from ChatGPT-authenticated Codex on October 14, 2026, but its API availability is unchanged. Keep API and ChatGPT lifecycle decisions separate.

Worker exit limit

SWARM_EXIT_AFTER_TASKS=N stops a worker after N completed assigned or resumed task executions. Offer review and mention sessions do not count toward this limit. The worker accepts no further work once completed and active sessions reach N. It waits for active sessions, then uses the normal shutdown routine and exits with code 0. Shutdown terminates process groups, emits telemetry, and sends /close with runtimeShutdown: true. Unset or invalid values preserve continuous operation. Per-task Docker pools set N to 1.

Credential readiness recovery

After boot, a live provider change triggers a fresh credential report. A not-ready report parks the worker in waiting_for_credentials; the runner continues polling and rechecks credentials at most once every 30 seconds after reconciling its environment. Once a ready report succeeds, the API restores dispatch eligibility without a restart, using the same runtime identity in multi-runtime mode. Failed checks or report deliveries also retry on that interval, and only one check/report runs at a time.

Ready workers retain the five-minute Bedrock enumeration refresh. CRED_CHECK_DISABLE=1 disables both boot checks and steady-state refreshes.

1. The ProviderAdapter contract

Claude execution transports

ClaudeAdapter supports CLAUDE_TRANSPORT=cli|sdk, with CLI as the default. The SDK transport uses pinned @anthropic-ai/claude-agent-sdk and the existing installed Claude executable. It still starts a Claude Code child process. Both transports share credential selection, session configuration, event normalization, summaries, costs, and context tracking. Both transports remove the legacy AGENT_SWARM_CLAUDE_OAUTH_TOKEN mirror from the Claude child environment. Adapter-owned summaries retain their selected credentials outside that child. Claude filters its standard OAuth variable from command hooks, but retains API keys. Only enable trusted project hooks. Session initialization persists the selected transport in providerMeta.transport.

Transport selection occurs for each new session. The agent runtime editor stores an optional agent-scoped CLAUDE_TRANSPORT configuration row. An omitted value preserves that row, null deletes it, and cli or sdk assigns it. The worker independently rejects an effective bridge under SDK selection. It preserves Swarm context preambles instead of native SDK resume.

Source of truth: src/providers/types.ts.

export interface ProviderAdapter {
  readonly name: string;
  readonly traits: ProviderTraits;
  createSession(config: ProviderSessionConfig): Promise<ProviderSession>;
  canResume(sessionId: string): Promise<boolean>;
  formatCommand(commandName: string): string;
}

export interface ProviderTraits {
  hasMcp: boolean;
  hasLocalEnvironment: boolean;
  steerModes?: Array<"steer" | "queue">;
}

export interface ProviderSession {
  readonly sessionId: string | undefined;
  onEvent(listener: (event: ProviderEvent) => void): void;
  waitForCompletion(): Promise<ProviderResult>;
  abort(reason?: string): Promise<void>;
  deliverSteering?(delivery: {
    mode: "steer" | "queue";
    text: string;
  }): Promise<
    | { delivered: true; mode: "steer" | "queue" }
    | { delivered: false; reason: string }
  >;
}

What each member does

MemberResponsibility
nameShort identifier used in logs ("claude", "pi", "codex").
traitsAdvertise MCP/local-environment behavior and live steering modes. An absent steerModes means no live steering.
createSession(config)Spin up a new session for a task. Must not block on model output — return a ProviderSession immediately and stream events asynchronously.
canResume(sessionId)Return true iff the provider can continue a previous session by ID. Used when the runner resumes paused work.
formatCommand(commandName)Map a swarm slash-command (e.g. "review-pr") to the form the underlying CLI expects (e.g. /review-pr, /skill:review-pr, or inlined via skill resolver).
session.onEventRegister a listener. The adapter must call every registered listener for every ProviderEvent.
session.waitForCompletion()Resolve once the session ends; returns { exitCode, sessionId, cost, output, isError, errorCategory, failureReason }.
session.abort()Cancel in flight (SIGTERM, SDK abort signal, etc.). Must be idempotent.
session.deliverSteering()Optional live-input transport. Return the mode actually delivered, or an undeliverable reason so the server can promote the message to a follow-up task.

ProviderSessionConfig inputs

interface ProviderSessionConfig {
  prompt: string;           // user prompt
  systemPrompt: string;     // composed system prompt
  model: string;            // resolved model id or ""
  role: string;             // "lead" | "worker" | ...
  agentId: string;
  taskId: string;
  apiUrl: string;           // swarm API base URL for callbacks
  apiKey: string;           // swarm API key
  cwd: string;              // workspace dir
  /** @deprecated Always undefined — native resume removed in the 2026-05-28 plan. */
  resumeSessionId?: string;
  iteration?: number;
  logFile: string;          // jsonl log path
  additionalArgs?: string[];
  env?: Record<string, string>;
}

ProviderEvent (the normalized stream)

Every provider translates its native events into this tagged union:

type ProviderEvent =
  | { type: "session_init"; sessionId: string }
  | { type: "message"; role: "assistant" | "user"; content: string }
  | { type: "tool_start"; toolCallId: string; toolName: string; args: unknown }
  | { type: "tool_end"; toolCallId: string; toolName: string; result: unknown }
  | { type: "result"; cost: CostData; output?: string; isError: boolean; errorCategory?: string }
  | { type: "error"; message: string; category?: string }
  | { type: "raw_log"; content: string }
  | { type: "raw_stderr"; content: string }
  | { type: "custom"; name: string; data: unknown }
  | { type: "context_usage"; contextUsedTokens: number; contextTotalTokens: number; contextPercent: number; outputTokens: number }
  | { type: "compaction"; preCompactTokens: number; compactTrigger: "auto" | "manual"; contextTotalTokens: number };

The runner consumes these events (not the provider's native ones) to post progress to the swarm API, detect tool loops, charge cost, and update task state. Implementing this translation is the bulk of the work for a new provider.


2. Reference implementations

FileTransportAuth
src/providers/claude-adapter.tsBun.spawn of claude CLI with --output-format stream-json, JSONL stdout parsingCLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY, or a gateway / Foundry / Bedrock / Vertex route (Model Gateways)
src/providers/pi-mono-adapter.tsIn-process via createAgentSession from @earendil-works/pi-coding-agent; no subprocessANTHROPIC_API_KEY / OPENROUTER_API_KEY / ~/.pi/agent/auth.json — or, when BEDROCK_AUTH_MODE=sdk or MODEL_OVERRIDE=amazon-bedrock/*, AWS SDK default chain (probed via ListFoundationModels; see pi-mono + Amazon Bedrock below)
src/providers/codex-adapter.tsParent adapter spawns src/commands/codex-session-runner.ts. The child starts one codex app-server, then bridges JSON-RPC notifications and requests to normalized events and controls over bidirectional JSONL.OPENAI_API_KEY or ChatGPT OAuth stored in swarm_config.codex_oauth (see §6)
src/providers/claude-managed-adapter.tsAnthropic SDK client.beta.sessions.events.stream (SSE); session executes in Anthropic's managed cloud sandbox, worker is a thin relayANTHROPIC_API_KEY plus pre-existing MANAGED_AGENT_ID + MANAGED_ENVIRONMENT_ID from one-time claude-managed-setup CLI (see §13)
src/providers/devin-adapter.tsCognition Devin v3 REST API; worker polls the remote sessionDEVIN_API_KEY + DEVIN_ORG_ID
src/providers/opencode-adapter.tsOpenCode 2 embedded in-process via @opencode/sdkProvider-specific API key, commonly OPENROUTER_API_KEY
src/providers/acp-adapter.tsBun.spawn of the selected OpenCode, Gemini CLI, Copilot, Qwen Code, Kiro CLI or Goose preset, or a custom target, driven over stdio with @agentclientprotocol/sdk's ndjson ClientSideConnection. The OpenCode preset injects a configured OPENROUTER_BASE_URL into OPENCODE_CONFIG_CONTENT as provider.openrouter.options.baseURL, alongside the model fallback.The ACP target owns its model auth and receives a session-scoped aseph_ token as the swarm MCP bearer
src/providers/dsh-adapter.tsPreinstalled DeepSeek Harness CLI, headless JSON over stdin/stdoutOPENROUTER_API_KEY for openrouter/ models; DEEPSEEK_API_KEY for native IDs. See DeepSeek Harness for model routing. Swarm MCP via an inserted dsh-mcp-client, cost/context from step usage, reasoning effort; no steering or native resume
src/providers/amp-adapter.tsPreinstalled Amp CLI (amp -x --stream-json --stream-json-input), Claude-compatible JSONL over stdin/stdoutAMP_API_KEY (an access token from ampcode.com). Swarm MCP via a per-task --mcp-config, system prompt via a per-task plugin mode, queued steering, tokens priced from the amp rows; every thread is stored on ampcode.com
src/providers/grok-adapter.tsPreinstalled xAI Grok CLI as an ACP server (grok agent --no-leader stdio), driven by the shared ACP client in acp-adapter.tsXAI_API_KEY, or OPENROUTER_API_KEY for openrouter/<id> models (see Grok). Fresh GROK_HOME per session with Claude/Cursor compat off, swarm MCP via session/new, system prompt appended via _meta.rules, cost from xAI's billed costUsdTicks, effort via --reasoning-effort, no steering
src/providers/cursor-adapter.tsIn-process @cursor/sdk local runtime (Agent.create + agent.send); the agent loop and tools run in the worker, inference on Cursor's hosted modelsCURSOR_API_KEY (user or service-account key; see Cursor for plans and key creation). Swarm MCP over HTTP with the per-task headers, steer + queue steering, token cost priced from vendor rates, reasoning effort via the model's own params. The swarm prompt rides in the first message unless the account has systemPrompt access (CURSOR_NATIVE_SYSTEM_PROMPT=true)

ACP session tokens identify the session's agent and task. On the HTTP config routes, non-lead session tokens receive masked secrets and cannot write or delete config; changing X-Agent-ID cannot grant lead permissions. The secret scrubber redacts aseph_ tokens in logs. Session completion starts revocation without waiting for its response. Each revoke request has a five-second timeout and logs HTTP, network, or timeout failures. If revocation fails, the token remains valid until its 24-hour expiry.

ACP config-option metadata is scrubbed with scrubSecrets before session events are emitted, including strings in grouped choices. This protects both persisted dashboard metadata and diagnostic logs while preserving non-secret model IDs, descriptions, and boolean values.

After a prompt completes, ACP persists a bounded, scrubbed acp_prompt_response raw-log diagnostic with the optional usage and _meta fields. Reported input/output/cache counters feed cost telemetry; missing counters are undefined at the adapter boundary but become zero in the session-cost API and DB. Persisted costs and UI displays cannot distinguish missing usage from measured zero; the raw diagnostic retains absence. Unresolved pricing stays unpriced. See cost and context computation.

Pi swarm MCP setup

Pi retries swarm MCP initialization and tool discovery up to three times, waiting 500 ms and then 1 s between attempts. Each attempt starts a fresh MCP client. After all attempts fail, it logs a warning and creates the session without swarm MCP tools.

Local subprocess teardown

Local CLI adapters start each harness in a dedicated POSIX process group. Normal completion, abort(), runner shutdown, and fatal runner errors terminate the whole group with SIGTERM, followed by SIGKILL after a short grace period. This also removes MCP servers and other descendants that outlive the harness CLI; Windows keeps direct-PID termination because it has no POSIX process groups.

Task tool preloading

The default-on TASK_TOOL_PRELOAD_ENABLED feature lets a new MCP session preload selected swarm tools by task type or schedule. The API adds Claude's per-tool anthropic/alwaysLoad metadata and a registered MCP instruction to call already-available tools directly. This does not change the provider adapter contract, tool permissions, or other harnesses' loading policy. See configuration and limits.

Live task steering

Live deliveries include [steering <id>] From <sender>: <body>, where the API resolves the sender's name and kind (user or agent), or system. Missing names fall back to the sender ID and kind. The marker remains unchanged for accept-steer. The runner uses this envelope for every provider's live delivery; the legacy Codex hook uses the same renderer. Template resolution failures still deliver the bare body. The dashboard displays the same sender label in steering rows, queued previews, and session activity.

Running tasks can receive more input through the optional ProviderSession.deliverSteering() method. queue adds context at the next turn boundary. steer requests an interrupt of the current turn; provider implementations must not advertise it unless they can deliver that semantic.

ProviderAdvertised modesmode: "steer" resultNotes
pisteer, queuesteeredNative steer() and followUp().
claude-managedsteer, queuesteeredSends ordered user.message events.
opencodequeuequeuedpromptAsync can add input at a turn boundary; abort-and-re-prompt is not advertised because live E2E found it undeliverable.
devinqueuequeuedThe message API accepts working sessions but does not guarantee interruption.
claudequeuequeuedRaw CLI stream-json can queue only.
codexsteer, queuesteered or queuedThe per-task app-server receives native turn/steer. Queued input starts after the active turn ends. See below.
cursorsteer, queuesteered or queuedrun.steer() reaches the in-flight run; a message the SDK reverts to a follow-up, and every queued one, starts the next run on the same agent.
ampqueuequeuedNo interrupt primitive. Queued input is appended to the next tool result; see Amp.
acpnonen/aACP exposes a single in-flight session/prompt; the only interrupt is session/cancel (a full abort), so no steering mode is advertised.

The static PROVIDER_STEER_CAPABILITIES map drives supportedSteerModes in task responses and onUnsupported: "fail" admission. It must match adapter.traits.steerModes ?? []. src/tests/provider-steering-capabilities.test.ts iterates every name in ProviderNameSchema, constructs its adapter, and fails with the provider name if the two drift.

Claude queue-steering gate

Claude queued input requires --input-format stream-json, which cannot be combined with -p <prompt>. With CLAUDE_QUEUE_STEERING unset, the adapter uses stream-json only when claude --version reports >= 2.1.205 and the effective binary is not a claude-bridge/tmux wrapper. Otherwise it preserves the long-standing -p path and the session has no live delivery method.

The operator override accepts:

  • 0, false, off, no: force the queue-steering path off.
  • 1, true, on, yes: force stream-json on without the automatic version/wrapper decision.
  • unset, empty, or another value: use automatic probing.

Codex app-server delivery

Each Codex task starts a fresh codex app-server inside the isolated session runner. The parent and child keep their JSONL control channel open. The child sends JSON-RPC requests and notifications to the app-server.

mode: "steer" sends native turn/steer, which adds input to the active turn. mode: "queue" stores input in the adapter and starts the next native turn after the active turn ends. Input that arrives before app-server readiness remains pending until the session is ready. abort() sends the native interrupt request, then terminates the isolated process group if Codex does not finish during the bounded grace period.

Queued input counts as delivered only after Codex accepts its turn. If the session ends first, delivery fails and the pending input remains eligible for follow-up promotion. Waiting for that acknowledgement does not block cancellation or other tasks.

The worker does not use a shared app-server daemon. It does not resume native Codex threads. Follow-up task continuity uses the swarm context preamble.

Codex hook delivery for legacy exec sessions

src/hooks/codex-hook.ts still supports legacy codex exec sessions. The worker image registers the hook for SessionStart, PostToolUse, and Stop through /etc/codex/requirements.toml. It polls pending steering messages, marks each one delivered, and injects the rendered envelope through hook output.

App-server sessions set SWARM_CODEX_APP_SERVER=1. The hook returns before it polls in that mode. The worker then remains the only steering transport. PreToolUse carries no steering because Codex drops its additionalContext. The image registers it for the PR body leak guard, which runs in every session mode and blocks gh pr create|edit on a public repo with exit code 2.

Claude and Codex hooks also check gh pr create after the leak guard when PR_ROOM_REPOS lists the target repository. The body must contain PR room: prr- followed by ten lowercase base32 characters. Create the room with the pr-room skill (app action createRoom) and pass the returned prBody through --body-file. This workflow reminder allows edits and unresolved inputs. An empty PR_ROOM_REPOS disables it.

For user-facing modes, degradation, entry points, and message states, see Steer a running task.

pi-mono + Amazon Bedrock auth (alpha)

Alpha: session summaries, memory rating, spend tracking and model tiers may be missing on Bedrock.

Mode selection

Bedrock SDK mode is active when either:

  1. BEDROCK_AUTH_MODE=sdk is set in swarm_config (explicit), or
  2. BEDROCK_AUTH_MODE is absent and MODEL_OVERRIDE starts with amazon-bedrock/ (prefix-inference fallback — preserves the earlier prefix-inference behavior).

Set BEDROCK_AUTH_MODE=bearer to authenticate with a Bedrock API key in AWS_BEARER_TOKEN_BEDROCK. The worker reports that token as missing when it is unset, even if another provider API key is configured. Both Bedrock modes require AWS_REGION and run the same live model enumeration probe.

BEDROCK_AUTH_MODE is a validated optional swarm_config key (values: sdk | bearer; see src/be/swarm-config-guard.ts) and a reloadable env key (see src/commands/runner.ts).

Credential probe

When either Bedrock mode is active, the worker runs a real ListFoundationModels call via @aws-sdk/client-bedrock (dynamically imported — the API binary never loads the SDK):

  • Success → ready: true, with satisfiedBy: "sdk-delegated" in SDK mode or "env" in bearer mode. The worker proceeds to claim tasks.
  • Failure → ready: false with a classified error hint. The worker parks in credential-wait until credentials are fixed.

Error categories classified by classifyAwsSdkError (src/utils/aws-error-classifier.ts):

CategoryTrigger exampleHint
aws-authExpiredTokenException, CredentialsProviderErrorRun aws sso login or refresh credentials
aws-throttleThrottlingException, ServiceQuotaExceededExceptionWait / request quota increase
aws-accessAccessDeniedException: not authorizedCheck IAM policy for bedrock:*
aws-modelValidationException, ResourceNotFoundExceptionCheck MODEL_OVERRIDE and region

Accepted credential sources

In bearer mode, supply AWS_BEARER_TOKEN_BEDROCK. In SDK mode, use any credential source the AWS SDK accepts:

  • AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ optional AWS_SESSION_TOKEN)
  • AWS_PROFILE resolved against ~/.aws/credentials and ~/.aws/config
  • AWS SSO sessions configured in ~/.aws/config
  • EC2 IMDS instance role / ECS task role
  • Web-identity / OIDC (AWS_WEB_IDENTITY_TOKEN_FILE, AWS_ROLE_ARN)
  • credential_process and assume-role chains

Configuration reference

KeyValuesDefault
BEDROCK_AUTH_MODEsdk | bearerinferred from MODEL_OVERRIDE prefix
AWS_BEARER_TOKEN_BEDROCKBedrock API keyrequired in bearer mode
AWS_REGIONany Bedrock-enabled regionrequired — unset reports a not-ready Bedrock state (no region is fabricated)

AWS_REGION must be set explicitly so enumeration runs against the same region as inference. When it is unset, the worker reports a not-ready Bedrock state with a "set AWS_REGION" hint and does not guess a region.

Live model enumeration

The credential enumeration also produces the usable model set, region-scoped to AWS_REGION. Usable = harness-drivable ∩ AWS-invocable:

  1. AWS-invocable — the union of:
    • ListFoundationModels filtered to on-demand TEXT models that are ACTIVE (base foundation-model ids), and
    • ListInferenceProfiles ids — the cross-region inference-profile ids (us. / eu. / apac. / au. / global.). The newest Claude models on Bedrock are invocable only through an inference profile and never appear in ListFoundationModels, so this union is what keeps the current Claude models available.
  2. Harness-drivable — the subset pi-ai's Converse harness can drive, from getModels("amazon-bedrock"). Each id is a valid pi-ai id (base or profile) and round-trips through MODEL_OVERRIDE=amazon-bedrock/<id>.

Ids are matched exactly and the pi-ai id is stored/displayed (the id the harness can drive). Models the harness can't drive — and harness models the account can't invoke — are both excluded, so the picker never surfaces an id that would fail with invalid model identifier at inference time.

ListFoundationModels returns models that exist in the region, not strictly those the account has enabled access to; the on-demand/ACTIVE filter narrows it, but base access-grant is not fully enumerable from the catalog. The inference-profile union is what makes the current models accurate.

The worker reports the intersected list to the API via the existing PUT /api/agents/:id/credential-status channel as an optional bedrock block inside cred_status (no new DB column — rides the migration 055 JSON column). The block carries: { region, probedAt, ready, models: [{id, name}], error? }. It refreshes at boot and on a throttled ~5-minute interval, so access enabled after boot appears without a worker restart.

Bedrock probe card (Credentials tab)

A dedicated AWS Bedrock card appears in the Credentials tab for all pi-harness agents. It renders a read-only ready/blocked/pending classification with parity to the main credentials card, plus region, probe timestamp, usable model count, and error text when blocked.

  • 🟢 Ready — SDK credential chain valid; models enumerated.
  • 🔴 Blocked — probe failed; error text shown; worker is parked at credential-wait.
  • ⚫ Pending — worker hasn't reported yet (booting, or Bedrock mode not active).

The dashboard model picker for the pi harness:

  • Prefers the live list when the worker has reported it.
  • Falls back to the static modelsdev-cache.json snapshot until the first worker report arrives.
  • Is never blank — there is always at least the snapshot list to choose from.
  • Surfaces the probe failure reason as subtext when a worker reported but its probe failed (ready:false), instead of a silently disabled group.

Cost & context tracking

Every adapter emits one CostData row per CLI invocation and one or more context_usage events per session. The dollar value is recomputed server-side against the seeded pricing table (Phase 2 of the cost-tracking plan); the resulting costSource enum is surfaced in the UI. The unified input + cache + output context formula (Phase 9) replaces every adapter's previous per-provider arithmetic so cross-provider percent comparisons make sense.

Full story: Cost & context computation.

All four are wired together by the factory at src/providers/index.ts:

export async function createProviderAdapter(provider: string): Promise<ProviderAdapter> {
  switch (provider) {
    case "claude":
      return new ClaudeAdapter();
    case "pi": {
      const { PiMonoAdapter } = await import("./pi-mono-adapter");
      return new PiMonoAdapter();
    }
    case "codex":
      return new CodexAdapter();
    case "claude-managed":
      return new ClaudeManagedAdapter();
    default:
      throw new Error(`Unknown HARNESS_PROVIDER: "${provider}". Supported: claude, pi, codex, claude-managed`);
  }
}

The runner reads the resolved HARNESS_PROVIDER and awaits the factory at boot. The async factory is intentional: providers with module-level side effects (notably pi) are lazy-loaded only when selected, so unused adapters cannot crash unrelated workers during startup. The resolved value comes from swarm_config overlaid on process.env, with this precedence (highest first):

  1. swarm_config HARNESS_PROVIDER — repo > agent > global, via /api/config/resolved
  2. process.env.HARNESS_PROVIDER — container env
  3. "claude" — default

Resolution lives in src/utils/harness-provider.ts (resolveHarnessProvider); it's threaded through fetchResolvedEnv in src/commands/runner.ts so credential pool selection uses the same resolved value.

Switching providers without restart

The worker re-evaluates the resolved provider on each poll iteration (throttled to ~10s). When the value changes — typically because an operator wrote a new swarm_config row, or called PATCH /api/agents/{id}/harness-provider (which mirrors the value into swarm_config at scope=agent) — the worker:

  1. Creates a fresh adapter via createProviderAdapter(resolvedProvider).
  2. Updates state.harnessProvider.
  3. Rebuilds basePrompt so traits-driven prompt sections (e.g. local-environment vs. cloud-managed) match the new provider.
  4. Resets the cached cred_status snapshot so the dashboard shows credential health for the new adapter.

In-flight task sessions hold their own ProviderSession reference and continue on the old adapter unaffected; only future spawns pick up the swap. Failures during reconciliation (network blip, invalid value, adapter init error) log a warning and stay on the current provider — the worker is never wedged by a bad config.

Invalid HARNESS_PROVIDER values are rejected at write time (validateConfigValue in src/be/swarm-config-guard.ts), so a typo via PUT /api/config or the set-config MCP tool returns 400 instead of being silently stored.

Per-task outputSchema support

Tasks may carry an optional JSON Schema on outputSchema that the agent's final output must conform to. Enforcement depends on the harness:

ProviderSupportedNotes
claudeYesVia MCP store-progress; CLI extraction fallback if missed
claude-managedYesVia MCP store-progress
codexYesVia MCP store-progress
opencodeYesVia MCP store-progress; the runner also validates the final assistant message, as it does for codex
pi (pi-mono)YesVia MCP store-progress
devinYesVia MCP store-progress when HAS_MCP=true; otherwise the runner validates direct providerOutput before finishing the task

The primary enforcement point is the MCP store-progress tool: a non-conforming output fails the tool call and the agent is asked to retry. For providers that return direct providerOutput to the runner, ensureTaskFinished() now performs the same JSON-parse + schema-validation check before persisting task.output; if validation fails, the task is marked failed instead of silently storing invalid structured output.

pi empty final turn. If a pi session's final assistant turn has no text and no tool call, the adapter sends one in-session reprompt asking the model to finish (call store-progress if the task needs a result), then waits for idle again. It never reprompts more than once per session, and not after an error, an abort, or a successful terminal store-progress call. The prompt text is the task.nudge.empty_final_turn template. Each empty turn also writes a session-log line with its stopReason and content block types. When provider output fails outputSchema validation and the fallback also fails, failureReason keeps the validation error.

Reasoning / effort control

PATCH /api/agents/{id}/runtime accepts an optional reasoning_effort field — a normalized, closed enum off | low | medium | high | xhigh | max — persisted like MODEL_OVERRIDE as the agent-scoped swarm_config key REASONING_EFFORT_OVERRIDE. The runner resolves it independently of the model/modelTier axis and populates ProviderSessionConfig.reasoningEffort; when unset, every adapter behaves exactly as it does today (no fleet-wide default is injected). minimal remains excluded because Codex *-codex models reject it. max is capability-gated: it is offered only where the harness transport passes the model's own values through — codex, cursor, opencode and pi keep it when the catalog declares it; claude, amp and grok filter it even when an upstream model snapshot advertises it.

src/providers/reasoning-effort.ts owns capability gating (reasoningCapability(harness, model)) and per-harness translation (applyReasoningEffort(harness, model, level), a discriminated union telling each adapter what to merge). Capability data is hybrid: the models.dev reasoning_options snapshot (src/providers/modelsdev-reasoning.json, derived from the canonical src/be/modelsdev-cache.json) wins where present; otherwise a hand-authored {low, medium, high} fallback, plus a small harness-specific override table for quirks the cache doesn't encode. The runtime route validates the requested level against this lookup and 400s unsupported combos with { error, harness, model, level, allowed }.

ProviderTransportNotes
claudeCLAUDE_CODE_EFFORT_LEVEL env varoff on a legacy budget_tokens-capable model sets MAX_THINKING_TOKENS=0 instead (the effort env is omitted). No CLI flag is used — --effort is buggy in -p mode. Precedence: if an operator's additionalArgs includes --effort, the CLI flag wins over CLAUDE_CODE_EFFORT_LEVEL (Claude CLI's own precedence) — the existing "additionalArgs is an escape hatch" behavior, not special-cased here.
codexmodel_reasoning_effort config fieldoff maps to 'none'; max passes through for capability-advertising models such as GPT-5.6. show_raw_agent_reasoning stays pinned false regardless of the effort level — higher effort costs reasoning tokens (visible via reasoning_output_tokens cost telemetry) but produces no visible reasoning trace in the dashboard. *-codex (non-max) models reject xhigh; *-codex-max models accept it.
pithinkingLevel session optionTop-level sibling of model on CreateAgentSessionOptions; pi's native vocabulary already includes off.
opencodeProvider-keyed options in the per-task opencode.jsonanthropic/* models: thinking.budgetTokens (an internal numeric translation, not a user-facing knob), or effort for max. openrouter/* models: reasoning.effort. OpenAI-compatible models: reasoningEffort. off omits reasoning keys entirely (a noop application) — Opencode has no explicit off switch.
ampreasoningEffort on the per-task plugin agent (off maps to none)Amp's CLI has no effort flag (--effort is rejected). Effort applies only to a pinned provider/model (for example openai/gpt-5-nano), judged by the catalog like pi and opencode; a mode (low..ultra) names no model in advance, so it takes none.

Each adapter reports the level it actually applied via ProviderResult.appliedReasoningEffort (null when applyReasoningEffort() returned a capability-rejected noop). The runner forwards that into agents.cred_status.latestModel.reasoningEffort, which the dashboard surfaces in the agent runtime editor, the harness credential-status tooltip, and the agents-list Model column (a compact [|||]-style badge — more bars mean higher effort).

See thoughts/taras/research/2026-05-26-agent-reasoning-effort-runtime-control.md for the full cross-harness normalization derivation.

Amp (amp)

Set HARNESS_PROVIDER=amp and AMP_API_KEY (an sgamp_ access token from ampcode.com Settings → Security; see Provider Auth: Amp) in the worker environment or agent-scoped config. The adapter runs Amp's CLI in execute mode, amp -x --stream-json --stream-json-input, with the prompt as the first stdin message. The full worker image pins the CLI (@ampcode/cli@0.0.1792470027-g8c393d, SHA-512 verified, auto-update off); the slim image does not include it. AMP_BINARY selects another trusted executable. Amp's licence is proprietary (terms): confirm it before publishing an image that contains the binary.

Every Amp thread is stored on ampcode.com. Prompts, tool calls and tool output leave your infrastructure, and Amp's own storage is outside any model vendor's retention setting. The adapter starts threads with --visibility private, but there is no local-only mode.

  • Models. Amp has no model flag. modelTier maps onto its four built-in modes: smol low, regular medium, smart high, ultra ultra. Amp chooses the model per mode on its server; measured 2026-10-05, low ran GLM-5.3 Flash, medium Claude Opus 5.5, high GPT-6 Astra and ultra Claude Fable 5.1. A concrete model is a provider/model id (openai/gpt-5-nano, anthropic/claude-haiku-4-5-20251001), pinned through the per-task plugin agent on top of the medium mode's prompt and tools. Anything else is rejected at send-task, agent runtime and session start. Send-task and the agent runtime also reject a pin whose provider Amp does not run (measured 2026-10-05: anthropic, openai, vertexai, xai, fireworks, baseten) and an anthropic/openai model missing from the catalog; allowCustomModel: true skips both checks for a provider or model Amp added later.
  • Swarm MCP. The per-task --mcp-config file (mode 0600, removed with the temporary directory) carries the swarm URL and the five per-task headers. The session fails when Amp's init event does not list the server, or lists it as anything but connected, connecting or pending. Amp can emit init before the connection settles (a server that answered after 3 seconds was still connecting), so a connection that never settles is not caught at startup; a refused connection reads reconnecting and fails.
  • Tool search. hasToolSearch is true. Amp reaches MCP tools through its own tool_search and code_exec, with underscored names. Exposing all 131 swarm tools directly (excluding both) raised the first request from about 37k to about 98k input tokens, so the default is kept and the prompt names the call shape.
  • Tokens and cost. The stream carries per-request usage but no model and no USD. After the session the adapter reads two things, neither of which runs inference. amp threads usage <id> --details gives what Amp billed for the whole thread, subagent threads included; it is posted as the harness cost (costSource: 'harness', like Devin's ACUs) when every request was billed through Amp. amp threads export <id> gives the model of each request and Amp's context window; the API prices those tokens from the amp rows (Anthropic, OpenAI, Google and Fireworks models.dev sections) for the per-model breakdown, and as the total when Amp's cost is missing or a request went through a linked provider (a ChatGPT subscription records $0 in Amp). Amp reports uncached prompt tokens as cache_creation_input_tokens for every vendor; the adapter keeps them as cache writes for Anthropic models and moves them to input for the rest. When the export is empty too, the stream totals are priced at the mode's model and tagged estimated, never $0. A model missing from the table settles unpriced.
  • Steering. steerModes: ["queue"]. A queued steer goes to a per-task inbox directory, and the plugin's tool.result hook appends it to the next tool result, so the model reads it mid-turn (verified live during a sleep 25 shell call). Amp's own stdin queue would hold it until the turn ends, usually after the task has finished. A steer that no tool result carries goes to stdin at the turn end and starts another turn; each message is claimed by an atomic rename, so only one path carries it. A steer reports delivered only on a consumption receipt: the next assistant message after the hook handed it to Amp, or Amp's echo of the stdin message. If Amp exits, a stdin write fails or input closes first, it reports undeliverable and the runner promotes it to a follow-up task (at-least-once: a crash right after the model read it can repeat it). Amp emits result only when stdin closes, so the adapter ends input at the first top-level assistant message with no tool call and nothing queued; a steer after that is refused and promoted too.
  • Failed results. A result event ends input at once, so an early failure (an unknown pin fails within seconds) does not wait on a watchdog. Amp's error text (Unknown model provider: x) becomes the failure reason.
  • Cancel and failure. Process-tree termination on every stop (cancel, MCP failure, stdin failure, the exit watchdog): Amp runs shell commands in a session of its own, so the group kill alone left sleep running. The adapter lists the descendants first, then kills them, and the session settles only after that.
  • Credentials. The worker live test runs amp usage (credit balance, no inference, 5 second limit). A bogus key fails with Invalid or missing API key; no key parks the worker in the credential wait.
  • Skills and notes. Amp reads ~/.claude/skills, ~/.agents/skills and every CLAUDE.md from the working directory upward, so nativeSkillDiscovery is true and the agent notes need no injection.
  • Settings. Per task: amp.updates.mode: "disabled", no Amp co-author or thread trailer on commits (amp.git.commit.*), notifications off, and amp.dangerouslyAllowAll (the worker container is the sandbox).

Not covered: installed MCP servers from the mcp-servers catalog are not forwarded, and there is no native resume (follow-ups use the context preamble).


3. How a harness run fits into a task lifecycle

Every harness run is scoped to exactly one task. The runner owns the task's lifecycle and gives the adapter only what it needs to execute that single unit of work.

The flow

All steps live in src/commands/runner.ts.

StepWhat it does
pollForTriggerGET /api/poll until a Trigger is returned
buildPromptForTriggerConstructs the user prompt from the trigger
fetchRelevantMemoriesEnriches context (memory vectors + installed skills)
buildSystemPromptComposes the full system prompt (see §6)
spawnProviderProcessCalls adapter.createSession(config); also buffers the last non-empty assistant message event as a harness-agnostic output fallback (see below)
session.onEvent(...)Each ProviderEvent → API call (progress, logs, cost, context)
session.waitForCompletionBlocks until the adapter resolves
ensureTaskFinishedPOST /api/tasks/{id}/finish
syncProfileFilesToServerSession-end FS → DB sync of the agent's self-editable files (see below). Runs for every hasLocalEnvironment harness.

One task → one session. The runner tracks concurrent tasks in state.activeTasks: Map<taskId, RunningTask> up to MAX_CONCURRENT_TASKS. An adapter never needs to multiplex tasks internally — if the worker should handle two at once, the runner spawns two sessions.

Last-assistant-text fallback: if the session ends without an explicit store-progress call and the adapter's ProviderResult.output is empty, ensureTaskFinished falls back to the last non-empty assistant message event buffered during session.onEvent(...) (capped at 30,000 characters), instead of the last tool-narration progress line. This covers harnesses that never populate ProviderResult.output (codex today) without any adapter-specific code — see runbooks/harness-providers.md for the full precedence order.

Session-end identity / config sync (FS → DB)

When a session finishes, the runner syncs the agent's self-editable files back to the API so edits the agent made during the session persist into its profile (context_versions):

  • SOUL.md / IDENTITY.md / TOOLS.md / HEARTBEAT.md (independent field updates)
  • the CLAUDE.md source — provider-dependent (see below)
  • the agent-managed section of /workspace/start-up.sh (setupScript)

The sync lives in src/commands/profile-sync.ts (syncProfileFilesToServer) and is called from checkCompletedProcesses in runner.ts. The decision to sync is made per finished session, using the provider / local-env trait snapshotted on RunningTask at spawn time — not the mutable global state.hasLocalEnvironment. The runner lets an in-flight session finish on its original adapter after a live provider swap, so reading the global would skip a session that started local (worker since flipped remote) or sync stale local files after a remote session finished (worker since flipped local). The sync fires when any finished session in the batch ran in a local environment:

Providerlocal environmentSession-end sync
claude, pi, codex, opencodeyesYes — when the finished session ran on this provider
devin, claude-managednoNo — these have no /workspace FS
  • CLAUDE.md source is provider-routed (resolveClaudeMdPath). claude edits its personal file at ~/.claude/CLAUDE.md (also synced by the Claude Stop hook); every other local harness (codex/pi/opencode) edits /workspace/CLAUDE.md — the file the runner materializes from the claudeMd DB field at boot and that the base-prompt truncation notice points them to. An all-claude batch syncs the personal file (the runner is a backstop and never overwrites it with the stale workspace copy); any non-claude session in the batch routes to /workspace/CLAUDE.md. This closes the previously-open non-Claude CLAUDE.md FS → DB gap. The runner writes /workspace/CLAUDE.md for every harness (one container can live-swap providers), and Claude sessions pass claudeMdExcludes in --settings so that copy is not loaded on top of ~/.claude/CLAUDE.md; repo-level CLAUDE.md files still load.
  • Baseline hashes protect lead-side profile edits. At session start the runner records SHA-256 baselines for the identity files it just materialized from the DB. On session-end sync, unchanged files are skipped; only files the agent actually modified sync back. That preserves update-profile edits made by the lead during a running session instead of having the stale local copy blindly overwrite the DB row at shutdown.
  • Identity budgets ratchet oversized fields down without truncating them. SOUL.md and IDENTITY.md have 10,000-character budgets; CLAUDE.md and TOOLS.md have 20,000-character budgets. A field already above its budget may stay level or shrink, but cannot grow. Each file is posted independently, so one rejection does not discard valid edits to the others. Rejections and later reconciliations are persisted as events, and unresolved rejections add recovery guidance to the next worker prompt. Run bun src/commands/profile-sync-audit.ts inside the worker to compare the local files with DB state; it exits 2 when divergence exists.

Notes:

  • Harness-agnostic, runner-driven. Because the sync runs in the runner — at the single point where every completed session converges, including crashes — it does not depend on the harness emitting a shutdown event. This is what makes it reliable for codex/opencode (which previously had no sync path) and for pi (whose in-extension session_shutdown sync could silently not-fire). The Claude plugin Stop hook (src/hooks/hook.ts) and the pi extension (src/providers/pi-mono-extension.ts) still run their own sync; the runner-level call is the authoritative backstop.
  • Idempotent. The profile route only writes a new context_versions row when the content hash changes (updateAgentProfile in src/be/db.ts), so a redundant sync — pi's extension + runner double-POST, or an unchanged file — collapses to a no-op.
  • Non-fatal but visible. A failed sync never fails the task, but unlike the original copies it checks resp.ok and logs a scrubbed warning instead of silently swallowing the error.
  • Lead edits now survive unchanged sessions. The previous "Rule 19" reversion problem is resolved for identity-file syncs: if the agent never changed the local file, the session-end sync skips it and preserves the newer DB-side content from update-profile. Files the agent explicitly edits still sync normally, which keeps self-evolution working without clobbering lead-driven profile changes.

What fields on the task become ProviderSessionConfig

Assembled around runner.ts L1582–1596 and L3093–3133:

ProviderSessionConfig fieldComes from
promptbuildPromptForTrigger(trigger, task, memories, ...), optionally prefixed with a bounded follow-up context preamble when task.parentTaskId is set
systemPromptbuildSystemPrompt() + task.additionalSystemPrompt (see §6)
modeltask.model → MODEL_OVERRIDE env → ""
agentId, taskId, apiUrl, apiKeyWorker env + task row
cwdtask.dir → repoContext.clonePath → process.cwd() (L3050–3072)
resumeSessionIdDeprecated — always undefined. The runner stopped threading native session resume in the 2026-05-28 plan; follow-up continuity flows entirely through the context preamble prepended to prompt.
iterationRetry counter (runner state)
logFile${logDir}/${timestamp}-${taskIdSlice}.jsonl (L3102) — see §4
envMerged worker env + task-provided env, plus DESPLEGA_TELEMETRY_ENV=test when unset (withHarnessTelemetryEnv), so an API the agent boots from its shell does not report as a production install. Adapters should pass this env to the process that runs the agent's commands.

API endpoints touched per task

The runner (not the adapter) owns all of these. They are listed here so you know what the adapter's event stream is ultimately driving:

  • POST /api/tasks/{id}/progress — human-readable progress (L402–410)
  • POST /api/tasks/{id}/context — on context_usage, compaction, completion (L1777, L1797, L1900)
  • POST /api/session-logs — on raw_log (L983, see §4)
  • POST /api/events/batch — tool/session events (L1648)
  • PUT /api/tasks/{id}/claude-session — on session_init (L1037)
  • POST /api/session-costs — on result (L1014)
  • POST /api/active-sessions / DELETE /api/active-sessions/by-task/{id} — L1178, L1199
  • POST /api/tasks/{id}/pause / resume — L711, L797
  • POST /api/tasks/{id}/finish — L579
  • GET /cancelled-tasks?taskId=... — L2909 (also polled by adapter-side hooks)

Resume semantics

Native session resume (the claude --resume <UUID> CLI flag and codex.resumeThread(id) SDK call) was removed in the 2026-05-28 deprecation plan (thoughts/taras/plans/2026-05-28-deprecate-native-resume.md). The reasoning: native resume relied on an on-disk transcript that disappears when the worker container restarts (deploy, OOM, autoscaler reschedule), and the harness then either errored out or silently spawned a context-less session. The bounded context preamble survives any worker restart because it is rebuilt from the parent-task chain held in the API DB.

  • Parent → child continuity is now single-layered: the runner prepends a bounded preamble (cap ~2000 tokens, see src/commands/context-preamble.ts) for all providers when task.parentTaskId is set, and adapters always spawn a fresh harness session. resolveResumeSession is preserved as an observability shim that logs which session ids would have been used; it never returns a resumeSessionId.
  • Pause → resume: a paused task restarts with a new session, re-applying task.progress, attachment fetch recipes, and the required outputSchema via buildResumePrompt; if the task is also part of a parent chain, the context-preamble path is applied before execution resumes.
  • Adapter behavior: claude / claude-managed / codex each warn and ignore any stray resumeSessionId they receive; the runner no longer sets one. CodexAdapter.canResume() returns false unconditionally.

What your adapter owes the task: emit session_init as early as possible (so the runner can persist the provider session id), emit tool_start/tool_end faithfully (so the UI shows the agent's work), and emit a result with populated CostData before your waitForCompletion() resolves.

OpenCode's native skill tool passes { id: "<skill-name>" }. Its adapter emits tool_start on session.tool.called, after input is available, and adds name to those arguments so skill.invoke telemetry records the skill name.

Session-summary transcript sourcing

Memory self-rating hints and server-side citation ratings (implicit-citation,explicit-self) default on across harnesses when MEMORY_RATERS is unset, including existing deployments. Set MEMORY_RATERS= to disable raters; LLM ratings still require an explicit llm entry. Config reload preserves the empty opt-out, and deleting an override restores the deployment value (or the runtime default when unset).

Session summaries must not depend solely on harness-managed transcript artifacts being present at shutdown. The Claude adapter owns an in-memory transcript assembled from its stream-json user, assistant, tool-call, and tool-result events, then invokes the session summarizer from the parent process after the CLI exits. Its child Stop hook detects this adapter-owned path and skips the legacy transcript_path read; standalone Claude hook installations retain that file fallback.

Codex already follows the same parent-owned in-memory pattern. Pi and OpenCode still use their harness-provided session file or SDK message query, respectively. Every provider emits a one-line session_summary skipped (<provider>): <reason> diagnostic when a transcript, task context, credential, summarizer result, or quality gate prevents indexing, so a missing upstream artifact cannot become a silent outage.


4. Raw session logs & the task details page

The logFile field in ProviderSessionConfig is not optional decoration — it is the system of record for what happened inside a run. The task details UI reads from this pipeline.

Path convention

${LOG_DIR:-/logs}/<sessionId>/<timestamp>-<taskId8>.jsonl

Constructed at runner.ts:3102. LOG_DIR defaults to /logs in Docker workers, so the effective path is /workspace/logs/<sessionId>/<...>.jsonl. The runner writes the first line — a metadata record — at L3120 before spawning.

What each adapter writes to logFile

All three open the file with Bun.file(config.logFile).writer() and append JSONL lines.

  • Claude (claude-adapter.ts):
    • Raw NDJSON stdout from the Claude CLI is piped through (L284).
    • stderr wrapped as {type: "stderr", content, timestamp} (L321–323).
    • File handle closed at L329.
  • Pi-mono (pi-mono-adapter.ts):
    • Every normalized ProviderEvent is written as {...event, timestamp} inside emit() (L167–187). Closed in runSession's finally at L327.
  • Codex (codex-adapter.ts):
    • Every ProviderEvent written with timestamp in emit() (L347–374).
    • Raw SDK ThreadEvent mirrored as raw_log at L466.
    • Closed at L734.

Live transcript normalization

The task details page normalizes the live event shapes emitted by Claude, Codex, and OpenCode instead of assuming a single provider's transcript format. Tool calls and results, reasoning, file changes, stderr, progress markers, errors, web searches, todo snapshots, and Codex collaboration events remain readable while a session is still running; genuinely unknown events stay visible with their provider event type for debugging.

For Claude and OpenCode sessions, recognized child-agent lifecycle events are also grouped into a sub-agent waterfall above the transcript. Each row shows the child label/type, running or terminal state, duration, input, and outcome when the provider exposes them. The viewer consumes the underlying lifecycle records once so the same child run is not repeated as noisy raw tool events in the transcript.

Secret scrubbing is mandatory at every log egress. Import scrubSecrets from src/utils/secret-scrubber.ts and wrap every string before writing. All three reference adapters do this; a new adapter must too (see claude L284, L294, L303, L320, L344; pi L170–173; codex L352–355).

How raw logs reach the task details page

The .jsonl file on disk is the adapter-side dump. The UI does not read the file directly — it reads the DB-backed copy.

adapter emits raw_log  ─▶  runner flushLogBuffer  ─▶  POST /api/session-logs
                                                           │
                                                           ▼
                                                 session_logs (SQLite)
                                                           │
                                                           ▼
              ui    ─ useTaskSessionLogs ─▶ GET /api/tasks/{id}/session-logs
                                                           │
                                                           ▼
                                              <SessionLogViewer />
  • Runner upload: runner.ts:1814–1833. Only raw_log triggers the remote push; raw_stderr is pretty-printed to worker stdout only (L1834–1836).
  • API write: src/http/session-data.ts L135–153 → createSessionLogs in src/be/db.
  • API read: same file L155–166 — GET /api/tasks/{taskId}/session-logs → getSessionLogsByTaskId.
  • UI hook: useTaskSessionLogs in ui/src/api/hooks/use-tasks.ts, consumed at ui/src/pages/tasks/[id]/page.tsx:42 and rendered by <SessionLogViewer /> at ui/src/components/shared/session-log-viewer.tsx.

What this means for a new provider

Your emit() implementation must do three things for the UI to light up:

  1. Write every event to logFile as JSONL (for offline diagnostics / /workspace/logs/).
  2. Emit a raw_log ProviderEvent for anything the user might want to inspect in the task details page. The runner will upload it.
  3. Run every string through scrubSecrets before emitting or writing.

Tool calls (tool_start/tool_end) are not shown via session-logs — they go to /api/events/batch. You still need to emit them, but they reach the UI through a different channel (the agent's tool-activity timeline).


5. Exposing the swarm MCP to the runtime

This is the single most important integration point after event translation. The swarm MCP server is how the agent actually interacts with the swarm: store progress, offer subtasks, read/write memory, request human input, cancel itself. A provider that runs code but cannot call swarm MCP tools is effectively a read-only model invocation — it will never drive real swarm behavior.

Where the MCP server lives

The swarm API exposes its MCP server at {apiUrl}/mcp. Tools are defined under src/tools/.

How each reference adapter wires it

ProviderWiringFile
ClaudeDiscovers an existing .mcp.json (walking up from cwd), injects X-Source-Task-Id into the agent-swarm entry, writes a per-session copy to /tmp/mcp-<taskId>.json, launches CLI with --mcp-config <path> --strict-mcp-config.claude-adapter.ts L117–184, L251
PiConstructs McpHttpClient(apiUrl, apiKey, agentId, taskId), calls listTools(), wraps each as a pi-mono ToolDefinition with prefix mcp__<name>__.pi-mono-adapter.ts L410–421, pi-mono-mcp-client.ts L53
CodexbuildCodexConfig registers mcp_servers["agent-swarm"] = { url: "{apiUrl}/mcp", http_headers: { Authorization, X-Agent-ID, X-Source-Task-Id }, bearer_token_env_var, startup_timeout_sec }. Passed to new Codex({ config }).codex-adapter.ts L132–243, L857–861
opencodecreateSession writes mcp.swarm = { type: "remote", url: "{apiUrl}/mcp", headers: { Authorization, X-Agent-ID, X-Source-Task-Id, X-Context-Key, X-Runtime-Instance-ID } } into the per-task config passed to its embedded @opencode/sdk host. X-Context-Key is sent only when the task has one.opencode-adapter.ts

Required headers

Whatever the transport, the adapter MUST set these three headers on the swarm MCP connection:

  • Authorization: Bearer ${apiKey}
  • X-Agent-ID: <agentId>
  • X-Source-Task-Id: <taskId> — so nested tool calls attribute back to the right task

Key tools a harness will call mid-run

Pretty labels at runner.ts:254–317. Representative list:

  • store-progress — structured progress updates + memories
  • offer-task / send-task — delegate to another agent
  • cancel-task / poll-task — lifecycle
  • memory-search / memory-get / inject-learning — shared memory
  • get-task-details, post-message, read-messages — inter-agent coordination
  • request-human-input — HITL gates
  • trigger-workflow — start a workflow DAG

Fallback when MCP is unavailable

Each reference adapter fails open (the run continues without MCP tools), but the agent is effectively blind to the swarm:

  • Pi: try/catch around discovery (pi-mono-adapter.ts L409–424) — on failure, customTools = [].
  • Codex: adds the agent-swarm entry unconditionally; failures fetching installed servers are non-fatal (codex-adapter.ts L217–229).
  • Claude: createSessionMcpConfig returns null if nothing found; CLI runs without --mcp-config.

Even if the in-process MCP connection fails, the runner and the adapter's own swarm-event hooks still talk to the swarm HTTP API directly for lifecycle, heartbeat, and cancellation polling — see src/providers/codex-swarm-events.ts and src/providers/pi-mono-extension.ts L1–50. That is the safety net; MCP is what lets the model call tools.


6. System prompt composition & delivery

ProviderSessionConfig.systemPrompt is the full assembled system prompt, not a fragment. Your adapter's job is to hand it to the underlying runtime verbatim — not to add preamble.

How it is built

Composed by getBasePrompt(args) in src/prompts/base-prompt.ts and orchestrated by buildTaskSessionPrompts() in src/commands/runner.ts. The system prompt depends only on the agent (profile, harness, deployment), so it is byte-identical across that agent's sessions and the provider reads it from its prompt cache instead of re-writing it. Nothing per task, per requester or per session may enter it. The pieces, in order:

SourceSectionGate
Composite system.session.{lead|worker|lead.managed|worker.managed|worker.remote}Role line, persona (description, SOUL.md, IDENTITY.md when edited), operating contract, workspace, memory, communication, secretsComposite picked by traits, then role: no MCP → worker.remote, MCP without a local environment → {lead|worker}.managed
Template system.agent.scripts_only_modeCode-mode instructionsscriptsOnly
Template system.agent.outputs or ...outputs.no_agent_fs## OutputsMCP + pages capability; agent-fs variant when AGENT_FS_API_URL is set on a local environment
Template system.agent.slack## Slack (both roles)Slack tokens set, not scripts-only, slack capability
Template system.agent.steering## Live task steeringSTEERING_ENABLED, adapter steerModes, core capability
Template system.agent.tools_skills (skills + MCP server lines interpolated)## Tools and skillsMCP. Count + discovery pointer for harnesses with native skill discovery (claude, pi), enumerated list otherwise
agentClaudeMd## Your notes (CLAUDE.md) (truncated at 20k)Local environment, provider claude/codex/opencode/pi, content differs from the generated default. Claude excludes both ~/.claude/CLAUDE.md and /workspace/CLAUDE.md from its memory load via claudeMdExcludes, so this is its only copy; the hook still writes ~/.claude/CLAUDE.md as the file the agent edits
SYSTEM_PROMPT env / --system-promptAppendedOperator config

Per-task context is appended to the task prompt (the first user turn), after the task text, by getTaskContextPrompt(args) and the runner:

SourceSectionGate
Template system.agent.scripts_only_mode.slack## Slack (scripts-only)Worker, scripts-only, Slack-originated task
Template system.agent.repository (per-task pieces interpolated)## RepositoryTask has a repo. Clone sentence + get-repos pointer, repo CLAUDE.md inlined for opencode only (12k cap), auto-stashes, guidelines, code-quality skill pointer
task.requester.profile## Requester ProfileRequester has role, notes, or comms
cwd warningNote: The task requested working directory ...Task dir does not exist

The shared system.agent.communication template sets reply defaults for every session composite:

  • Simple questions and action confirmations use one to three sentences. Routine replies stay under 120 words.
  • Requested depth, requester preferences, required formats, and essential evidence can require longer replies. Internal investigation and required task artifacts retain their necessary detail.
  • The comms skill handles requested simplification, re-expression, and clarity rewrites when available. Ordinary replies do not require a skill invocation.
  • Visual mode requires an explicit visual request and delivery tools that work in the active channel. Requests to show data remain data requests.
  • Output schemas and channel delivery rules take precedence over skill defaults. Slack's engine continues to deliver task results.

Customized prompt templates can override these defaults. The seeder preserves operator edits.

Inject-when-edited: IDENTITY.md and CLAUDE.md are compared (whitespace-normalized) with the generators in src/prompts/defaults.ts, current and legacy. An unedited default is skipped. Profile metadata updates regenerate unchanged IDENTITY.md and CLAUDE.md defaults in the same transaction, comparing against the previous metadata first. Explicit file updates and custom text are preserved. This keeps defaults recognizable after task refreshes and worker restarts; already-stale historical blobs are not heuristically reclassified. SOUL.md is always injected. TOOLS.md is never injected; the workspace block points at it.

Identity sources (soulMd, identityMd, claudeMd, toolsMd, heartbeatMd) are fetched from GET /me. If missing, defaults come from the template, then from the generators in src/prompts/defaults.ts, then pushed back to the server. At each polled task, the runner reads /me again before building the provider's system prompt. This read has a two-second deadline covering headers and body; unavailable or malformed responses preserve the last good identity. Omitted fields preserve cached values, while empty strings clear them.

Per-task identity refresh only updates memory. It does not rewrite workspace files or profile-sync baselines, which avoids overwriting edits made by concurrent local tasks. Boot materialization and the existing FS → DB sync lifecycle continue to own those files. In particular, TOOLS.md and HEARTBEAT.md are not injected prompt sections, and remote changes to those workspace files are outside this refresh's scope. One full /me read per task keeps this compatible with existing servers without adding a signature endpoint.

Reference material (script authoring contract, scheduling, Slack rules, memory triage, code-quality checks, heartbeat runbook) lives in the seeded skills swarm-scripts, scheduling, slack-interaction, memory, code-quality, and heartbeat-runbook. The prompt carries the branch and a pointer. Render every variant with bun scripts/dump-prompt-variants.ts [outDir].

If the runner reuses an existing repo clone that has local changes, ensureRepoForTask() now auto-stashes that work before refreshing from origin. The resulting swarm-autostash refs are threaded into repoContext.autoStashes and appended to the base prompt so the active session can restore them deliberately instead of silently losing or ignoring dirty work.

Template resolution goes over HTTP (configureHttpResolver(apiUrl, apiKey) at runner.ts:2206) to obey the API/worker DB boundary. Prompt files under src/prompts/ must remain pure — no bun:sqlite or src/be/db imports. Enforced by scripts/check-db-boundary.sh.

Swarm tool-discovery guidance follows the adapter's hasToolSearch trait (false when omitted). Claude Code sets it to true for native ToolSearch. Codex, OpenCode, ACP, Claude Managed, Devin, and dsh set it to false. Amp sets it to true and gets its own text (system.agent.tool_discovery.amp): its MCP tools sit behind its own tool_search and code_exec, with dashes turned into underscores (store-progress is store_progress). pi sets it to the value of PI_TOOL_DEFERRAL (below); MCP-enabled sessions receive guidance that swarm tools are already in the tool list. This trait changes prompt text only, not tool registration.

pi tool deferral

pi SDK sessions load none of pi's built-in extensions, so by default every swarm tool is declared to the model up front. With PI_TOOL_DEFERRAL=true the pi adapter:

  • marks each swarm tool exposure: "direct" when it is in CORE_TOOLS (src/tools/tool-config.ts) or the server sent _meta["anthropic/alwaysLoad"] for it (task tool manifests), and exposure: "deferred" otherwise;
  • adds createToolSearchExtension() to the session and +tool_search to its default tools;
  • reports hasToolSearch: true, so the prompt tells the model to load tools through its tool search.

Swarm tools carry namespace: { name: "agent-swarm" } and, when the server declares one, their outputSchema; results pass structuredContent through to pi alongside the text. Tool names do not change.

The flag defaults to false. Loading a deferred tool changes the tool set mid-session, and pi falls back to a transcript checkpoint for providers that cannot represent that change, which can invalidate the prompt cache. Pilot it on one pi agent with agent-scoped configuration (PI_TOOL_DEFERRAL=true) and compare against a pi agent on the same model: context tokens at session start, cost per task, and cache-read tokens as a share of input tokens. The worker picks the value up on its next task.

pi codemode

PI_CODEMODE=true adds pi's codemode tool: a QuickJS sandbox inside the worker process in which the model's JavaScript calls tools (await tools.get_task_details({ taskId })) in any order or in parallel and returns only the result. The adapter uses createCodemodeExtension({ mode: "on", models }) and +codemode:

  • mode: "on" keeps every declared tool declared, so lifecycle tools (store-progress, send-task, …) are still called directly. mode: "only" is never used.
  • models is false unless PI_CODEMODE_MODELS=true (default off, ignored while PI_CODEMODE is off). When on, scripts get pi's models API: models.classify() for classifier models and models.generateImages() for image models, plus the model catalog. Their usage counts toward the session cost: pi 1.0 puts a script's models.* usage on the codemode tool result and getSessionStats() sums it, which the adapter reports as the session's cost. src/tests/providers/pi-cost.test.ts proves that chain.
  • Scripts receive a tool's structuredContent when the tool declares an outputSchema, which every swarm tool does.
  • Nested calls go through pi's tool pipeline, so the swarm tool_call hooks (cancellation, loop detection) still see them.
  • createBoundedCodemodeExtension caps each script: a 120 s deadline (pi's default is none, and it also overrides a longer timeout_ms in the script's // @options line), 32 nested tool calls, and 4 running at once. Hitting a limit, or cancelling the task, aborts the sandbox signal, which interrupts the QuickJS worker and fails the codemode call.

The flag is the only switch: when it is on, every pi session gets codemode, whatever the task's model or tier. Our July scripts-only eval found small models degrade as coordinators when they must work through scripts, so pilot it on an agent that runs a capable model. Leave the flag off unless you are piloting it, and compare tool calls per task and cost against a pi agent without it.

How each adapter delivers the prompt

  • Claude — CLI flag: cmd.push("--append-system-prompt", this.config.systemPrompt) at claude-adapter.ts:245–247.
  • Codex — written into AGENTS.md in cwd inside a <swarm_system_prompt> block. The Codex SDK has no --append-system-prompt equivalent; this file is the only channel. Entry: writeCodexAgentsMd(config.cwd, config.systemPrompt) at codex-adapter.ts:761; implementation at codex-agents-md.ts:56–119. If an AGENTS.md exists, the block is prepended; otherwise stacked atop any CLAUDE.md. Cleanup in the session finally at codex-adapter.ts:738.
  • Pi — SDK parameter: new DefaultResourceLoader({ appendSystemPrompt: [config.systemPrompt], extensionFactories, ... }) passed via CreateAgentSessionOptions in pi-mono-adapter.ts. The adapter must await resourceLoader.reload() first: createAgentSession only reloads a loader it builds itself, and an unloaded loader contributes no appended prompt and no extensions. The adapter's SettingsManager sets projectTrusted: false, because the cwd is a task repo clone: the repo's .pi/extensions, packages, settings and SYSTEM.md never load into the worker process.
  • Amp — Amp has no system-prompt flag. The adapter writes a per-task plugin ($XDG_CONFIG_HOME/amp/plugins/agent-swarm.ts, in a temporary directory) that registers one agent mode extending the selected built-in mode, with the swarm prompt as its instructions (appended to Amp's own prompt). The prompt is embedded as a JSON literal, never as code. Spawned with -m agent-swarm. Live verified; nothing is written to the repository or baked into the image.

What this means for a new provider

Pick the delivery path your runtime supports, in order of preference:

  1. A dedicated system-prompt argument (flag, SDK field) — cleanest, no filesystem side effects.
  2. A file-based convention (like Codex's AGENTS.md) — fine, but you must clean up in finally and not clobber user files.
  3. Prepending to the user prompt — last resort; may confuse the model.

If your runtime has a distinct prompt shape (e.g. a different preamble format), add a subdirectory under src/prompts/<foo>/ and invoke it from your adapter, but keep the merge logic in base-prompt.ts as the single source of truth.


7. Skills (how the three providers handle them)

Skills are the swarm's portable procedural knowledge — reusable markdown files invoked as slash-commands like /review-pr, /implement-issue, /create-pr. Each provider surfaces them differently; your adapter must implement formatCommand(name) and may need a resolver if your runtime doesn't have native support.

The three patterns

Claude. The CLI already knows about skills installed under ~/.claude/skills/<name>/SKILL.md. The adapter just returns /<name>.

formatCommand(name: string): string { return `/${name}`; }

See claude-adapter.ts:601.

The runner's first prompt is still inlined before it reaches the CLI (resolveClaudePrompt). Claude Code's native expansion puts the slash args in <command-args> and again after ARGUMENTS:, and the runner's args carry the whole task body, so native expansion would send the body twice.

Pi. Pi-mono supports skills but namespaces them:

formatCommand(name: string): string { return `/skill:${name}`; }

See pi-mono-adapter.ts:541. Skills live at ~/.pi/agent/skills/<name>/SKILL.md.

Codex and OpenCode. These SDKs have no SKILL.md mechanism, so the adapter resolves the skill itself before calling the model:

  • src/providers/codex-skill-resolver.ts intercepts a leading /<name> in the user prompt.
  • Codex reads ${CODEX_SKILLS_DIR ?? ~/.codex/skills}/<name>/SKILL.md.
  • OpenCode reads ${OPENCODE_SKILLS_DIR ?? ~/.opencode/skills}/<name>/SKILL.md.
  • Inlines the SKILL.md content into the prompt before calling the provider SDK.
  • formatCommand(name) simply returns /${name} so the swarm can still emit the canonical form; the resolver does the work.

This is the template to follow if your provider lacks native skill support.

Follow-up and resumed task prompts keep the skill invocation on the first line, with the bounded prior-context block after it. Persisted profile-sync warnings also follow the invocation and stay ahead of prior context. Claude and the OpenCode ACP target expand the leading command natively; Codex and OpenCode use the adapter resolver. Prepending context before the command leaves it as plain text.

Skill telemetry records successful shell cat reads of installed SKILL.md files as skill.invoke with via: "read", including Codex's bash -lc commands. Only plain multi-file cat commands are supported; compound commands, pipelines, redirections, expansions, and options are ignored because shell success does not prove each file was read. Listing or searching a skills directory does not count. Each skill counts once per session across prompt, tool, and read delivery; failed Codex shell commands do not count.

Where skill files come from

The swarm still seeds skills into each provider's skill dir at container boot in docker-entrypoint.sh L764–803, but bundled complex-skill files are now also synced from the database during normal skill refreshes. At minimum, every skill installs its SKILL.md into:

  • ~/.claude/skills/<name>/SKILL.md
  • ~/.pi/agent/skills/<name>/SKILL.md
  • ~/.codex/skills/<name>/SKILL.md
  • ~/.opencode/skills/<name>/SKILL.md
  • ~/.agents/skills/<name>/SKILL.md

For DB-backed complex skills, sibling bundled files are mirrored alongside SKILL.md under the same skill directory. Legacy remote/sourceRepo-only complex skills still rely on the entrypoint fallback when the bundle is not present in the database yet.

When adding a new provider, extend both sync paths with ~/.<foo>/skills/<name>/... (or whatever directory layout your runtime expects).


8. Adding a new provider — step by step

Assume you are adding a provider called foo. Follow every step; "optional" is called out where true.

Step 1 — Scaffold the adapter

Create src/providers/foo-adapter.ts:

import type {
  ProviderAdapter,
  ProviderEvent,
  ProviderResult,
  ProviderSession,
  ProviderSessionConfig,
} from "./types";

export class FooAdapter implements ProviderAdapter {
  readonly name = "foo";

  async createSession(config: ProviderSessionConfig): Promise<ProviderSession> {
    return new FooSession(config);
  }

  async canResume(_sessionId: string): Promise<boolean> {
    return false; // or true if your SDK/CLI supports resume
  }

  formatCommand(commandName: string): string {
    return `/${commandName}`; // adjust to your runtime's convention
  }
}

class FooSession implements ProviderSession {
  sessionId: string | undefined;
  private listeners: Array<(e: ProviderEvent) => void> = [];
  // ... abort controller, pending promise, etc.

  constructor(private config: ProviderSessionConfig) {
    this.start();
  }

  onEvent(listener: (e: ProviderEvent) => void) { this.listeners.push(listener); }
  private emit(event: ProviderEvent) { for (const l of this.listeners) l(event); }

  async waitForCompletion(): Promise<ProviderResult> { /* resolve when native stream ends */ }
  async abort(): Promise<void> { /* kill subprocess / signal AbortController */ }

  private async start() { /* spawn CLI or call SDK; translate events to emit(...) */ }
}

Step 2 — Register in the factory

Edit src/providers/index.ts:

import { FooAdapter } from "./foo-adapter";
// ...
case "foo": return new FooAdapter();

Update the error message (Supported: claude, pi, codex, foo) so the unknown-provider error stays accurate.

Step 3 — Translate native events to ProviderEvent

This is the heart of the adapter. For each event your SDK / CLI emits, decide which ProviderEvent type to produce:

  • Session start → session_init { sessionId } (set this.sessionId first, then emit).
  • Assistant text → message { role: "assistant", content }.
  • Tool call start/end → tool_start / tool_end with { toolCallId, toolName, args|result }.
  • Turn/usage stats → context_usage.
  • Auto-compaction → compaction.
  • Any provider-specific event with no direct mapping → custom { name, data } (e.g. Codex uses custom for codex.reasoning and codex.todo_list).
  • Terminal → result { cost, output, isError, errorCategory } then resolve waitForCompletion().
  • Non-fatal diagnostics → raw_log / raw_stderr.

Reference code:

Step 4 — Emit CostData

The result event must carry a populated CostData so the swarm can track spend:

emit({
  type: "result",
  cost: {
    sessionId: this.sessionId!,
    taskId: config.taskId,
    agentId: config.agentId,
    totalCostUsd, inputTokens, outputTokens,
    cacheReadTokens, cacheWriteTokens,
    durationMs, numTurns, model, isError: false,
  },
  isError: false,
});

If your SDK returns tokens but not USD, compute cost from a pricing table (see src/providers/codex-models.ts for the Codex model/pricing resolver pattern).

Step 5 — Model selection

ProviderSessionConfig.model is set by the runner from opts.model || process.env.MODEL_OVERRIDE || "" and may be overridden per task (task.model). Decide:

  • What is the provider default when model === ""? Read it from a provider-specific env var (CODEX_DEFAULT_MODEL is the existing convention).
  • Do you accept shortnames ("sonnet", "gpt-5") and expand to full IDs? If so, build a resolver — see resolveCodexModel in src/providers/codex-models.ts and resolveModel in src/providers/pi-mono-adapter.ts.

Step 6 — Credentials & auth

Three patterns are already in the codebase; pick the one that fits your provider:

Claude-style. Validate at adapter start; throw a clear error if missing. Example: validateClaudeCredentials() in src/providers/claude-adapter.ts.

Codex ChatGPT-style. See §6 below. This is the most involved path but required for desktop-login-style flows.

Pi-style. Reads ~/.pi/agent/auth.json. If your SDK looks up its own auth file, the adapter may not need to do anything beyond ensuring the file exists at worker boot.

Secret scrubbing: any credential you log must go through the project's scrubber. See src/utils/secret-scrubber.ts and the CLAUDE.md "Secret scrubbing" section.

Step 7 — MCP server injection

Every adapter fetches per-agent MCP servers from the swarm API and wires them into the provider:

GET {apiUrl}/api/agents/{agentId}/mcp-servers?resolveSecrets=true
Authorization: Bearer {apiKey}

Then:

  • Claude writes /tmp/mcp-<taskId>.json and passes it via --mcp-config (claude-adapter.ts L46–183).
  • Pi hands them to pi's built-in MCP extension (createMcpExtension()): toPiMcpServers maps stdio servers to { command, args, env } and HTTP/SSE servers to streamable HTTP { url, headers }, and the swarm hook extension calls pi.registerMcpServer() for each on session_start (the adapter calls session.bindExtensions({}) to emit it). Tools are named mcp__<name>__<tool> with direct exposure. The extension's config loader is replaced, so ~/.pi/agent/mcp.json and a repo's .pi/mcp.json are never read. Resolved header and env values are escaped ($ → $$, leading ! → $!) because pi treats !… as a shell command and expands $VAR. The swarm server itself stays on the adapter's own McpHttpClient, which carries the per-session X-Agent-ID, X-Source-Task-Id and X-Runtime-Instance-ID headers.
  • Codex builds a structured mcp_servers object for new Codex({ config }) (codex-adapter.ts L132–243).

Always include the swarm's own MCP server with an X-Source-Task-Id header so nested tool calls attribute back correctly.

Step 8 — Swarm event hooks (cancellation, heartbeat, tool-loop detection)

The adapter is responsible for polling swarm-side signals during a run. Pattern files:

  • Codex: src/providers/codex-swarm-events.ts — throttled fireAndForget fetches for cancel/heartbeat/activity/context-usage, attached via this.listeners.push(...) inside the session.
  • Pi: src/providers/pi-mono-extension.ts — createSwarmHooksExtension passed into DefaultResourceLoader({ extensionFactories: [swarmExtension] }).
  • Claude: external hook process reads a task file (/tmp/agent-swarm-task-<pid>.json) written by claude-adapter.ts L31–43; hook logic lives under src/hooks/ (e.g. tool-loop-detection.ts).

Tool-loop detection (src/hooks/tool-loop-detection.ts::checkToolLoop) is reusable — call it from your event translator when you see tool_start. You do not need to await it: calls for the same session key run one after another in call order inside checkToolLoop, so a fire-and-forget caller still gets an exact repeat count.

Step 9 — Skills and slash-commands

Skills are the swarm's portable procedural knowledge (/review-pr, /implement-issue, etc.). Each provider handles them differently:

  • Claude: native slash-commands, so formatCommand(name) => "/" + name (claude-adapter.ts L601). createSession still inlines a leading /<name> from ~/.claude/skills/<name>/SKILL.md (frontmatter stripped) so the task body in the args is not sent twice; commands without a SKILL.md there expand natively.
  • Pi: prefixed, formatCommand(name) => "/skill:" + name (pi-mono-adapter.ts L541); resolved from ~/.pi/agent/skills/<name>/SKILL.md.
  • Codex: no native skills support → src/providers/codex-skill-resolver.ts intercepts a leading /<name> in the prompt, reads ${CODEX_SKILLS_DIR ?? ~/.codex/skills}/<name>/SKILL.md, and inlines it before calling thread.runStreamed. The system prompt is delivered by writing AGENTS.md into cwd (see codex-agents-md.ts).
  • OpenCode: no native SKILL.md support → uses the same inline resolver, reads ${OPENCODE_SKILLS_DIR ?? ~/.opencode/skills}/<name>/SKILL.md, and inlines it before calling client.session.prompt.

Pick the model that matches your runtime; if your provider has no native skill mechanism, follow the Codex inline-resolver pattern.

The swarm syncs skill files into each provider's skill dir at container boot and during per-task refreshes (copies SKILL.md into ~/.claude/skills/, ~/.pi/agent/skills/, ~/.codex/skills/, ~/.opencode/skills/, and ~/.agents/skills/). Add an entry for your provider there.

Step 10 — Worker bootstrap (Docker entrypoint)

Edit docker-entrypoint.sh:

  1. Credential validation branch — mirror the pattern used for pi (L7–12), codex (L13–71), or claude (L72–79).
  2. Binary reachability check — add a block similar to the CODEX_BINARY / CLAUDE_BINARY checks (L87–108).
  3. Skill sync — extend the skill-copy block (L764–803) with your provider's skill directory.
  4. If your provider has a CLI binary, install it in Dockerfile.worker.

Step 11 — Login CLI (only if OAuth)

If your provider needs a user-interactive OAuth flow (like Codex's ChatGPT login), add a CLI command:

  1. Implement PKCE + local callback server. Reference: src/providers/codex-oauth/flow.ts — createAuthorizationFlow (URL with code_challenge=S256), startLocalOAuthServer (node:http on 127.0.0.1:1455/auth/callback), exchangeAuthorizationCode.
  2. Add storage helpers at src/providers/<foo>-oauth/storage.ts that PUT /api/config with { scope: "global", key: "<foo>_oauth", value: JSON.stringify(creds), isSecret: true }. See storeCodexOAuth and getValidCodexOAuth (with auto-refresh) in src/providers/codex-oauth/storage.ts.
  3. Add the CLI command at src/commands/<foo>-login.ts. Reference: src/commands/codex-login.ts — uses promptHiddenInput for masked API-key entry and attempts to auto-open the browser via open / start / xdg-open by platform.
  4. Register the command in src/cli.tsx (non-UI command: console.log + process.exit(0) style) and update COMMAND_HELP.
  5. In docker-entrypoint.sh, restore credentials at boot by fetching them from /api/config/resolved?includeSecrets=true&key=<foo>_oauth and writing the provider's expected auth-file format (see the codex block, L13–71, for the jq-based reshape).
  6. At adapter session-creation time, re-fetch-and-refresh as a fallback if the token is expired (see codex-adapter.ts L810–844).

Step 12 — Types & enums

Update union-type entries that enumerate providers:

  • src/types.ts — HarnessProvider union.
  • templates/schema.ts — template provider enum.
  • Any migration that stores a provider column — do not modify existing migrations; create a new one under src/be/migrations/ if a schema update is needed.

Step 13 — Prompts

If your provider benefits from a distinct system-prompt shape, add src/prompts/<foo>/ mirroring src/prompts/claude/ and src/prompts/codex/. Wire it into the adapter's createSession. Prompt files must remain pure (no DB imports) — the DB boundary is enforced by scripts/check-db-boundary.sh.

Step 14 — Tests

Add at minimum:

  • src/tests/<foo>-adapter.test.ts — unit tests for event translation (feed fake native events, assert emitted ProviderEvents).
  • If the adapter advertises steerModes, cover each delivery mode and SDK rejection, then add the provider to ProviderNameSchema; provider-steering-capabilities.test.ts verifies its traits against the server capability map.
  • src/tests/<foo>-oauth.test.ts (if OAuth) — PKCE helpers, storage round-trip, token refresh.

Existing analogs: src/tests/codex-*.test.ts, src/tests/claude-*.test.ts, src/tests/pi-*.test.ts. Tests must use isolated SQLite files and clean up -wal / -shm in afterAll (see the "Unit tests" block in CLAUDE.md).

Step 15 — Documentation

  • Update CLAUDE.md: add foo to the HARNESS_PROVIDER accepted values list and document any required env vars.
  • Update this guide's "Reference implementations" table.
  • Update the README's "Multi-provider" line.
  • Update Harness Configuration with the new provider's setup instructions.
  • If the provider needs new HTTP endpoints, regenerate OpenAPI: bun run docs:openapi.

Swarm readiness: what a provider must do before it is a worker

Spawning the CLI and returning its final text is not enough. Each item below was missed by the dsh adapter and found only by running real tasks (2026-10-01). Ship each one with the adapter, or document the gap in this guide and in runbooks/harness-providers.md.

RequirementWhat to check
Swarm MCPThe session reaches ${MCP_BASE_URL}/mcp with Authorization, X-Agent-ID, X-Source-Task-Id, X-Context-Key and X-Runtime-Instance-ID, and traits.hasMcp is true. A session that starts without the swarm tools fails instead of running on. With no MCP client, keep hasMcp: false and document why.
Failure pathAn agent can end a task failed (store-progress with status: "failed", or a harness-level signal without MCP). An abnormal turn end exits non-zero.
SandboxThe agent can write /workspace/shared, /workspace/personal and /tmp. A cwd-only write sandbox blocks cross-agent handoff.
Provider recordedagent_tasks.provider is set for every task, including spawn failures (the runner sends provider on /finish).
Cost and contextcontext_usage per model call, CostData with provider, routing prefixes in src/be/pricing-normalize.ts, and seed rows in src/be/seed-pricing.ts so the default tier models price as pricing-table.
Model observabilityThe session log names the model called, or at least the model configured.
Reasoning effortconfig.reasoningEffort maps to the harness's real setting; the harness is in REASONING_HARNESSES and offers only levels it honours.
Dashboard logsA logs-parser adapter in apps/ui/src/logs-parser/adapters.ts, tested on real persisted rows. Without it every row renders as UNKNOWN.
Runtime editorThe harness is in LOCAL_HARNESSES and isLocalHarness, with model groups and effort levels in apps/ui/src/lib/agent-runtime-models.ts.

How to QA a new harness

Unit tests prove the adapter builds the right command, not that the harness can do swarm work. Run this on the deployed swarm before the harness takes general worker duty.

Setup. Pick a low-usage worker with no in-flight tasks. Record its harness_provider, MODEL_OVERRIDE, REASONING_EFFORT_OVERRIDE and AGENT_MAX_TASKS (GET /api/agents/<id>/runtime and agent-scoped config). Switch it with PATCH /api/agents/<id>/runtime or the Runtime editor, add the harness credentials as agent-scoped config, and pin each scenario to it (routingReason: human_pinned).

Gates on every task:

  • agent_tasks.provider is the new harness, spawn failures included. NULL or another harness means a silent fallback.
  • agent_tasks.model matches the request, and the session log names the model.
  • The dashboard renders the session with no UNKNOWN rows.
  • A session_costs row exists with costSource: pricing-table, and the task shows context usage.

Scenarios:

#ScenarioPass when
1Smoke: reply with a sentinel stringExact sentinel, completed
2Write a file under /workspace/shared, return its sha256File exists, digest matches
3Pure compute (sum of primes below 10000)Correct value
4Read the repo and cite a file and lineCorrect citation
5modelTier, then an explicit modelRow model and logged model match each
6Model whose credential is missingfailed fast with a clear reason, provider set
7Call MCP tools (memory-search, store-progress)Calls succeed
8Impossible task (read a missing file)Status failed, not completed
9Cancel a long sleepcancelled, agent idle again, no orphan process
10More tasks than AGENT_MAX_TASKSExtra task rejected at capacity
11Large output with unicodeIntact
12Follow-up via parentTaskIdParent context carried
13Steer a running taskMatches the advertised steerModes

Bar for general worker duty. Scenarios 1, 2, 3, 6, 7, 8 and 9 pass, plus every gate. A harness that fails 2, 7 or 8 is limited to read-only or self-contained work: it cannot hand off through /workspace/shared, report progress, or fail a task. Scenarios 5 and 10 to 13 may pass as a documented degrade.

Restore. Put the agent back on the recorded values with the same PATCH, remove the test credentials, and confirm its next task records the original provider.

9. Codex OAuth: the full reference flow

This is documented separately because it is the most involved integration.

┌────────────────┐   codex-login CLI      ┌──────────────────┐
│ User's laptop  │ ─── PKCE auth URL ──▶  │ auth.openai.com  │
│                │ ◀── code (state) ───── │ OAuth server     │
│                │                        └──────────────────┘
│ Local callback │         ▲
│ :1455/auth/... │─────────┘
└────────┬───────┘
         │ exchangeAuthorizationCode
         ▼
┌────────────────┐
│  Creds JSON    │
│  (tokens)      │
└────────┬───────┘
         │ PUT /api/config { key:"codex_oauth", isSecret:true }
         ▼
┌────────────────────────────┐
│ swarm_config (encrypted)   │
└────────┬───────────────────┘
         │ docker-entrypoint.sh fetches at boot
         ▼
┌────────────────────────────┐
│ ~/.codex/auth.json (0600)  │
└────────────────────────────┘

Key files: src/providers/codex-oauth/{flow.ts,storage.ts,auth-json.ts,pkce.ts,types.ts}, src/commands/codex-login.ts, docker-entrypoint.sh L13–71, adapter fallback codex-adapter.ts L810–844.

The shape conversion (our flat {access, refresh, expires, accountId} → Codex CLI's {auth_mode: "chatgpt", tokens: {...}}) lives in credentialsToAuthJson() at src/providers/codex-oauth/auth-json.ts.

Generated files record the current time in last_refresh, separately from the access token's expiry. A Swarm-only expires field preserves the expiry in milliseconds for opaque-token round trips. When reading a file, the access token's JWT exp takes precedence, including after the CLI refreshes the token and rewrites the file without Swarm metadata. The reader falls back to expires, then to the historical expiry-in-last_refresh format for legacy files without a usable JWT expiry.

See also: Codex OAuth setup guide.

Boot-time credential restoration: precedence and the standalone fallback

docker-entrypoint.sh's Codex auth path 1 (L209–273) tries, in order, the first non-empty value from:

The guard on this block checks MCP_URL (defaulted at L26 from MCP_BASE_URL, falling back to http://host.docker.internal:3013), not the raw MCP_BASE_URL — a standalone worker that relies on that default would otherwise never reach the CODEX_OAUTH env-var fallback below, even though the config-store lookup it gates only needs some reachable API base URL, exactly like every other API call in this script.

  1. codex_oauth_0 in the resolved swarm_config (post-migration 071 pool slot 0).
  2. codex_oauth (legacy, pre-pool key).
  3. A plain CODEX_OAUTH container env var (docker run -e CODEX_OAUTH=...) — used only when neither config-store key has a row.

The third source exists because checkCodexCredentials (codex-adapter.ts) treats a bare CODEX_OAUTH env var as satisfiedBy: 'side-effect-pending' — it trusts this entrypoint block to materialize ~/.codex/auth.json from it before the first task runs. Resolving it into its own local variable (rather than reusing the CODEX_OAUTH name) also avoids clobbering the container-provided env var with an empty string when the config store has nothing — the bug tracked as issue #1102.

Standalone vs. pool refresh-token handling. The jq conversion that writes auth.json blanks tokens.refresh_token for config-store credentials (codex_oauth_0 / codex_oauth) — matching credentialsToAuthJson() — because those are pool credentials: the runner re-materializes a fresh auth.json per task from a live codex_oauth_<n> slot, and any earlier self-refresh outside the /api/oauth/refresh-locks lock (from a credential-wait probe, a manual run, or a crash loop hitting this boot-seeded file first) is an unlocked rotation that can revoke the whole token family. A CODEX_OAUTH container env var is single-slot/non-pool — the runner never refreshes it back into a codex_oauth_<n> config-store slot, and it never gets a per-task overwrite — so this boot-time write is the only copy of that credential the worker will ever have. Blanking its refresh token there would permanently strip the one thing that lets the Codex CLI renew an expired access token for the container's lifetime, so the standalone path preserves it as-is (both the already-shaped auth_mode: "chatgpt" form and the flat {access, refresh, accountId, expires} form, whose refresh field feeds tokens.refresh_token only on this path).

Per-task pool-slot detection must recognize the legacy key too. The blanked-refresh-token boot-seed above is only safe because the runner's per-task path (resolveCodexOAuthCredentialInfo in runner.ts, backed by loadAllCodexOAuthSlots() in storage.ts) revalidates/refreshes it through the locked getValidCodexOAuth() before every task — but only when it recognizes the credential as pool-backed and sets codexSlot. During a rolling upgrade where the control plane still exposes only the legacy codex_oauth row (not yet renamed to codex_oauth_0), loadAllCodexOAuthSlots() reports that row as pool slot 0 — mirroring loadCodexOAuth()'s existing slot-0 fallback — precisely so this case isn't misclassified as a standalone/non-pool auth.json. Without that, codexSlot stays undefined, resolveCodexAuthMode() (codex-adapter.ts) skips revalidation for an already-chatgpt-mode file, and the access token silently expires with no refresh token to renew it.


10. Pre-PR checklist for a new provider

Run before opening the PR (per CLAUDE.md):

bun run lint:fix
bun run tsc:check
bun run test:root
bash scripts/check-db-boundary.sh
bun run docs:openapi   # only if you added HTTP endpoints

Manual verification:

  • HARNESS_PROVIDER=foo bun run src/cli.tsx worker starts and connects.
  • A trivial task ("Say hi") runs to completion and posts progress + cost.
  • cancel-task via MCP actually aborts the in-flight run.
  • Advertised steering modes deliver as documented; unsupported modes degrade or fail according to onUnsupported.
  • docker build -f Dockerfile.worker . succeeds.
  • Full E2E with Docker (see CLAUDE.md "E2E testing with Docker") with -e HARNESS_PROVIDER=foo.
  • For OAuth providers: run bun run src/cli.tsx <foo>-login end-to-end, then boot a worker in Docker and verify it picks up the stored creds.

11. Files to touch — quick checklist

ConcernFile(s)
Adapter implementationsrc/providers/<foo>-adapter.ts
Factory registrationsrc/providers/index.ts
Types / enumssrc/types.ts, templates/schema.ts
Prompts (optional)src/prompts/<foo>/
OAuth (optional)src/providers/<foo>-oauth/*, src/commands/<foo>-login.ts
Setup CLI (optional, e.g. claude-managed)src/commands/<foo>-setup.ts
CLI wiringsrc/cli.tsx (COMMAND_HELP, command routing)
Docker bootstrapdocker-entrypoint.sh, Dockerfile.worker
Hooks (optional)src/hooks/*, src/providers/<foo>-swarm-events.ts
Skills resolver (if provider lacks native support)src/providers/<foo>-skill-resolver.ts
Models / pricingsrc/providers/<foo>-models.ts, src/be/seed-pricing.ts, src/be/pricing-normalize.ts
Reasoning effortpackages/model-catalog/src/reasoning.ts, src/providers/reasoning-effort.ts
Dashboard logsapps/ui/src/logs-parser/adapters.ts, apps/ui/src/logs-parser/index.ts
Runtime editorapps/ui/src/lib/agent-runtime-models.ts
Testssrc/tests/<foo>-*.test.ts
Integrations UI (optional)ui/src/lib/integrations-catalog.ts
DocsCLAUDE.md, README.md, this guide, Harness Configuration

claude-managed reference files: src/providers/claude-managed-adapter.ts, src/providers/claude-managed-swarm-events.ts, src/providers/claude-managed-models.ts, src/commands/claude-managed-setup.ts, ui/src/lib/integrations-catalog.ts.


12. Claude Managed Agents — pre-existing Agent + Environment pattern

claude-managed is the first reference adapter where the session runtime executes outside the worker container: the worker only opens an SSE stream against client.beta.sessions.events.stream and relays normalized events to the runner. This forces a few design decisions that are worth calling out — they apply to any future provider with a similar "managed cloud session" shape (Devin's /sessions API is the closest existing analog).

a. We don't agents.create at runtime

Anthropic's beta API has a 1:1 Agent ↔ identity model. Calling client.beta.agents.create(...) from each worker on each task would (a) leak agents into the customer's account at the rate of one per task, and (b) make skill / tool inventory non-deterministic per session. Instead, we treat the Agent and Environment as persistent infrastructure, created once during operator onboarding and persisted by ID. The adapter only ever calls client.beta.sessions.create({ agent: MANAGED_AGENT_ID, environment_id: MANAGED_ENVIRONMENT_ID, ... }).

b. The claude-managed-setup CLI

bun run src/cli.tsx claude-managed-setup

Implementation: src/commands/claude-managed-setup.ts. Behavior:

  1. Reads ANTHROPIC_API_KEY from .env / env (or prompts).
  2. Creates the Environment (client.beta.environments.create) — the long-lived sandbox configuration (allowed networks, default packages, persistent volumes).
  3. Uploads each remaining plugin/commands/*.md skill plus the seeded work-on-task and review-offered-task skills from templates/skills/ via client.beta.skills.create (one-shot per skill content hash; the CLI dedupes against an existing inventory).
  4. Creates the Agent (client.beta.agents.create) and attaches the freshly uploaded skills.
  5. PUT /api/config persists MANAGED_AGENT_ID + MANAGED_ENVIRONMENT_ID into swarm_config so deployed workers (and the integrations UI) can restore them at boot.
  6. Re-run with --force to recreate (rare — only if upstream rotates IDs).

This is the shape every future "managed cloud session" provider should follow: setup-once → IDs live in swarm_config → workers fail-fast at boot if absent.

c. System prompt in the user message + prompt-cache breakpoint

Managed-agents has no system field on sessions.create. The closest analog is client.beta.sessions.events.send({ events: [{ type: "user.message", content: [...] }] }). We compose the swarm's full assembled systemPrompt as the first content block and the per-task prompt as the second:

[
  { type: "text", text: <full system prompt + agent identity + skills>, cache_control: { type: "ephemeral" } },
  { type: "text", text: `User request:\n\n${prompt}` },  // no cache_control
]

The cache_control: { type: "ephemeral" } marker on the first block creates a prompt-cache breakpoint — Anthropic caches everything up to that boundary across sessions for the same agent, so subsequent tasks for the same agent re-use the static prefix at cache-read pricing. The per-task block sits after the breakpoint and is allowed to differ without invalidating the cache.

This is enforced by composeManagedUserMessage in claude-managed-adapter.ts (asserted byte-identical-prefix in src/tests/claude-managed-adapter.test.ts).

d. X-Source-Task-Id is dropped

The MCP integration §5 calls out that every adapter MUST set X-Source-Task-Id on the swarm MCP connection. claude-managed cannot. The MCP servers are configured server-side on the Anthropic-managed Agent (not per-session), and the SDK doesn't expose a per-session HTTP-header override. We instead pass the task ID via metadata.swarmTaskId on sessions.create, and the swarm MCP tools accept task_id as an explicit tool argument when the header is missing. New providers that hit the same constraint should follow this fallback.

e. Skill upload via beta.skills.create

Skills are not synced into a filesystem path on the worker (the worker doesn't run the model). They're uploaded once during claude-managed-setup via client.beta.skills.create({ content, name, ... }) and referenced by ID on the Agent. The remaining command skills come from plugin/commands/*.md. The runner turn skills, work-on-task and review-offered-task, come from templates/skills/<name>/config.json and content.md, rendered with the same frontmatter builder used by the seeder. Local workers receive that pair through DB-to-filesystem skill sync; managed agents receive the uploaded pair. Both sources remain in the repo.

f. SDK shape deviations to be aware of

The Anthropic Beta SDK has a few non-obvious surface differences from the conventional Anthropic messages.create API. The header comments in src/providers/claude-managed-adapter.ts (top-of-file block, ~L13–35) document them in detail, but the highlights for anyone reading the code:

  • Resource type is github_repository, not github_repo. The SDK type is BetaGitHubRepositoryResource and the literal field is type: "github_repository".
  • events.send takes { events: [...] } — an array, not a single event arg. The naming makes it look like events.send(event) would work; it would not.
  • Session status enum is 'rescheduling' | 'running' | 'idle' | 'terminated'. "Archived" is not a status — it's signaled by archived_at !== null. canResume() therefore rejects on terminated or non-null archived_at.
  • cache_control is a runtime-honored field that's NOT in the TS definition for BetaManagedAgentsTextBlock. We attach it via a typed extension and cast on the way out so the runtime payload includes it.
  • events.stream returns an AsyncIterable, not a Promise of an array — iterate with for await.
  • events.list is a PagePromise that's also AsyncIterable over historical session events; the resume path uses it to pre-fetch + dedupe against the live stream.

13. Further reading

On this page

Codex model availabilityWorker exit limitCredential readiness recovery1. The ProviderAdapter contractClaude execution transportsWhat each member doesProviderSessionConfig inputsProviderEvent (the normalized stream)2. Reference implementationsPi swarm MCP setupLocal subprocess teardownTask tool preloadingLive task steeringClaude queue-steering gateCodex app-server deliveryCodex hook delivery for legacy exec sessionspi-mono + Amazon Bedrock auth (alpha)Mode selectionCredential probeAccepted credential sourcesConfiguration referenceLive model enumerationBedrock probe card (Credentials tab)Cost & context trackingSwitching providers without restartPer-task outputSchema supportReasoning / effort controlAmp (amp)3. How a harness run fits into a task lifecycleThe flowSession-end identity / config sync (FS → DB)What fields on the task become ProviderSessionConfigAPI endpoints touched per taskResume semanticsSession-summary transcript sourcing4. Raw session logs & the task details pagePath conventionWhat each adapter writes to logFileLive transcript normalizationHow raw logs reach the task details pageWhat this means for a new provider5. Exposing the swarm MCP to the runtimeWhere the MCP server livesHow each reference adapter wires itRequired headersKey tools a harness will call mid-runFallback when MCP is unavailable6. System prompt composition & deliveryHow it is builtpi tool deferralpi codemodeHow each adapter delivers the promptWhat this means for a new provider7. Skills (how the three providers handle them)The three patternsWhere skill files come from8. Adding a new provider — step by stepStep 1 — Scaffold the adapterStep 2 — Register in the factoryStep 3 — Translate native events to ProviderEventStep 4 — Emit CostDataStep 5 — Model selectionStep 6 — Credentials & authStep 7 — MCP server injectionStep 8 — Swarm event hooks (cancellation, heartbeat, tool-loop detection)Step 9 — Skills and slash-commandsStep 10 — Worker bootstrap (Docker entrypoint)Step 11 — Login CLI (only if OAuth)Step 12 — Types & enumsStep 13 — PromptsStep 14 — TestsStep 15 — DocumentationSwarm readiness: what a provider must do before it is a workerHow to QA a new harness9. Codex OAuth: the full reference flowBoot-time credential restoration: precedence and the standalone fallback10. Pre-PR checklist for a new provider11. Files to touch — quick checklist12. Claude Managed Agents — pre-existing Agent + Environment patterna. We don't agents.create at runtimeb. The claude-managed-setup CLIc. System prompt in the user message + prompt-cache breakpointd. X-Source-Task-Id is droppede. Skill upload via beta.skills.createf. SDK shape deviations to be aware of13. Further reading