# Contributing (/docs/contributing)



Agent Swarm is open source and welcomes contributions from the community. Whether you're fixing a bug, adding a feature, creating a template, or improving documentation — we appreciate your help.

Getting Started [#getting-started]

If you're new to the project, start with the [Getting Started guide](/docs/getting-started) for an overview of the setup and architecture. The [Architecture Overview](/docs/architecture/overview) explains how the components fit together.

1. **Fork the repository** — [desplega-ai/agent-swarm](https://github.com/desplega-ai/agent-swarm)
2. **Clone your fork** and create a branch:
   ```bash
   git clone https://github.com/<your-username>/agent-swarm.git
   cd agent-swarm
   git checkout -b my-feature
   ```
3. **Install dependencies**:
   ```bash
   bun install
   ```

Code Contributions [#code-contributions]

Development Workflow [#development-workflow]

```bash
# Start the API server in dev mode
bun run dev:http

# Run a worker locally
bun run worker

# Lint and format
bun run lint:fix
bun run format

# Type-check
bun run tsc:check

# Run the root test suite
bun run test:root
```

Pull Request Guidelines [#pull-request-guidelines]

* **One concern per PR** — keep changes focused and reviewable
* **Write clear commit messages** — describe *why*, not just *what*
* **Follow existing patterns** — match the codebase's style and conventions
* **Test your changes** — run `bun run lint`, `bun run tsc:check`, and `bun run test:root` before submitting
* **Keep PRs small** — large PRs are harder to review. Break big features into smaller incremental PRs when possible

Code Style [#code-style]

* TypeScript throughout, strict mode
* Use `bun` as the package manager and runtime
* Prefer explicit types over `any`
* Follow the existing project structure (commands in `src/commands/`, tools in `src/tools/`, hooks in `src/hooks/`)

Contributing Templates [#contributing-templates]

Templates are a great way to contribute — they let other teams bootstrap specialized agents quickly.

Creating a Template [#creating-a-template]

1. Create a directory under `templates/community/<your-template-name>/`
2. Add the required files:

```
templates/community/your-template/
├── config.json      # Template metadata and agent defaults
├── SOUL.md          # Agent persona and values
├── IDENTITY.md      # Expertise, working style
├── CLAUDE.md        # Instructions and notes
├── TOOLS.md         # Environment-specific knowledge
└── start-up.sh      # Setup script (runs on container start)
```

3. Define your `config.json`:

```json
{
  "name": "your-template",
  "displayName": "Your Template",
  "description": "A short description of what this agent specializes in",
  "version": "1.0.0",
  "category": "community",
  "icon": "bot",
  "author": "Your Name <you@example.com>",
  "agentDefaults": {
    "role": "worker",
    "capabilities": ["your", "capabilities"],
    "maxTasks": 3
  },
  "files": {
    "claudeMd": "CLAUDE.md",
    "soulMd": "SOUL.md",
    "identityMd": "IDENTITY.md",
    "toolsMd": "TOOLS.md",
    "setupScript": "start-up.sh"
  }
}
```

4. Use `{{agent.name}}`, `{{agent.role}}`, and `{{agent.capabilities}}` placeholders in your identity files — they get interpolated at agent startup.

5. Submit a PR. Community templates are reviewed for quality and safety before merging.

Template Guidelines [#template-guidelines]

* **Clear purpose** — the template should serve a specific, well-defined role
* **Good defaults** — capabilities, max tasks, and identity files should make sense out of the box
* **Documentation** — include a brief description and any setup notes in the template's `CLAUDE.md`
* **No secrets** — never include API keys, tokens, or credentials in template files

Browse existing templates at [templates.agent-swarm.dev](https://templates.agent-swarm.dev) for inspiration.

Documentation [#documentation]

The docs site lives in `docs-site/` and uses [Fumadocs](https://fumadocs.dev) with MDX content.

Editing Docs [#editing-docs]

1. Content files are in `docs-site/content/docs/`
2. Add new pages as `.mdx` files in the appropriate directory
3. Register new pages in the section's `meta.json`
4. Build and verify:
   ```bash
   cd docs-site
   pnpm install
   pnpm build
   ```

Reporting Issues [#reporting-issues]

* Use [GitHub Issues](https://github.com/desplega-ai/agent-swarm/issues) for bugs and feature requests
* Include reproduction steps, expected vs. actual behavior, and relevant logs
* Check existing issues before opening a new one

Community [#community]

* [Discord](https://discord.gg/KZgfyyDVZa) — chat, ask questions, share your swarm setups
* [GitHub Discussions](https://github.com/desplega-ai/agent-swarm/discussions) — longer-form conversations

Useful References [#useful-references]

* [CLI Reference](/docs/reference/cli) — command details for managing agents, tasks, and configuration
* [MCP Tools Reference](/docs/reference/mcp-tools) — all tools available to agents in the swarm
* [Environment Variables](/docs/reference/environment-variables) — configuration options
* [Deployment Guide](/docs/guides/deployment) — production deployment with Docker Compose

License [#license]

Agent Swarm is released under the [MIT License](https://github.com/desplega-ai/agent-swarm/blob/main/LICENSE). By contributing, you agree that your contributions will be licensed under the same terms.


# Getting Started (/docs/getting-started)



This guide walks you through setting up a fully operational Agent Swarm with a lead agent and workers.

> Agent Swarm supports multiple AI providers via the **harness** system. The default is **Claude Code** (recommended), with Codex, opencode, pi-mono, Devin, Claude Managed Agents, and ACP-compatible agents also supported. See the [Harness Configuration](/docs/guides/harness-configuration) guide for details.

Prerequisites [#prerequisites]

* [Docker](https://docker.com) and Docker Compose
* [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed, with an OAuth token (`claude setup-token`) — or an API key for an [alternative provider](/docs/guides/harness-configuration)

Using Templates [#using-templates]

Agent Swarm ships with **official templates** that give your agents pre-configured identities, skills, and setup scripts. Instead of starting from scratch, pick a template that matches the role you need.

| Template                      | Role     | Best for                                                        |
| ----------------------------- | -------- | --------------------------------------------------------------- |
| **Lead**                      | `lead`   | Orchestrating workers, decomposing tasks, coordinating projects |
| **Coder**                     | `worker` | Implementation, PRs, code reviews                               |
| **Researcher**                | `worker` | Web research, analysis, documentation                           |
| **Reviewer**                  | `worker` | Code review, quality assurance                                  |
| **Tester**                    | `worker` | Testing, validation, CI/CD                                      |
| **Forward Deployed Engineer** | `worker` | Client-facing work, rapid prototyping                           |

To use a template, set the `TEMPLATE_ID` environment variable when starting an agent:

```bash
# Use the coder template for a worker
TEMPLATE_ID=official/coder docker compose up worker-1 -d

# Use the lead template
TEMPLATE_ID=official/lead docker compose up lead -d
```

Templates are fetched from the [Templates Registry](https://templates.agent-swarm.dev) and cached locally for 24 hours. You can also [create your own templates](/docs/contributing#contributing-templates) and contribute them to the community.

Browse all available templates at [templates.agent-swarm.dev](https://templates.agent-swarm.dev).

Option A: Onboard Wizard (Recommended) [#option-a-onboard-wizard-recommended]

The fastest way to get a full swarm running. The interactive wizard collects credentials, generates `docker-compose.yml` + `.env`, starts the stack, and verifies health.

```bash
bunx @desplega.ai/agent-swarm onboard
```

Or in non-interactive mode with a preset:

```bash
ANTHROPIC_API_KEY=sk-... bunx @desplega.ai/agent-swarm onboard --yes --preset=dev
```

Available presets: `full`, `dev`, `content`, `research`, `solo`. Use `--max-concurrent-tasks` to set each generated agent's task capacity from 1 to 100 (defaults: lead 2, worker 1), or `--dry-run` to preview without writing files.
When the wizard finishes, it prints a dashboard URL that already includes the generated `apiUrl` and `apiKey`, so the first dashboard session auto-connects to the new swarm.

Fresh self-hosted installs also seed a disabled starter catalog of schedules and workflows. Each bundled automation declares the parameters and integrations it needs. When you enable one with missing setup, Agent Swarm marks it `needs_setup`, shows the missing fields in the dashboard, and does not dispatch it until configuration is complete.

Option B: Manual Docker Compose [#option-b-manual-docker-compose]

Set up manually with Docker Compose for full control.

```bash
git clone https://github.com/desplega-ai/agent-swarm.git
cd agent-swarm

# Configure environment
cp .env.docker.example .env
# Edit .env — set API_KEY and CLAUDE_CODE_OAUTH_TOKEN at minimum
# Optionally set TEMPLATE_ID=official/coder for workers, TEMPLATE_ID=official/lead for leads

# Start everything
docker compose -f docker-compose.example.yml --env-file .env up -d
```

The API runs on port `3013`. For the dashboard, use the hosted [app.agent-swarm.dev](https://app.agent-swarm.dev) or run it yourself — see [Dashboard UI](/docs/ui).

Option C: Local API + Docker Workers [#option-c-local-api--docker-workers]

Run the API locally and connect Docker workers to it.

```bash
git clone https://github.com/desplega-ai/agent-swarm.git
cd agent-swarm
bun install

# 1. Configure and start the API server
cp .env.example .env
# Edit .env — set API_KEY
bun run start:http
```

In a new terminal, start a worker:

```bash
# 2. Configure and run a Docker worker
cp .env.docker.example .env.docker
# Edit .env.docker — set API_KEY (same as above) and CLAUDE_CODE_OAUTH_TOKEN
bun run docker:build:worker
mkdir -p ./logs ./work/shared ./work/worker-1
bun run docker:run:worker
```

Option D: Claude Code as Lead Agent [#option-d-claude-code-as-lead-agent]

Use Claude Code directly as the lead agent — no Docker required for the lead.

```bash
# After starting the API server (Option C, step 1):
bunx @desplega.ai/agent-swarm connect
```

This configures Claude Code to connect to the swarm. Start Claude Code and tell it:

```
Register yourself as the lead agent in the agent-swarm.
```

Verifying Your Setup [#verifying-your-setup]

Once your swarm is running, you can verify it's working:

1. **Check the API** — Visit `http://localhost:3013/health`
2. **Check agents** — Use the dashboard or API to see registered agents
3. **Send a test task** — Message the lead agent via Slack or the API

Configuration [#configuration]

Required Environment Variables [#required-environment-variables]

| Variable                  | Description                                   |
| ------------------------- | --------------------------------------------- |
| `API_KEY`                 | Secret key for API authentication             |
| `CLAUDE_CODE_OAUTH_TOKEN` | OAuth token for Claude CLI (default provider) |

> Credential requirements depend on your chosen harness provider. See [Harness Configuration](/docs/guides/harness-configuration) for alternatives like `ANTHROPIC_API_KEY` or `OPENROUTER_API_KEY`.

Optional Variables [#optional-variables]

| Variable           | Default                            | Description                                                                                                                                            |
| ------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `PORT`             | `3013`                             | API server port                                                                                                                                        |
| `HARNESS_PROVIDER` | `claude`                           | AI provider: `claude`, `codex`, `opencode`, `pi`, `devin`, `claude-managed`, or `acp`. See [Harness Configuration](/docs/guides/harness-configuration) |
| `AGENT_ID`         | Auto-generated                     | Stable UUID for task resume                                                                                                                            |
| `AGENT_NAME`       | Auto-generated                     | Display name for the agent                                                                                                                             |
| `AGENT_ROLE`       | `worker`                           | Role: `worker` or `lead`                                                                                                                               |
| `MCP_BASE_URL`     | `http://host.docker.internal:3013` | MCP server URL                                                                                                                                         |

For the complete list, see [Environment Variables](/docs/reference/environment-variables).

Next Steps [#next-steps]

* [Harness Configuration](/docs/guides/harness-configuration) — Configure any supported harness provider or a mixed swarm
* [Browse Templates](https://templates.agent-swarm.dev) — Explore and preview agent templates
* [Architecture Overview](/docs/architecture/overview) — Understand the system design
* [Task Lifecycle](/docs/concepts/task-lifecycle) — Learn how tasks flow through the swarm
* [Deployment Guide](/docs/guides/deployment) — Production deployment options
* [AgentMail Integration](/docs/integrations/agentmail) — Give agents their own email inboxes
* [Linear Integration](/docs/integrations/linear) — Bidirectional ticket tracking with Linear
* [Contributing](/docs/contributing) — Learn how to contribute code, templates, and docs


# Introduction (/docs)



Agent Swarm lets you run a team of AI coding agents that coordinate autonomously. A **lead agent** receives tasks (from you, Slack, or GitHub), breaks them down, and delegates to **worker agents** running in Docker containers. Workers execute tasks, report progress, and ship code — all without manual intervention.

Built by [desplega.sh](https://desplega.sh) — built by builders, for builders.

Key Features [#key-features]

* **Lead/Worker coordination** — A lead agent delegates and tracks work across multiple workers
* **Docker isolation** — Each worker runs in its own container with a full dev environment
* **Slack, GitHub, GitLab & Email integration** — Create tasks by messaging the bot, @mentioning it in issues/PRs/MRs, or sending an email
* **Task lifecycle** — Priority queues, dependencies, pause/resume across deployments
* **Task steering** — Add context to active work, interrupt supported harnesses, or degrade safely to queued input and follow-up tasks
* **Compounding memory** — Agents learn from every session and get smarter over time
* **Persistent identity** — Each agent has its own personality, expertise, and working style that evolves
* **Dashboard UI** — Real-time monitoring, parametric light/dark themes, per-user usage costs, requester-filtered task lists, operator configuration, inter-agent chat, and task attachment previews
* **Service discovery** — Workers can expose HTTP services and discover each other
* **Scheduled tasks** — Recurring and one-time task automation (cron, interval, or delayed)
* **Workflow automation** — DAG-based workflow engine with triggers, conditions, actions, and `foreach` fan-out/rejoin
* **Swarm Apps** — Versioned, schema-backed dashboard apps with reusable elements, named queries/actions, per-user configuration, RBAC, history, and rollback
* **E2B-backed eval harness** — Scenario × harness-config matrix testing against real swarm stacks with stored artifacts, deterministic checks, and LLM/agentic judges
* **x402 payments** — Agents can make USDC micropayments for x402-gated APIs
* **Agent-fs integration** — Persistent, searchable filesystem shared across the swarm with provider-backed task attachments and direct raw-download routes for workers
* **Debug dashboard** — SQL query interface for database inspection (lead-only)
* **[Linear integration](/docs/integrations/linear)** — Bidirectional ticket tracker sync with AgentSession lifecycle
* **Portless local dev** — Friendly domain URLs for local development
* **Onboard wizard** — Interactive CLI to set up a new swarm from scratch with Docker Compose
* **Skill system** — Reusable procedural knowledge that agents can create, share, install, and publish
* **External route bridge** — `agent-swarm x` and `swarm_x` expose approved third-party routes such as Composio without a custom MCP server
* **Config-driven metrics** — Define read-only SQL dashboards and render them in the UI with versioned metric definitions
* **Human-in-the-Loop** — Workflow nodes that pause for human approval or input via the dashboard
* **Approval requests UI** — Dashboard interface for reviewing and responding to HITL requests
* **MCP server management** — Register, install, and manage MCP servers for agents with scope cascade (agent → swarm → global)
* **Scripts-only MCP mode** — Reduce the exposed MCP surface to eight script tools while keeping the full swarm SDK available through `script-run`
* **Context usage tracking** — Monitor context window usage and compaction events per task
* **Slack HITL notifications** — Dispatch Slack notifications when approval requests are created
* **Unified user identity** — Canonical user registry with cross-platform resolution (Slack, GitHub, GitLab, Linear, email)
* **Per-repo guidelines** — Configurable PR checks, merge policy, and review guidance per repository
* **Cross-harness follow-up continuity** — Child tasks inherit a bounded parent-context preamble rebuilt from the task chain, so continuity survives restarts on every provider

How It Works [#how-it-works]

<Mermaid
  chart="graph TD
    User[&#x22;You (Slack / GitHub / Email / CLI)&#x22;] --> Lead[&#x22;Lead Agent&#x22;]
    Lead <--> API[&#x22;MCP API Server&#x22;]
    API <--> DB[&#x22;SQLite DB&#x22;]
    Lead --> W1[&#x22;Worker&#x22;]
    Lead --> W2[&#x22;Worker&#x22;]
    Lead --> W3[&#x22;Worker&#x22;]
    W1 -.- D1[&#x22;Docker container&#x22;]
    W2 -.- D2[&#x22;Docker container&#x22;]
    W3 -.- D3[&#x22;Docker container&#x22;]"
/>

1. **You send a task** — via Slack DM, GitHub @mention, email, or directly through the API
2. **Lead agent plans** — breaks the task down and assigns subtasks to workers
3. **Workers execute** — each in an isolated Docker container with git, Node.js, Python, etc.
4. **Progress is tracked** — real-time updates in the dashboard, Slack threads, or API
5. **Results are delivered** — PRs created, issues closed, Slack replies sent
6. **Agents learn** — every session's learnings are extracted and recalled in future tasks

Supported AI Assistants [#supported-ai-assistants]

Agent Swarm supports multiple AI coding assistants via the harness system:

* **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** (recommended) — Anthropic's official CLI for Claude
* **[Codex](https://github.com/openai/codex)** — OpenAI's coding agent with support for both API keys and ChatGPT OAuth
* **pi (pi-mono)** — Alternative provider backend for other model access
* **[Devin](https://devin.ai)** — Cognition's Devin via its managed `/sessions` API
* **Claude Managed Agents** — Anthropic's managed cloud sandbox; sessions execute outside the worker
* **opencode** — in-process [`@opencode-ai/sdk`](https://opencode.ai) server with SSE event mapping (experimental)
* **[Agent Client Protocol (ACP)](https://agentclientprotocol.com)** — run any ACP-speaking coding agent through the generic `acp` harness

See the [Harness Configuration](/docs/guides/harness-configuration) guide for setup instructions for each provider.

Next Steps [#next-steps]

* [Getting Started](/docs/getting-started) — Set up your first swarm
* [Architecture Overview](/docs/architecture/overview) — Understand how the system works
* [Core Concepts](/docs/concepts/task-lifecycle) — Learn about tasks, agents, and coordination
* [API Reference](/docs/api-reference) — REST API endpoints for agents, tasks, workflows, and more

***

*Last updated: September 8, 2026 — v1.142.0*


# Active Sessions (/docs/api-reference/active-sessions)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/active-sessions&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/active-sessions&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/active-sessions/{id}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/active-sessions/by-task/{taskId}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/active-sessions/cleanup&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/active-sessions/heartbeat/{taskId}&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/active-sessions/provider-session/{taskId}&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/active-sessions/recover-orphaned-tasks&#x22;,&#x22;method&#x22;:&#x22;post&#x22;}]" />


# Agents (/docs/api-reference/agents)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/agents&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/agents&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/agents/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/agents/{id}/activity&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/agents/{id}/credential-status&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/agents/{id}/credential-status&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/agents/{id}/harness-provider&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;},{&#x22;path&#x22;:&#x22;/api/agents/{id}/name&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/agents/{id}/profile&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/agents/{id}/runtime&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;},{&#x22;path&#x22;:&#x22;/api/agents/{id}/runtime-instances&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/agents/{id}/setup-script&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/agents/credential-status&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# API Keys (/docs/api-reference/api-keys)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/keys/available&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/keys/clear-rate-limit&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/keys/costs&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/keys/name&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;},{&#x22;path&#x22;:&#x22;/api/keys/report-rate-limit&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/keys/report-rate-limit-windows&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/keys/report-usage&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/keys/status&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# ApprovalRequests (/docs/api-reference/approvalrequests)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/approval-requests&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/approval-requests&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/approval-requests/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/approval-requests/{id}/respond&#x22;,&#x22;method&#x22;:&#x22;post&#x22;}]" />


# Apps (/docs/api-reference/apps)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/apps&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/apps&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}/actions/{name}&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}/models/{model}/rows&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}/models/{model}/rows&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}/models/{model}/rows/{rowId}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}/models/{model}/rows/{rowId}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}/models/{model}/rows/{rowId}&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}/models/{model}/rows/bulk&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}/queries/{name}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}/rollback&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}/sync&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}/user-config&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}/user-config&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}/versions&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/apps/{id}/versions/{version}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# Assets (/docs/api-reference/assets)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/assets&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/assets/{entityType}/{id}/key&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;},{&#x22;path&#x22;:&#x22;/api/assets/app/{id}/key&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;},{&#x22;path&#x22;:&#x22;/api/assets/key-audit&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/assets/mappings&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/assets/script/{id}/key&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;}]" />


# Budgets (/docs/api-reference/budgets)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/budgets&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/budgets/{scope}/{scopeId}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/budgets/{scope}/{scopeId}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/budgets/{scope}/{scopeId}&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/budgets/refusals&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# Config (/docs/api-reference/config)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/config&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/config&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/config/{id}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/config/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/config/env-presence&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/config/reload&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/config/resolved&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# Core (/docs/api-reference/core)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/close&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/ping&#x22;,&#x22;method&#x22;:&#x22;post&#x22;}]" />


# Debug (/docs/api-reference/debug)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/db-query&#x22;,&#x22;method&#x22;:&#x22;post&#x22;}]" />


# Ecosystem (/docs/api-reference/ecosystem)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/ecosystem&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# Events (/docs/api-reference/events)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/events&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/events&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/events/batch&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/events/counts&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# External APIs (/docs/api-reference/external-apis)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/x/script/{endpointId}&#x22;,&#x22;method&#x22;:&#x22;post&#x22;}]" />


# Favorites (/docs/api-reference/favorites)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/favorites&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/favorites&#x22;,&#x22;method&#x22;:&#x22;put&#x22;}]" />


# FS (/docs/api-reference/fs)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/fs/agent-credentials&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/fs/capabilities&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/fs/members/invite&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/fs/tasks/{taskId}/files&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/fs/tasks/{taskId}/files&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/fs/tasks/{taskId}/files/{attachmentId}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/fs/tasks/{taskId}/files/{attachmentId}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/fs/tasks/{taskId}/files/{attachmentId}/raw&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/fs/tasks/{taskId}/files/{attachmentId}/signed-url&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# Heartbeat (/docs/api-reference/heartbeat)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/heartbeat/checklist&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/heartbeat/sweep&#x22;,&#x22;method&#x22;:&#x22;post&#x22;}]" />


# Inbox State (/docs/api-reference/inbox-state)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/inbox-state&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/inbox-state&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;}]" />


# API Reference (/docs/api-reference)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

agent-swarm.dev API v1.142.0 [#agent-swarmdev-api-v11420]

Base URL: `http://localhost:3013`

352 endpoints across 43 categories.

Categories [#categories]

* [API Keys](/docs/api-reference/api-keys) — 8 endpoints
* [Active Sessions](/docs/api-reference/active-sessions) — 8 endpoints
* [Agents](/docs/api-reference/agents) — 13 endpoints
* [ApprovalRequests](/docs/api-reference/approvalrequests) — 4 endpoints
* [Apps](/docs/api-reference/apps) — 20 endpoints
* [Assets](/docs/api-reference/assets) — 6 endpoints
* [Budgets](/docs/api-reference/budgets) — 5 endpoints
* [Config](/docs/api-reference/config) — 7 endpoints
* [Core](/docs/api-reference/core) — 2 endpoints
* [Debug](/docs/api-reference/debug) — 1 endpoint
* [Ecosystem](/docs/api-reference/ecosystem) — 1 endpoint
* [Events](/docs/api-reference/events) — 4 endpoints
* [External APIs](/docs/api-reference/external-apis) — 1 endpoint
* [FS](/docs/api-reference/fs) — 9 endpoints
* [Favorites](/docs/api-reference/favorites) — 2 endpoints
* [Heartbeat](/docs/api-reference/heartbeat) — 2 endpoints
* [Inbox State](/docs/api-reference/inbox-state) — 2 endpoints
* [Integrations](/docs/api-reference/integrations) — 2 endpoints
* [KV](/docs/api-reference/kv) — 10 endpoints
* [MCP OAuth](/docs/api-reference/mcp-oauth) — 8 endpoints
* [MCP Servers](/docs/api-reference/mcp-servers) — 8 endpoints
* [Memory](/docs/api-reference/memory) — 12 endpoints
* [Metrics](/docs/api-reference/metrics) — 9 endpoints
* [OAuth](/docs/api-reference/oauth) — 6 endpoints
* [Pages](/docs/api-reference/pages) — 17 endpoints
* [Poll](/docs/api-reference/poll) — 2 endpoints
* [Pricing](/docs/api-reference/pricing) — 6 endpoints
* [PromptTemplates](/docs/api-reference/prompttemplates) — 10 endpoints
* [Repos](/docs/api-reference/repos) — 5 endpoints
* [Schedules](/docs/api-reference/schedules) — 7 endpoints
* [Script Connections](/docs/api-reference/script-connections) — 21 endpoints
* [Script Runs](/docs/api-reference/script-runs) — 10 endpoints
* [Scripts](/docs/api-reference/scripts) — 16 endpoints
* [Session Data](/docs/api-reference/session-data) — 7 endpoints
* [Sessions](/docs/api-reference/sessions) — 2 endpoints
* [Skills](/docs/api-reference/skills) — 17 endpoints
* [Stats](/docs/api-reference/stats) — 6 endpoints
* [Status](/docs/api-reference/status) — 2 endpoints
* [Tasks](/docs/api-reference/tasks) — 23 endpoints
* [Trackers](/docs/api-reference/trackers) — 14 endpoints
* [Users](/docs/api-reference/users) — 13 endpoints
* [Webhooks](/docs/api-reference/webhooks) — 5 endpoints
* [Workflows](/docs/api-reference/workflows) — 19 endpoints


# Integrations (/docs/api-reference/integrations)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/integrations/claude-managed/test&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/integrations/mcp-user/config&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# KV (/docs/api-reference/kv)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/kv&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/kv/_/{namespace}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/kv/_/{namespace}/{key}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/kv/_/{namespace}/{key}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/kv/_/{namespace}/{key}&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/kv/_/{namespace}/{key}/incr&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/kv/{key}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/kv/{key}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/kv/{key}&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/kv/{key}/incr&#x22;,&#x22;method&#x22;:&#x22;post&#x22;}]" />


# MCP OAuth (/docs/api-reference/mcp-oauth)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/mcp-oauth/{mcpServerId}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/mcp-oauth/{mcpServerId}/authorize&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/mcp-oauth/{mcpServerId}/authorize-url&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/mcp-oauth/{mcpServerId}/manual-client&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/mcp-oauth/{mcpServerId}/metadata&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/mcp-oauth/{mcpServerId}/refresh&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/mcp-oauth/{mcpServerId}/status&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/mcp-oauth/callback&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# MCP Servers (/docs/api-reference/mcp-servers)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/agents/{id}/mcp-servers&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/mcp-servers&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/mcp-servers&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/mcp-servers/{id}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/mcp-servers/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/mcp-servers/{id}&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/mcp-servers/{id}/install&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/mcp-servers/{id}/install/{agentId}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;}]" />


# Memory (/docs/api-reference/memory)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/memory/{id}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/memory/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/memory/edges&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/memory/edit&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/memory/health&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/memory/index&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/memory/list&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/memory/rate&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/memory/re-embed&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/memory/retrievals&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/memory/search&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/memory/usefulness&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# Metrics (/docs/api-reference/metrics)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/metrics/definitions&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/metrics/definitions&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/metrics/definitions/{id}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/metrics/definitions/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/metrics/definitions/{id}&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/metrics/definitions/{id}/run&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/metrics/definitions/{id}/versions&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/metrics/definitions/{id}/versions/{version}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/metrics/schema&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# OAuth (/docs/api-reference/oauth)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/oauth/{provider}/callback&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth/callback&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth/keep-warm/codex&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth/redirect-uri&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth/refresh-locks/{key}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth/refresh-locks/{key}&#x22;,&#x22;method&#x22;:&#x22;post&#x22;}]" />


# Pages (/docs/api-reference/pages)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/@swarm/api/{path}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/@swarm/api/{path}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/@swarm/api/{path}&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;},{&#x22;path&#x22;:&#x22;/@swarm/api/{path}&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/@swarm/api/{path}&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/pages&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/pages&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/pages/{id}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/pages/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/pages/{id}&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/pages/{id}/launch&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/pages/{id}/versions&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/pages/{id}/versions/{version}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/pages/actions&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/pages/resolve&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/p/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/p/{id}.json&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# Poll (/docs/api-reference/poll)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/channel-activity/commit-cursors&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/poll&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# Pricing (/docs/api-reference/pricing)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/models-catalog&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/pricing&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/pricing/{provider}/{model}/{tokenClass}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/pricing/{provider}/{model}/{tokenClass}&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/pricing/{provider}/{model}/{tokenClass}/{effectiveFrom}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/pricing/{provider}/{model}/{tokenClass}/active&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# PromptTemplates (/docs/api-reference/prompttemplates)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/prompt-templates&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/prompt-templates&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/prompt-templates/{id}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/prompt-templates/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/prompt-templates/{id}/checkout&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/prompt-templates/{id}/reset&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/prompt-templates/events&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/prompt-templates/preview&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/prompt-templates/render&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/prompt-templates/resolved&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# Repos (/docs/api-reference/repos)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/repos&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/repos&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/repos/{id}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/repos/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/repos/{id}&#x22;,&#x22;method&#x22;:&#x22;put&#x22;}]" />


# Schedules (/docs/api-reference/schedules)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/schedules&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/schedules&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/schedules/{id}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/schedules/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/schedules/{id}&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;},{&#x22;path&#x22;:&#x22;/api/schedules/{id}&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/schedules/{id}/run&#x22;,&#x22;method&#x22;:&#x22;post&#x22;}]" />


# Script Connections (/docs/api-reference/script-connections)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/credential-bindings&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/credential-bindings&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/integrations-catalog&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/integrations-catalog/{domain}/surface&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth-apps&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth-apps&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth-apps/{id}/authorizations&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth-apps/{id}/authorize-url&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth-apps/{provider}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth-apps/{provider}/refresh&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth-apps/{provider}/tokens&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth-apps/discover&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth-authorizations/{id}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth-authorizations/{id}/refresh&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/oauth-presets&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/script-connections&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/script-connections&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/script-connections/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/script-connections/{id}/disable&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/script-connections/{id}/mcp-call&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/script-connections/{id}/refresh&#x22;,&#x22;method&#x22;:&#x22;post&#x22;}]" />


# Script Runs (/docs/api-reference/script-runs)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/internal/raw-llm&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/internal/script-runs/{runId}/agent-task&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/internal/script-runs/{runId}/heartbeat&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/internal/script-runs/{runId}/status&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/internal/script-runs/{runId}/steps&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/internal/script-runs/{runId}/steps/{stepKey}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/script-runs&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/script-runs&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/script-runs/{id}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/script-runs/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# Scripts (/docs/api-reference/scripts)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/mcp-bridge&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/scripts&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/scripts/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/scripts/{id}/apis&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/scripts/{id}/apis&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/scripts/{id}/apis/{endpointId}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/scripts/{id}/apis/{endpointId}&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;},{&#x22;path&#x22;:&#x22;/api/scripts/{id}/apis/{endpointId}/rotate&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/scripts/{id}/apis/{endpointId}/secret&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/scripts/{id}/versions&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/scripts/{name}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/scripts/{name}/types&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/scripts/run&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/scripts/search&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/scripts/type-defs&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/scripts/upsert&#x22;,&#x22;method&#x22;:&#x22;post&#x22;}]" />


# Session Data (/docs/api-reference/session-data)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/attribution/by-person&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/session-costs&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/session-costs&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/session-costs/dashboard&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/session-costs/summary&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/session-logs&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{taskId}/session-logs&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# Sessions (/docs/api-reference/sessions)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/sessions&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/sessions/{rootTaskId}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# Skills (/docs/api-reference/skills)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/agents/{id}/skills&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/agents/{id}/skills/signature&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/skills&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/skills&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/skills/{id}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/skills/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/skills/{id}&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/skills/{id}/files&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/skills/{id}/files&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/skills/{id}/files/{path}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/skills/{id}/files/{path}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/skills/{id}/files/{path}&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/skills/{id}/install&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/skills/{id}/install/{agentId}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/skills/install-remote&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/skills/sync-filesystem&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/skills/sync-remote&#x22;,&#x22;method&#x22;:&#x22;post&#x22;}]" />


# Stats (/docs/api-reference/stats)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/concurrent-context&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/logs&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/metrics&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/scheduled-tasks&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/services&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/stats&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# Status (/docs/api-reference/status)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/status&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/status/test-connection&#x22;,&#x22;method&#x22;:&#x22;post&#x22;}]" />


# Tasks (/docs/api-reference/tasks)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/paused-tasks&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/steering-messages&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/steering-messages/{id}/delivered&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/steering-messages/{id}/handled&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/steering-messages/{id}/undeliverable&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/task-templates&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{id}/cancel&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{id}/context&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{id}/context&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{id}/finish&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{id}/pause&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{id}/progress&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{id}/promote-draft&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{id}/resume&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{id}/session&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{id}/steer&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{id}/steering-messages&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{id}/supersede&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{id}/title&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;},{&#x22;path&#x22;:&#x22;/api/tasks/{id}/vcs&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;}]" />


# Trackers (/docs/api-reference/trackers)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/trackers/jira/authorize&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/trackers/jira/callback&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/trackers/jira/disconnect&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/trackers/jira/refresh&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/trackers/jira/status&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/trackers/jira/webhook-register&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/trackers/jira/webhook/{id}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/trackers/jira/webhook/{token}&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/trackers/linear/authorize&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/trackers/linear/callback&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/trackers/linear/disconnect&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/trackers/linear/refresh&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/trackers/linear/status&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/trackers/linear/webhook&#x22;,&#x22;method&#x22;:&#x22;post&#x22;}]" />


# TypeScript Types (/docs/api-reference/typescript)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

Every endpoint in this reference declares its request **and response** schemas in the
OpenAPI 3.1 spec, so a generated client is fully typed end to end — no `unknown`
responses. Each operation page also shows its exact response type inline (the
"TypeScript Definitions" panel under each response code).

Pre-generated definitions [#pre-generated-definitions]

* [openapi.d.ts](/openapi.d.ts) — TypeScript definitions for all 352 operations (`paths` + `components`), generated with [openapi-typescript](https://openapi-ts.dev)
* [openapi.json](https://github.com/desplega-ai/agent-swarm/blob/main/openapi.json) — the spec itself (also served at `GET /openapi.json` by every running server)

Generate your own [#generate-your-own]

```bash
bunx openapi-typescript http://localhost:3013/openapi.json -o api.d.ts
```

Use with openapi-fetch [#use-with-openapi-fetch]

```ts
import createClient from "openapi-fetch";
import type { components, paths } from "./api";

const client = createClient<paths>({
  baseUrl: "http://localhost:3013",
  headers: { Authorization: `Bearer ${process.env.AGENT_SWARM_API_KEY}` },
});

// Fully typed: data is { tasks: AgentTask[] | AgentTaskSummary[]; total: number }
const { data, error } = await client.GET("/api/tasks", {
  params: { query: { status: "in_progress" } },
});

// Entity types come from the named schema components
type AgentTask = components["schemas"]["AgentTask"];
type Workflow = components["schemas"]["Workflow"];
```


# Users (/docs/api-reference/users)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/users&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/users&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/users/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/users/{id}&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;},{&#x22;path&#x22;:&#x22;/api/users/{id}/events&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/users/{id}/identities&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/users/{id}/identities/{kind}/{externalId}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/users/{id}/mcp-tokens&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/users/{id}/mcp-tokens/{tokenId}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/users/{id}/merge&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/users/unmapped&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/users/unmapped/{kind}/{externalId}/resolve&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/whoami&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# Webhooks (/docs/api-reference/webhooks)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/agentmail/webhook&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/github/webhook&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/gitlab/webhook&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/integrations/kapso/webhook&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/webhooks/{workflowId}&#x22;,&#x22;method&#x22;:&#x22;post&#x22;}]" />


# Workflows (/docs/api-reference/workflows)



{/* This file was generated by scripts/generate-docs.ts. Do not edit manually. */}

<APIPage document="&#x22;../openapi.json&#x22;" operations="[{&#x22;path&#x22;:&#x22;/api/executor-types&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/executor-types/{type}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/workflow-events&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/workflow-runs/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/workflow-runs/{id}/cancel&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/workflow-runs/{id}/retry&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/workflow-runs/{runId}/events&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/workflows&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/workflows&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/workflows/{id}&#x22;,&#x22;method&#x22;:&#x22;delete&#x22;},{&#x22;path&#x22;:&#x22;/api/workflows/{id}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/workflows/{id}&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;},{&#x22;path&#x22;:&#x22;/api/workflows/{id}&#x22;,&#x22;method&#x22;:&#x22;put&#x22;},{&#x22;path&#x22;:&#x22;/api/workflows/{id}/nodes/{nodeId}&#x22;,&#x22;method&#x22;:&#x22;patch&#x22;},{&#x22;path&#x22;:&#x22;/api/workflows/{id}/runs&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/workflows/{id}/trigger&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/workflows/{id}/trigger/validate&#x22;,&#x22;method&#x22;:&#x22;post&#x22;},{&#x22;path&#x22;:&#x22;/api/workflows/{id}/versions&#x22;,&#x22;method&#x22;:&#x22;get&#x22;},{&#x22;path&#x22;:&#x22;/api/workflows/{id}/versions/{version}&#x22;,&#x22;method&#x22;:&#x22;get&#x22;}]" />


# Receipts (/docs/receipts)



**Receipts** are workflow recipes — battle-tested swarm configurations that any operator can drop into their own deployment with minimal adaptation. Each receipt ships with:

* A description of the pattern and when to use it
* A "Copy for your swarm" prompt you can paste into your lead agent's session to auto-configure the workflow
* A downloadable workflow JSON definition with placeholders for your agent IDs, org IDs, and Slack channels

Receipts are intentionally generic. They define the **shape** of a workflow; you bring the **work**.

Available receipts [#available-receipts]

Workflows [#workflows]

* [Ralph Loop](/docs/receipts/workflows/ralph-loop) — A reusable iterative agent loop that pairs a worker with an analyst. Persistent scratch space in `agent-fs`, max-iteration safety cap, automatic Slack notifications on success/failure.
* [One-off Script Workflow runs](/docs/guides/script-workflow-runs) — Copy-paste durable run patterns for classify-and-act, fan-out-and-synthesize verification, loop-until-done refinement, and SDK-launched one-off runs. Use these as receipts when a job needs a workflow-shaped harness but not a registered reusable DAG.

How to use a receipt [#how-to-use-a-receipt]

Two paths, depending on how hands-on you want to be:

1. **Paste the prompt.** Each receipt page has a "Copy for your swarm" block at the top. Paste it into a Claude session connected to your swarm's lead agent — the lead will fetch the JSON, adapt the IDs to your deployment, and create the workflow via the `create-workflow` MCP tool.
2. **Manual install.** Download the workflow JSON, replace the `<your-...>` placeholders with values from your swarm, and call `create-workflow` yourself with the resulting object.

See [Workflows](/docs/concepts/workflows) for the underlying concepts and [create-workflow](/docs/api-reference/workflows#create-workflow) in the API reference.


# Build an App (/docs/apps/build-an-app)



<Callout type="warn" title="Beta — requires agent-swarm 1.129.0 or later">
  Apps are in **beta**. The definition contract and APIs may change between releases. Core Apps require **agent-swarm 1.129.0**; source-backed model sync requires **1.130.0**; canonical asset namespace support for Apps requires **1.131.0**.
</Callout>

The most direct way to build an App is to describe the outcome to your lead agent. The lead can use the `/apps` skill to turn the request into a validated definition, create it with `app-upsert`, and return the dashboard URL.

A complete watchlist App [#a-complete-watchlist-app]

This smaller, sanitized example is derived from a real Competitor Tracker App. It keeps the same useful shape—typed records, a sorted query, a task action, a creation form, and a table row action—without publishing any live rows or organization-specific prompts.

Ask the lead to create the following with `app-upsert`:

```json
{
  "name": "Watchlist Tracker",
  "description": "Track products worth revisiting and dispatch fresh research",
  "definition": {
    "models": {
      "subject": {
        "columns": {
          "name": { "kind": "string", "required": true, "index": true },
          "category": {
            "kind": "enum",
            "enum": ["direct", "adjacent", "reference"],
            "default": "reference"
          },
          "status": {
            "kind": "enum",
            "enum": ["active", "paused", "archived"],
            "default": "active"
          },
          "summary": { "kind": "string" },
          "url": { "kind": "string" },
          "lastReviewed": { "kind": "date" }
        }
      }
    },
    "queries": {
      "allSubjects": {
        "model": "subject",
        "sort": { "column": "name", "dir": "asc" },
        "limit": 300
      }
    },
    "actions": {
      "research": {
        "kind": "task",
        "prompt": "Research the supplied watchlist subject against current primary sources. Report what changed, the strongest point of differentiation, and any corrections the stored row needs."
      }
    },
    "pages": {
      "main": {
        "title": "Watchlist",
        "root": "root",
        "elements": {
          "root": {
            "type": "Stack",
            "props": { "direction": "column", "gap": "lg", "padding": "md" },
            "children": ["heading", "addCard", "tableCard"]
          },
          "heading": {
            "type": "Heading",
            "props": { "text": "Watchlist", "level": "h1" }
          },
          "addCard": {
            "type": "Card",
            "props": { "title": "Add a subject" },
            "children": ["addForm"]
          },
          "addForm": {
            "type": "Form",
            "props": {
              "id": "newSubject",
              "fields": [
                { "name": "name", "label": "Name", "required": true },
                { "name": "category", "label": "Category", "kind": "enum", "options": ["direct", "adjacent", "reference"] },
                { "name": "summary", "label": "Summary", "kind": "text" },
                { "name": "url", "label": "URL" }
              ],
              "submitLabel": "Add subject",
              "onSubmit": [
                {
                  "action": "app.mutate",
                  "params": { "model": "subject", "op": "create", "values": { "$form": "" } }
                }
              ]
            }
          },
          "tableCard": {
            "type": "Card",
            "props": { "title": "Tracked subjects" },
            "children": ["table"]
          },
          "table": {
            "type": "Table",
            "props": {
              "data": { "$state": "/queries/allSubjects/data" },
              "loading": { "$state": "/queries/allSubjects/loading" },
              "error": { "$state": "/queries/allSubjects/error" },
              "emptyMessage": "Nothing on the watchlist yet.",
              "columns": [
                { "key": "name", "label": "Name" },
                { "key": "category", "label": "Category", "kind": "badge" },
                { "key": "status", "label": "Status", "kind": "badge" },
                { "key": "summary", "label": "Summary" },
                { "key": "lastReviewed", "label": "Last reviewed", "kind": "date" }
              ],
              "rowActions": [
                {
                  "label": "Research",
                  "actions": [
                    {
                      "action": "app.action",
                      "params": { "name": "research", "input": { "subject": { "$row": "" } } }
                    }
                  ]
                }
              ]
            }
          }
        }
      }
    },
    "defaultPage": "main"
  }
}
```

The response includes an App ID and `/apps/<id>` URL. Open that route in the [dashboard](/docs/ui) to add a row and dispatch the Research action.

How a Meetings workflow maps to an App [#how-a-meetings-workflow-maps-to-an-app]

A meeting decision register can use the same primitives:

| Need                        | App primitive                                                                                 |
| --------------------------- | --------------------------------------------------------------------------------------------- |
| Durable decision record     | A `decision` model with meeting ID, proposal, status, approvers, rationale, and decision date |
| Approval inbox              | A `pendingDecisions` named query filtered to `status: "pending"`                              |
| Multi-agent validation gate | A named script action that checks the required reviews and applies the approved transition    |
| Human control               | A table or detail page with Review and Approve actions                                        |
| Team-specific access        | App ownership and `app.use` permissions                                                       |
| Auditable changes           | Row authorship plus definition history, diff, and rollback                                    |

This keeps the meeting-specific schema and policy in the App. If the approval rule changes, patch the action and UI instead of adding another permanent subsystem to the swarm.

Messages to send your lead agent [#messages-to-send-your-lead-agent]

These messages are intentionally complete enough to paste into Slack as-is.

Create an App [#create-an-app]

> Build me an App called Customer Signals. Track company, signal type, source URL, observed date, summary, owner, and status. I need a newest-first view, filters for owner and status, a form to add a signal, and a row action that asks the swarm to investigate it. Show me the App when it is ready.

Add a view [#add-a-view]

> Update Customer Signals with a second page called Account detail. Clicking a company in the main table should open that page and show every signal for the selected company, newest first. Keep the current page and data unchanged otherwise.

Add an action [#add-an-action]

> Add a Triage row action to Customer Signals. It should send the complete selected row to the lead with a prompt to verify the source, assess urgency, and recommend the next step. Confirm before dispatching the task.

Evolve the schema [#evolve-the-schema]

> Change Customer Signals so the old priority string becomes an enum with low, medium, and high. Map urgent to high, normal to medium, and everything else to low. Hide the old field instead of purging it, and update every query and page binding in the same patch.

Synchronize an external source [#synchronize-an-external-source]

> On agent-swarm 1.130.0 or later, sync open GitHub issues from our registered GitHub connection into Customer Signals. Use issue number as the join key, project title, URL, labels, and opened date into source-owned columns, keep owner and notes editable, and add a Refresh action plus synced and stale columns to the table.

Personalize the App [#personalize-the-app]

> Add per-user settings for default owner and whether archived signals are shown. Use those preferences in the page without storing them on shared rows. Keep the current theme as the definition default and let viewers override it.

The authoring loop [#the-authoring-loop]

Agents should make App changes through a read-patch-validate loop:

1. Run `app-list` to discover the App ID when it is not already known.
2. Run `app-get` and read the complete current definition.
3. Use `app-upsert` only for creation or an intentional full replacement. For iteration, prefer `app-patch` with the smallest coherent subtree.
4. If validation rejects the update, fix every returned `issues[]` entry and retry. The rejected write did not change the saved App.
5. Open `/apps/<id>` in the dashboard and verify the affected form, query, and action.
6. Before a risky change, inspect `app-history` and `app-diff`. Use `app-rollback` when restoring an earlier definition is safer than another patch.

Definition patches use JSON Merge Patch semantics. Arrays and scalar values replace; many declarations—including page elements, actions, columns, and parameters—are atomic, so restate the complete declaration you want to keep. Never guess the current definition or send a partial definition through `app-upsert`.

For more complete starting points, continue to [App recipes](/docs/apps/recipes). For endpoint details, see the [Apps API reference](/docs/api-reference/apps). For the definition primitives, return to [App concepts](/docs/apps/concepts).


# App concepts (/docs/apps/concepts)



<Callout type="warn" title="Beta — requires agent-swarm 1.129.0 or later">
  Apps are in **beta**. The definition contract and APIs may change between releases. Core Apps require **agent-swarm 1.129.0**; source-backed model sync requires **1.130.0**; canonical asset namespace support for Apps requires **1.131.0**.
</Callout>

An App definition is the versioned contract for its data and interface. The server validates the complete definition before saving it, so a rejected update leaves the current App unchanged.

Models [#models]

Models define persistent rows. Each model has typed columns: `string`, `number`, `boolean`, `date`, or `enum`. Columns can be required, defaulted, or indexed where the kind supports it. The server adds identity, timestamps, and authorship fields to every row.

Prefer hiding a column when a field is being retired. Hiding preserves existing values and keeps rollback possible. Purging a column is an explicit, irreversible migration and should be reserved for data that really must be removed.

Named queries [#named-queries]

Queries give an App a declared read surface. A query selects one model and can add equality filters, sorting, and a row limit. Parameter placeholders such as `{ "$param": "competitor" }` let a detail page or agent request one specific record without exposing an unfiltered fallback.

Agents call named queries with `app-query`; saved scripts use `ctx.swarm.app_query`. Scripts also receive generated per-App TypeScript types for models and queries starting in 1.129.0.

Actions [#actions]

An App can declare up to 20 named actions:

* **`task`** dispatches a prompt to the swarm. Invocation input is appended as context, so a table row can travel with the task.
* **`script`** runs a saved script with definition defaults plus invocation input.
* **`sync`** refreshes all or a selected subset of the App's external sources.

Pages invoke these through `app.action`. Built-in interface actions also create, update, or delete rows; refresh queries; and navigate between App pages.

Pages and reusable elements [#pages-and-reusable-elements]

Pages are flat, validated element trees rendered by the dashboard. The component catalog includes layout, headings, text, forms, tables, filters, metrics, alerts, badges, drawers, and detail views. Bindings such as `{ "$state": "/queries/allItems/data" }` connect declared queries and actions to those elements.

Reusable elements live in the versioned definition. They are private by default and can be explicitly exported for another App to reference. The server compatibility-checks breaking changes to exported elements.

Per-user configuration and theme [#per-user-configuration-and-theme]

`userConfig` declares typed settings, but each user's values are stored separately from the shared definition and rows. This is useful for preferences such as a default region, reporting window, or whether archived records are shown.

An optional definition `theme` sets the App's default canvas preset. Viewers can override it per App without changing the shared definition. Treat the definition theme as a default, not an enforcement mechanism.

Versioning, migrations, and rollback [#versioning-migrations-and-rollback]

Every successful definition write snapshots the previous definition. Use `app-history` to find a version, `app-diff` to inspect it against the current definition, and `app-rollback` to restore it.

Rollback is a forward schema migration over today's rows, not row-level time travel. A lossy migration or rollback must state how values are mapped, coerced, backfilled, or purged. If validation rejects it, no definition or row changes are written.

RBAC, ownership, and scope [#rbac-ownership-and-scope]

Apps have permission-aware lifecycle and use operations. Ownership and App permissions determine who can inspect, change, or use an App; they do not make every connected source private. In particular, synchronized rows are visible to principals with `app.use`, so scope task sources and external projections to the audience intended to read them.

From 1.131.0, Apps participate in canonical asset namespaces. That makes App ownership and movement fit the same namespace model as other swarm assets while preserving RBAC checks.

Source-backed models [#source-backed-models]

Source-backed model sync arrived in 1.130.0. A model can project records from a saved script or from the swarm task pool into normal App rows. Each source declares a stable join key, and source-bound columns are read-only between refreshes; App-owned columns on the same row remain editable.

Synced rows expose provenance and freshness through `source`, `syncedAt`, and `stale`. Refresh through a `sync` action, the sync API, `app-sync`, or a schedule. Script sources use the source script owner's credentials and should reference a registered connection instead of embedding secrets.

Next, [build a complete App and iterate on it](/docs/apps/build-an-app), or consult the [Apps API reference](/docs/api-reference/apps).


# Apps (/docs/apps)









<Callout type="warn" title="Beta — requires agent-swarm 1.129.0 or later">
  Apps are in **beta**. The definition contract and APIs may change between releases. Core Apps require **agent-swarm 1.129.0**; source-backed model sync requires **1.130.0**; canonical asset namespace support for Apps requires **1.131.0**.
</Callout>

A Swarm App is a persistent application authored and maintained by agents. It combines structured data, named queries, actions that call scripts or dispatch tasks, and a validated interface that people use from the dashboard.

Apps are useful when the swarm needs to keep live records and give people a repeatable way to inspect or change them. Examples include an issue triage board, competitor watchlist, approval queue, content calendar, or meeting decision register.

Why Apps instead of a core feature? [#why-apps-instead-of-a-core-feature]

Build an App when the workflow is specific to a team or use case but can be described with these primitives:

* a schema for the records you need to keep;
* named queries for the views people and agents need;
* actions for work the swarm or a script should perform; and
* forms, tables, detail views, and controls for the human interface.

For example, a gated multi-agent meeting decision does not need a bespoke Meetings subsystem. It can be a `decision` model, a query for decisions awaiting approval, a validation action, and a page that exposes the gate. The behavior stays versioned with the App and can evolve without widening the swarm's core product surface.

Build a core feature only when the capability is infrastructure that every swarm needs, cannot be expressed safely through the App contract, or must participate in runtime behavior below the App layer.

What you get [#what-you-get]

* **Live data:** typed models with string, number, boolean, date, and enum columns.
* **Purpose-built reads:** named, parameterized queries instead of UI-side data scraping.
* **Agentic actions:** buttons and row actions can run a saved script, dispatch a task, or refresh a synchronized source.
* **Validated UI:** pages are composed from a constrained component catalog and checked before storage.
* **Safe iteration:** definition history, diffs, schema migrations, and rollback are built in.
* **Personalization:** per-user settings and viewer-specific theme overrides live outside shared row data.
* **Access control:** App lifecycle and use permissions follow swarm RBAC and ownership rules.

What Apps look like [#what-apps-look-like]

The Apps catalog gives every live App a stable dashboard route. The examples below use seeded sample data; they contain no customer records or credentials.

<img alt="The Apps catalog with Launch Readiness, Customer Signals, and Decision Register examples" src="__img0" />

Each App renders its validated page definition against live named-query data. This Launch Readiness example combines metrics, filters, a populated table, and an action that can dispatch work to the swarm.

<img alt="A populated Launch Readiness App with metrics, filters, milestones, and actions" src="__img1" />

Per-user configuration stays outside the shared definition and rows. The settings drawer exposes those private values alongside the viewer's theme override.

<img alt="The Launch Readiness settings drawer with theme and per-user preferences" src="__img2" />

Start here [#start-here]

* [Understand the App primitives](/docs/apps/concepts).
* [Build and iterate on an App](/docs/apps/build-an-app), including a complete example and messages you can send to the lead agent.
* [Start from a worked recipe](/docs/apps/recipes) for a DB-only tracker, a Linear and GitHub delivery view, or gated meeting decisions.
* Open the [Apps dashboard surface](/docs/ui#apps) to use the result.
* Use the [Apps API reference](/docs/api-reference/apps) for lifecycle, row, query, sync, history, and user-config endpoints.

<Callout type="info" title="Deployment capability">
  App tools are available when the server's `CAPABILITIES` includes `pages`, for example `CAPABILITIES=core,task-pool,pages`. There is no separate `apps` capability flag.
</Callout>


# App recipes (/docs/apps/recipes)



<Callout type="warn" title="Beta — requires agent-swarm 1.129.0 or later">
  Apps are in **beta**. The definition contract and APIs may change between releases. Core Apps require **agent-swarm 1.129.0**; source-backed model sync requires **1.130.0**; canonical asset namespace support for Apps requires **1.131.0**.
</Callout>

These recipes are starting points, not screenshots of a fixed product template. Send the included message to your lead agent, or copy the `app-upsert` input and adjust the names and fields. After creation, use `app-get` followed by focused `app-patch` calls rather than replacing a definition you have not just read.

DB-only: Team Notes [#db-only-team-notes]

This is the smallest complete pattern: one model stored only in the swarm database, one named query, a form, and a table. It has no source, connection, saved script, or sync action. Rows change only through App mutations.

```json
{
  "name": "Team Notes",
  "description": "Keep decisions and follow-ups in one shared register",
  "definition": {
    "models": {
      "note": {
        "columns": {
          "title": { "kind": "string", "required": true },
          "category": {
            "kind": "enum",
            "enum": ["decision", "follow_up", "context"],
            "default": "context"
          },
          "owner": { "kind": "string" },
          "followUp": { "kind": "date" },
          "archived": { "kind": "boolean", "default": false }
        }
      }
    },
    "queries": {
      "activeNotes": {
        "model": "note",
        "filter": { "archived": false },
        "sort": { "column": "createdAt", "dir": "desc" }
      }
    },
    "pages": {
      "main": {
        "title": "Team notes",
        "root": "root",
        "elements": {
          "root": {
            "type": "Stack",
            "props": { "direction": "column", "gap": "lg", "padding": "md" },
            "children": ["heading", "createCard", "notesCard"]
          },
          "heading": {
            "type": "Heading",
            "props": { "text": "Team notes", "level": "h1" }
          },
          "createCard": {
            "type": "Card",
            "props": { "title": "Add a note" },
            "children": ["createForm"]
          },
          "createForm": {
            "type": "Form",
            "props": {
              "id": "newNote",
              "fields": [
                { "name": "title", "label": "Note", "kind": "text", "required": true },
                {
                  "name": "category",
                  "label": "Category",
                  "kind": "enum",
                  "options": ["decision", "follow_up", "context"]
                },
                { "name": "owner", "label": "Owner" },
                { "name": "followUp", "label": "Follow up", "kind": "date" }
              ],
              "submitLabel": "Add note",
              "onSubmit": [
                {
                  "action": "app.mutate",
                  "params": {
                    "model": "note",
                    "op": "create",
                    "values": { "$form": "" }
                  }
                }
              ]
            }
          },
          "notesCard": {
            "type": "Card",
            "props": { "title": "Open notes" },
            "children": ["notesTable"]
          },
          "notesTable": {
            "type": "Table",
            "props": {
              "data": { "$state": "/queries/activeNotes/data" },
              "loading": { "$state": "/queries/activeNotes/loading" },
              "error": { "$state": "/queries/activeNotes/error" },
              "emptyMessage": "No active notes yet.",
              "columns": [
                { "key": "title", "label": "Note" },
                { "key": "category", "label": "Category", "kind": "badge" },
                { "key": "owner", "label": "Owner" },
                { "key": "followUp", "label": "Follow up", "kind": "date" }
              ],
              "rowActions": [
                {
                  "label": "Archive",
                  "variant": "outline",
                  "actions": [
                    {
                      "action": "app.mutate",
                      "params": {
                        "model": "note",
                        "op": "update",
                        "rowId": { "$row": "id" },
                        "values": { "archived": true }
                      }
                    }
                  ]
                }
              ]
            }
          }
        }
      }
    },
    "defaultPage": "main"
  }
}
```

Message to your lead [#message-to-your-lead]

> Create the DB-only Team Notes App from the recipe. Keep every row in the swarm database—do not add a source or connection. Validate the definition, add three clearly fictional sample notes so I can verify the table, and return the App URL.

Project management: Linear and GitHub [#project-management-linear-and-github]

Source sync requires agent-swarm 1.130.0 or later. This recipe keeps Linear issues and GitHub issues in separate models because each source owns its own join key and projected columns. App-owned notes remain editable, while the next refresh replaces source-bound fields.

Before creating the App:

1. A lead or operator must register the `linear` and `github` connection slugs. Do not paste tokens into an App definition or task message. The pull scripts must use the registered typed client or an approved credential binding; naming a source connection validates and preflights it but does not authorize egress by itself.
2. Save one pull script per source. Each must return `{ "records": [{ "key": "...", "fields": { ... } }], "complete": true }`, or `complete: false` when paging or limits may omit records.
3. The seeded `github-issues-pull` script can provide the GitHub side. Save a Linear pull script that emits the fields used below.
4. Replace the two obvious UUID placeholders with the real saved-script IDs. An agent-owned script can be wired only by its owner. If a script is global and ownerless, only the lead or operator can wire or change that source.

```json
{
  "name": "Delivery Radar",
  "description": "Review active Linear work and GitHub issues from one App",
  "definition": {
    "models": {
      "linearIssue": {
        "columns": {
          "externalKey": { "kind": "string" },
          "identifier": {
            "kind": "string",
            "source": { "of": "linear", "field": "identifier" }
          },
          "title": {
            "kind": "string",
            "source": { "of": "linear", "field": "title" }
          },
          "status": {
            "kind": "string",
            "source": { "of": "linear", "field": "state" }
          },
          "owner": {
            "kind": "string",
            "source": { "of": "linear", "field": "assignee" }
          },
          "url": {
            "kind": "string",
            "source": { "of": "linear", "field": "url" }
          },
          "externalUpdatedAt": {
            "kind": "date",
            "source": { "of": "linear", "field": "updatedAt", "transform": "date-parse" }
          },
          "note": { "kind": "string" }
        },
        "sources": {
          "linear": {
            "connector": "script",
            "scriptId": "11111111-1111-4111-8111-111111111111",
            "joinKey": "externalKey",
            "args": { "team": "ENG", "states": ["Todo", "In Progress"] },
            "connection": "linear"
          }
        }
      },
      "githubIssue": {
        "columns": {
          "externalKey": { "kind": "string" },
          "issueNumber": {
            "kind": "number",
            "source": { "of": "github", "field": "number" }
          },
          "title": {
            "kind": "string",
            "source": { "of": "github", "field": "title" }
          },
          "status": {
            "kind": "string",
            "source": { "of": "github", "field": "state" }
          },
          "owner": {
            "kind": "string",
            "source": { "of": "github", "field": "userLogin" }
          },
          "url": {
            "kind": "string",
            "source": { "of": "github", "field": "htmlUrl" }
          },
          "externalUpdatedAt": {
            "kind": "date",
            "source": { "of": "github", "field": "updatedAt", "transform": "date-parse" }
          },
          "note": { "kind": "string" }
        },
        "sources": {
          "github": {
            "connector": "script",
            "scriptId": "22222222-2222-4222-8222-222222222222",
            "joinKey": "externalKey",
            "args": { "repo": "your-org/your-repo", "state": "open" },
            "connection": "github"
          }
        }
      }
    },
    "queries": {
      "linearIssues": {
        "model": "linearIssue",
        "sort": { "column": "externalUpdatedAt", "dir": "desc" }
      },
      "githubIssues": {
        "model": "githubIssue",
        "sort": { "column": "externalUpdatedAt", "dir": "desc" }
      }
    },
    "actions": {
      "refreshSources": { "kind": "sync" }
    },
    "pages": {
      "main": {
        "title": "Delivery radar",
        "root": "root",
        "elements": {
          "root": {
            "type": "Stack",
            "props": { "direction": "column", "gap": "lg", "padding": "md" },
            "children": ["heading", "intro", "refresh", "sources"]
          },
          "heading": {
            "type": "Heading",
            "props": { "text": "Delivery radar", "level": "h1" }
          },
          "intro": {
            "type": "Text",
            "props": {
              "content": "Source-owned delivery data with App-owned notes and visible freshness.",
              "tone": "muted"
            }
          },
          "refresh": {
            "type": "Button",
            "props": { "label": "Refresh Linear and GitHub", "busyWith": "refreshSources" },
            "on": {
              "press": [
                { "action": "app.action", "params": { "name": "refreshSources" } }
              ]
            }
          },
          "sources": {
            "type": "Grid",
            "props": { "columns": { "base": 1, "lg": 2 }, "gap": "md" },
            "children": ["linearCard", "githubCard"]
          },
          "linearCard": {
            "type": "Card",
            "props": { "title": "Linear" },
            "children": ["linearTable"]
          },
          "linearTable": {
            "type": "Table",
            "props": {
              "data": { "$state": "/queries/linearIssues/data" },
              "loading": { "$state": "/queries/linearIssues/loading" },
              "error": { "$state": "/queries/linearIssues/error" },
              "columns": [
                { "key": "identifier", "label": "Issue" },
                { "key": "title", "label": "Title" },
                { "key": "status", "label": "Status", "kind": "badge" },
                { "key": "owner", "label": "Owner" },
                { "key": "stale", "label": "Stale", "kind": "badge" },
                { "key": "syncedAt", "label": "Synced", "kind": "date" }
              ]
            }
          },
          "githubCard": {
            "type": "Card",
            "props": { "title": "GitHub" },
            "children": ["githubTable"]
          },
          "githubTable": {
            "type": "Table",
            "props": {
              "data": { "$state": "/queries/githubIssues/data" },
              "loading": { "$state": "/queries/githubIssues/loading" },
              "error": { "$state": "/queries/githubIssues/error" },
              "columns": [
                { "key": "issueNumber", "label": "Issue", "kind": "number" },
                { "key": "title", "label": "Title" },
                { "key": "status", "label": "Status", "kind": "badge" },
                { "key": "owner", "label": "Reporter" },
                { "key": "stale", "label": "Stale", "kind": "badge" },
                { "key": "syncedAt", "label": "Synced", "kind": "date" }
              ]
            }
          }
        }
      }
    },
    "defaultPage": "main"
  }
}
```

The `connection` field validates and preflights the named connection, then passes its slug to the script as `args.connection`; the pull script still owns the actual API calls, authentication mechanism, and paging. The seeded `github-issues-pull` uses an approved `GITHUB_TOKEN` egress placeholder, so its run-as identity also needs that credential binding for `api.github.com`. A complete pull marks source-owned rows that disappeared as stale. An incomplete pull does not.

Message to your lead [#message-to-your-lead-1]

> On agent-swarm 1.130.0 or later, build the Delivery Radar App from the recipe. Use our registered `linear` and `github` connections; do not embed credentials. Reuse the seeded `github-issues-pull` script where its output matches the recipe, and save a Linear pull script that emits identifier, title, state, assignee, URL, and updatedAt. Replace both placeholder script IDs, create the App, run the first sync, and report the per-source counts and App URL. Tell me before creation if a connection or global-script step is lead-gated or missing.

Meetings: gated decision records [#meetings-gated-decision-records]

This is what a Meetings-style approval flow looks like as an App: the meeting-specific schema, policy, and interface stay in one versioned definition. It is an example of using App primitives for the workflow; it is not a verdict on any other implementation.

Replace the placeholder `scriptId` with a saved gate script. That script must use the supplied decision row and `app.id`, verify the required independent reviews, and update the row only when the gate passes. A script action returning `ok` does not change App data by itself.

```json
{
  "name": "Meeting Decisions",
  "description": "Capture proposals and enforce review gates before approval",
  "definition": {
    "models": {
      "decision": {
        "columns": {
          "meetingRef": { "kind": "string", "required": true },
          "proposal": { "kind": "string", "required": true },
          "owner": { "kind": "string", "required": true },
          "status": {
            "kind": "enum",
            "enum": ["pending_review", "reviews_requested", "approved", "rejected"],
            "default": "pending_review"
          },
          "reviewers": { "kind": "string" },
          "rationale": { "kind": "string" },
          "decidedAt": { "kind": "date" }
        }
      }
    },
    "queries": {
      "pendingDecisions": {
        "model": "decision",
        "filter": { "status": "pending_review" },
        "sort": { "column": "createdAt", "dir": "asc" }
      },
      "allDecisions": {
        "model": "decision",
        "sort": { "column": "updatedAt", "dir": "desc" }
      }
    },
    "actions": {
      "requestReviews": {
        "kind": "task",
        "prompt": "Collect independent reviews for the supplied meeting decision. Return each reviewer, recommendation, rationale, and evidence. Do not approve or mutate the decision row."
      },
      "validateGate": {
        "kind": "script",
        "scriptId": "33333333-3333-4333-8333-333333333333",
        "args": { "requiredReviews": 2 }
      }
    },
    "pages": {
      "main": {
        "title": "Meeting decisions",
        "root": "root",
        "elements": {
          "root": {
            "type": "Stack",
            "props": { "direction": "column", "gap": "lg", "padding": "md" },
            "children": ["heading", "intro", "createCard", "pendingCard"]
          },
          "heading": {
            "type": "Heading",
            "props": { "text": "Meeting decisions", "level": "h1" }
          },
          "intro": {
            "type": "Alert",
            "props": {
              "title": "Approval gate",
              "message": "A decision remains pending until the validation script verifies the required independent reviews.",
              "tone": "info"
            }
          },
          "createCard": {
            "type": "Card",
            "props": { "title": "Record a proposal" },
            "children": ["createForm"]
          },
          "createForm": {
            "type": "Form",
            "props": {
              "id": "newDecision",
              "fields": [
                { "name": "meetingRef", "label": "Meeting", "required": true },
                { "name": "proposal", "label": "Proposal", "kind": "text", "required": true },
                { "name": "owner", "label": "Decision owner", "required": true },
                { "name": "reviewers", "label": "Required reviewers" }
              ],
              "submitLabel": "Record proposal",
              "onSubmit": [
                {
                  "action": "app.mutate",
                  "params": {
                    "model": "decision",
                    "op": "create",
                    "values": { "$form": "" }
                  }
                }
              ]
            }
          },
          "pendingCard": {
            "type": "Card",
            "props": { "title": "Awaiting review" },
            "children": ["pendingTable"]
          },
          "pendingTable": {
            "type": "Table",
            "props": {
              "data": { "$state": "/queries/pendingDecisions/data" },
              "loading": { "$state": "/queries/pendingDecisions/loading" },
              "error": { "$state": "/queries/pendingDecisions/error" },
              "emptyMessage": "No decisions are waiting for review.",
              "columns": [
                { "key": "proposal", "label": "Proposal" },
                { "key": "meetingRef", "label": "Meeting" },
                { "key": "owner", "label": "Owner" },
                { "key": "status", "label": "Gate", "kind": "badge" },
                { "key": "reviewers", "label": "Reviewers" }
              ],
              "rowActions": [
                {
                  "label": "Request reviews",
                  "variant": "outline",
                  "actions": [
                    {
                      "action": "app.action",
                      "params": {
                        "name": "requestReviews",
                        "input": { "decision": { "$row": "" } }
                      }
                    }
                  ]
                },
                {
                  "label": "Validate gate",
                  "actions": [
                    {
                      "action": "app.action",
                      "params": {
                        "name": "validateGate",
                        "input": { "decision": { "$row": "" } }
                      }
                    }
                  ]
                }
              ]
            }
          }
        }
      }
    },
    "defaultPage": "main"
  }
}
```

Keep evidence in durable review artifacts or dedicated review rows if you need structured reviewer-by-reviewer history. The `reviewers` string above is intentionally the simplest usable field, not a substitute for an audit model.

Message to your lead [#message-to-your-lead-2]

> Build the Meeting Decisions App from the recipe as the Apps-based form of a gated meeting workflow. Save a validation script that requires two independent reviews, rejects self-approval, records the rationale and decision timestamp, and updates the selected row only after the gate passes. Replace the placeholder script ID, seed two fictional pending decisions, exercise one successful and one rejected gate, and return the App URL plus the definition diff. Keep the framing informative; do not compare or disparage another implementation.

Verify any recipe [#verify-any-recipe]

After creation:

1. Run `app-get` and confirm the stored definition matches the intended models, sources, actions, and pages.
2. Open `/apps/<id>` and exercise every form and row action with fictional data.
3. For source-backed Apps, run `app-sync` once and inspect every pass's `pulled`, `created`, `updated`, `refreshed`, `markedStale`, and `warnings` counts.
4. Use `app-history` and `app-diff` before a schema change. Prefer `app-patch`; use `app-rollback` only after reading the migration report.

For the iteration contract, return to [Build an App](/docs/apps/build-an-app). For source ownership and freshness rules, see [App concepts](/docs/apps/concepts#source-backed-models).


# Agent Identity & Configuration (/docs/architecture/agents)



Every agent in the swarm is a persistent entity with its own identity, memories, and environment. This identity evolves over time as the agent works.

Identity Files [#identity-files]

Each agent has four identity files that persist across sessions:

| File            | Purpose                                       | Example                                                         |
| --------------- | --------------------------------------------- | --------------------------------------------------------------- |
| **SOUL.md**     | Core persona, values, behavioral directives   | "You're not a chatbot. Be thorough. Own your mistakes."         |
| **IDENTITY.md** | Expertise, working style, track record        | "I'm the coding arm of the swarm. I ship fast and clean."       |
| **TOOLS.md**    | Environment knowledge — repos, services, APIs | "The API runs on port 3013. Use `wts` for worktree management." |
| **CLAUDE.md**   | Persistent notes and instructions             | Learnings, preferences, important context                       |

How Identity Works [#how-identity-works]

1. **Template-based bootstrap** — On first registration, if `TEMPLATE_ID` is set, the template is fetched and used to generate identity files. Templates can also set default `role`, `capabilities`, `maxTasks`, and `isLead` values that apply when not explicitly configured.
2. **Default generation** — If no template is set, the system generates generic templates based on the agent's name, role, and description
3. **Self-editing** — Agents modify their own identity files during sessions. A PostToolUse hook syncs changes to the database in real-time
4. **API / MCP tool** — Use the `update-profile` tool to programmatically set any identity field

Identity size budgets [#identity-size-budgets]

The prompt has finite space for identity files, so profile updates use ratcheting budgets instead of silently accepting content that sessions cannot read:

| Field         | Budget            | Behavior above the budget                         |
| ------------- | ----------------- | ------------------------------------------------- |
| `SOUL.md`     | 10,000 characters | May stay the same size or shrink, but cannot grow |
| `IDENTITY.md` | 10,000 characters | May stay the same size or shrink, but cannot grow |
| `CLAUDE.md`   | 20,000 characters | May stay the same size or shrink, but cannot grow |
| `TOOLS.md`    | 20,000 characters | May stay the same size or shrink, but cannot grow |

This ratchet preserves existing oversized profiles while giving agents a safe path back under budget. Move durable detail into searchable memory files and keep concise pointers in identity files.

Filesystem sync sends each changed identity file independently. If one field is rejected, valid edits to the other fields still persist. The rejection is recorded as a `system.profile_sync_rejected` event and shown to the agent at its next session with the current size, budget, and recovery guidance; a later successful sync records `system.profile_sync_reconciled`. Before boot replaces a local identity file from the database, the worker preserves divergent local content in a `.pre-boot-<timestamp>.bak` file.

Agent Appearance [#agent-appearance]

An agent profile can optionally store a Lucide icon and hex color. In the dashboard, open an agent's detail page, enter edit mode, and use the appearance picker to search hundreds of curated icons by natural words (spaces and hyphens are treated equivalently), select a suggested color, or enter a `#RRGGBB` value. The selected avatar is used consistently in agent lists and detail views; agents without one keep their deterministic icon and color.

The same setting is available through `update-profile`:

```json
{
  "avatar": {
    "type": "lucide",
    "icon": "rocket",
    "color": "#7C3AED"
  }
}
```

Pass `"avatar": null` to restore the deterministic default. Omitting `avatar` leaves the current appearance unchanged.

Version History [#version-history]

All identity file changes are tracked with version history. You can:

* View past versions using the `context-history` MCP tool
* Compare versions using the `context-diff` tool
* Roll back to a previous version if needed

System Prompt Assembly [#system-prompt-assembly]

The system prompt is built from multiple layers, assembled at task start:

1. **Base role instructions** — Lead or worker-specific behavior rules
2. **Code quality & repository guidelines** — Mandatory PR checks, merge policy, and review guidance from per-repo configuration
3. **Conditional Slack instructions** — Injected only for worker tasks that originated from Slack (channel ID and thread context)
4. **Agent identity** — SOUL.md + IDENTITY.md content
5. **Repository context** — If the task targets a specific GitHub repo, that repo's CLAUDE.md and guidelines are included
6. **Filesystem guide** — Memory directories, personal/shared workspace, setup script instructions
7. **Self-awareness** — How the agent is built (runtime, hooks, memory system, task lifecycle)
8. **Additional prompt** — Custom text from `SYSTEM_PROMPT` env var or `--system-prompt` CLI flag

Startup Scripts [#startup-scripts]

Each agent has a startup script (`/workspace/start-up.sh`) that runs at every container start. Agents can modify this script to configure their environment or set up repo-local dependencies, and the changes persist across restarts. The script runs as the non-root `worker` user after privilege drop, so it is not a place to apt-install system packages.

**Supported formats** (priority order):

* `start-up.sh` / `start-up.bash` — Bash scripts
* `start-up.js` — Node.js scripts
* `start-up.ts` / `start-up.bun` — Bun/TypeScript scripts

**Example: Project-local setup**

```bash
#!/bin/bash
# /workspace/start-up.sh

echo "Installing dependencies..."
if [ -f "package.json" ]; then
    bun install
fi
```

Agent Workspace [#agent-workspace]

Each agent has access to:

| Path                          | Description                                             |
| ----------------------------- | ------------------------------------------------------- |
| `/workspace/personal/`        | Agent's private workspace (isolated per agent)          |
| `/workspace/shared/`          | Shared workspace between all agents                     |
| `/workspace/personal/memory/` | Private searchable memory files                         |
| `/workspace/shared/`          | Read-only shared disk (all agents' directories visible) |
| `/logs`                       | Session logs                                            |

Agent Filesystem (agent-fs) [#agent-filesystem-agent-fs]

When `AGENT_FS_API_URL` is configured as a global swarm config, agents gain access to **agent-fs** — a persistent, searchable filesystem shared across the swarm. Agent-fs provides:

* **Personal and shared drives** — Each agent has a personal drive and access to a shared org drive
* **Full-text and semantic search** — Search across all files with keyword or natural language queries
* **Comments** — Human-agent collaboration via file comments
* **CLI access** — The `agent-fs` CLI is pre-installed in worker containers and auto-configured on first boot

Agents are automatically registered with agent-fs on their first container boot. The lead agent creates a shared organization and workers receive invitations automatically.

Repository Guidelines [#repository-guidelines]

Repos registered with the swarm can have **guidelines** — per-repo quality rules that are injected into agent prompts and enforced by plugin commands.

Guidelines have three sections:

| Section             | Purpose                                                          |
| ------------------- | ---------------------------------------------------------------- |
| **PR Checks**       | Commands agents must run before pushing (lint, typecheck, tests) |
| **Merge Policy**    | Whether agents are allowed to merge, and any required checks     |
| **Review Guidance** | How agents should review PRs for this repo                       |

How It Works [#how-it-works]

1. **Lead sets guidelines** — Use the `update-repo` MCP tool to configure guidelines for a repo
2. **Prompt injection** — When a task targets a repo, its guidelines are injected into the agent's system prompt as a mandatory "Repository Guidelines" section
3. **Command enforcement** — `/create-pr` and `/implement-issue` require PR checks to pass before pushing; `/review-pr` references the review guidance
4. **Lead gating** — The lead agent can block code tasks for repos that lack guidelines

Use the `get-repos` MCP tool to view registered repos and their guidelines.

Lead vs Worker [#lead-vs-worker]

Lead Agent [#lead-agent]

* Receives incoming messages from Slack, GitHub, and email
* Has an inbox for triaging messages
* Can delegate tasks to workers
* Can inject learnings into worker memories
* Has access to all Slack channels
* Coordinates projects across workers

Worker Agent [#worker-agent]

* Executes assigned tasks
* Reports progress via `store-progress`
* Can expose HTTP services
* Builds specialized expertise over time
* Can claim tasks from the task pool
* Can communicate with other agents via channels

Related [#related]

* [Architecture Overview](/docs/architecture/overview) — How all the components fit together
* [Hook System](/docs/architecture/hooks) — Hooks that sync identity files and manage sessions
* [Memory System](/docs/architecture/memory) — How agents build compounding knowledge
* [Agents API Reference](/docs/api-reference/agents) — REST API endpoints for registering, querying, and managing agents


# Hook System (/docs/architecture/hooks)



Six hooks fire during each Claude Code session, providing safety, context management, and persistence. Hooks are implemented in `src/hooks/hook.ts`.

Hook Overview [#hook-overview]

| Hook                 | When                      | What it does                                                                                                           |
| -------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| **SessionStart**     | Session begins            | Writes CLAUDE.md from DB, loads concurrent session context for leads                                                   |
| **PreCompact**       | Before context compaction | Injects a "goal reminder" with current task details so the agent doesn't lose track                                    |
| **PreToolUse**       | Before each tool call     | Checks for task cancellation, detects tool loops, blocks excessive polling                                             |
| **PostToolUse**      | After each tool call      | Sends heartbeat, syncs identity file edits to DB, auto-indexes memory files                                            |
| **UserPromptSubmit** | New iteration starts      | Checks for task cancellation                                                                                           |
| **Stop**             | Session ends              | Cleans up artifact tunnels, saves PM2 state, syncs all identity files, runs session summarization, marks agent offline |

Hook Details [#hook-details]

SessionStart [#sessionstart]

Fires when a new Claude Code session begins. This hook:

* Writes the agent's `CLAUDE.md` from the database to the filesystem
* For lead agents, loads context about other concurrent sessions
* Sets up the workspace environment

PreCompact [#precompact]

Fires before Claude Code compacts its context window. This is critical for long-running tasks — without this hook, the agent might lose track of what it's working on after compaction.

The hook injects the current task description and any saved progress as a "goal reminder" into the compacted context.

PreToolUse [#pretooluse]

Fires before every tool call. This hook provides safety guardrails:

* **Cancellation check** — If the task has been cancelled, the hook blocks the tool call and notifies the agent
* **Loop detection** — Detects when the same tool is called with identical arguments repeatedly (a sign the agent is stuck), including nested MCP argument payloads; low-cardinality Codex file-change ping-pong patterns use a higher threshold before blocking
* **Polling limits** — Prevents excessive polling of external services

PostToolUse [#posttooluse]

Fires after every tool call. This is the most active hook:

* **Heartbeat** — Sends a heartbeat to the MCP server so the lead knows the worker is alive
* **Activity tracking** — Updates the agent's `lastActivityAt` timestamp (fire-and-forget) for stall detection
* **Identity sync** — If an identity file (SOUL.md, IDENTITY.md, TOOLS.md, CLAUDE.md) was edited, syncs each changed file independently with `changeSource: "self_edit"`, so one rejected field cannot discard valid edits to the others. The session-start runner records baseline hashes; the Stop/session-end path skips unchanged files so lead-side `update-profile` edits are not overwritten by stale local copies. Filesystem sync skips SOUL.md and IDENTITY.md shorter than 500 characters, and growth beyond the identity-field budgets is surfaced as a persisted rejection for the next session
* **Memory indexing** — If a file was written to a memory directory, automatically generates an embedding and indexes it

UserPromptSubmit [#userpromptsubmit]

Fires when a new iteration of the agent loop begins (i.e., the agent receives a new prompt from the runner). This hook:

* Checks for task cancellation
* Can inject additional context if needed

Stop [#stop]

Fires when the session ends. This hook handles cleanup:

* **Artifact cleanup** — Stops any active artifact tunnels (PM2 processes prefixed with `artifact-`)
* **PM2 state** — Saves the current PM2 process list for auto-restart
* **Identity sync** — Final independent sync of identity files that changed relative to their session-start hashes. HTTP failures and budget rejections are logged instead of being swallowed, while unchanged files are skipped so DB-side lead edits survive the session
* **Session summary + LLM memory rating** — Runs a lightweight structured-output call via the shared `internal-ai` abstraction (`src/utils/internal-ai/`) to extract key learnings from the session transcript. The wrapper resolves credentials in priority order — `OPENROUTER_API_KEY` → `ANTHROPIC_API_KEY` → `OPENAI_API_KEY` → Codex OAuth (`~/.codex/auth.json`) → `CLAUDE_CODE_OAUTH_TOKEN` (mirrored to `AGENT_SWARM_CLAUDE_OAUTH_TOKEN` to survive Claude CLI's hook env-stripping) — and dispatches the call to the matching backend (OpenRouter / Anthropic / OpenAI SDK or a `claude -p --json-schema` fallback). Default model: `google/gemini-3-flash-preview` via OpenRouter; override with `MEMORY_RATER_LLM_MODEL`. When `MEMORY_LLM_RATER_ENABLED=true`, the same call also produces structured `useful: true | false` ratings for the memories that were retrieved into the task's prompt — piggybacking on the summary call so there's no extra LLM round-trip. Self-similar retrievals (e.g. cron clones of the same task) are deduped by memory name before the rater sees them, preventing posterior inflation. Ratings are POSTed to `/api/memory/rate` with `source: "llm"`. **No-op when no credential resolves** — self-hosters / OSS users without any of the supported credentials skip session summary + LLM ratings entirely. The same wrapper drives session summarization for the `pi`, `opencode`, and `codex` worker harnesses (`src/providers/pi-mono-extension.ts`, `plugin/opencode-plugins/lib/summarize.ts`, `src/providers/codex-adapter.ts`), so Pro/Max OAuth-only workers and non-Claude harnesses now get summaries that previously silently dropped.
* **Agent status** — Marks the agent as offline in the database

Related [#related]

* [Memory System](/docs/architecture/memory) — How PostToolUse auto-indexes memory files
* [Agent Identity & Configuration](/docs/architecture/agents) — Identity files synced by PostToolUse and Stop hooks
* [Task Lifecycle](/docs/concepts/task-lifecycle) — Task states checked by PreToolUse cancellation detection


# Memory System (/docs/architecture/memory)



Agent Swarm agents aren't stateless. They build compounding knowledge through multiple automatic mechanisms. The memory system uses provider abstractions, vector search, reranking, and typed recall-edge capture so the swarm can surface the right memory and retain why it mattered.

<Callout type="info">
  The memory system was redesigned in [#212](https://github.com/desplega-ai/agent-swarm/issues/212) to add TTL-based expiry, reranking with recency and access signals, and swappable provider interfaces.
</Callout>

How Memory Works [#how-memory-works]

Every agent has a searchable memory backed by embeddings and stored in SQLite. The system is built on two provider abstractions:

* **`EmbeddingProvider`** — Converts text to vectors. Default implementation uses OpenAI `text-embedding-3-small` (512 dimensions). The model, dimensions, API key, and base URL are configurable via `EMBEDDING_MODEL`, `EMBEDDING_DIMENSIONS`, `EMBEDDING_API_KEY`, and `EMBEDDING_API_BASE_URL` — point it at any OpenAI-compatible endpoint (Azure OpenAI, Together, vLLM, Ollama, etc.). Swappable for other providers in code.
* **`MemoryStore`** — Persists and retrieves memories. Default implementation uses SQLite with [sqlite-vec](https://alexgarcia.xyz/sqlite-vec/) for KNN vector search. Falls back to brute-force cosine similarity when the extension is unavailable.

Memory Sources [#memory-sources]

Memories are automatically created from:

* **Session summaries** — At the end of each session, a lightweight model (default: Gemini 3 Flash via OpenRouter, configurable through `MEMORY_RATER_LLM_MODEL`) extracts key learnings: mistakes made, patterns discovered, failed approaches, and codebase knowledge. These summaries become searchable memories. Requires `OPENROUTER_API_KEY` — without it the Stop hook skips session-summary indexing entirely.
* **Task completions** — Every completed (or failed) task's output is indexed. Failed tasks include notes about what went wrong, so the agent avoids repeating the same mistake.
* **Explicit stores** — Agents call the `memory-store` tool (or `ctx.swarm.memory_store` from a script) with a title, content, scope, and tags. This is the write path the system prompt names, and it works on every harness, including remote ones.
* **File-based notes** — On harnesses with a file hook (claude, pi, opencode), files written to `/workspace/personal/memory/` or `/workspace/shared/memory/<agentId>/` are indexed automatically.
* **Lead-to-worker injection** — The lead agent can push specific learnings into any worker's memory using the `inject-learning` tool, closing the feedback loop.

Memory Scopes [#memory-scopes]

| Scope           | Path                                                                                              | Visibility              |
| --------------- | ------------------------------------------------------------------------------------------------- | ----------------------- |
| Agent (private) | `memory-store` with `scope: "agent"` (default), or `/workspace/personal/memory/`                  | Only the owning agent   |
| Swarm (shared)  | `memory-store` with `scope: "swarm"`, `inject-learning`, or `/workspace/shared/memory/<agentId>/` | All agents in the swarm |

Automatic Scope Promotion [#automatic-scope-promotion]

The `inject-learning` tool creates swarm-scoped memories by default, so learnings injected by the lead are available to all workers.

TTL & Expiry [#ttl--expiry]

Memories have a time-to-live (TTL) based on their source type. Expired memories are automatically filtered from search results but not proactively deleted from the database.

| Source            | TTL           | Rationale                                |
| ----------------- | ------------- | ---------------------------------------- |
| `task_completion` | 7 days        | Task outputs become stale quickly        |
| `session_summary` | 3 days        | Session context is ephemeral             |
| `file_index`      | 30 days       | File contents may change                 |
| `manual`          | Never expires | Explicitly stored knowledge is permanent |

Expired memories can still be retrieved by ID via `memory-get` — only search results are filtered. The `memory-delete` tool provides explicit cleanup when needed.

Memory Retrieval & Reranking [#memory-retrieval--reranking]

Before starting each task, the runner automatically searches for relevant memories and includes them in the agent's context.

Search Process [#search-process]

1. Task description is used as the search query
2. An embedding is generated via the `EmbeddingProvider`
3. Candidate memories are retrieved via KNN search (sqlite-vec) or brute-force cosine similarity (fallback)
4. Reciprocal-rank fusion blends vector matches with a full-text pass before reranking by default; set `MEMORY_HYBRID_SEARCH=0` to use vector-only retrieval
5. The candidate set is expanded with 1-hop memory-link neighbors by default; set `MEMORY_GRAPH_EXPANSION=0` to disable it (see [Graph Expansion](#graph-expansion))
6. Candidates below the minimum similarity floor are dropped, then the survivors are **reranked** using a composite score
7. Top matches are included in the task context as "Relevant Past Knowledge"

Every retrieval row records its provenance (`retrievalSource`: `vec`, `fts`, `hybrid`, `fallback`, or `graph`), so the [usefulness readout](#usefulness-readout) can compare how often each retrieval arm's results actually get cited.

Reranking [#reranking]

Raw vector similarity alone isn't enough — a curated manual memory from last month can still be more useful than a noisy session summary from yesterday. The reranker computes:

```
finalScore = similarity × sourceQuality × recencyDecay × accessBoost
```

| Signal             | Formula                                                                                                | Effect                                                                     |
| ------------------ | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| **Source quality** | manual `1.5`, file notes `1.0`, task completions `0.7`, session summaries `0.5`                        | Curated knowledge ranks above ephemeral auto-generated snapshots           |
| **Recency decay**  | Source-aware by default: manual `∞`, file notes `180d`, task completions `14d`, session summaries `7d` | Long-lived curated memories stay relevant; ephemeral memories decay faster |
| **Access boost**   | `1 + min(accessCount/10, 0.5) × recencyFactor`                                                         | Frequently accessed memories get up to 1.5× boost                          |

Before reranking, candidates with raw cosine similarity below `MEMORY_MIN_SIMILARITY` (default `0.1`) are discarded as noise. The remaining candidate set is fetched at 3× the requested limit, then narrowed after reranking. This ensures that a highly relevant but older memory can still surface if its similarity is strong enough.

Graph Expansion [#graph-expansion]

By default, search results gain **1-hop graph neighbors**: after the store returns its candidates and before reranking, the expander follows outgoing `memory_link` rows (resolved wikilinks pointing at live memories) from each candidate and injects the linked memories as additional candidates. A memory that is lexically and semantically distant from the query can therefore still surface because a strong hit links to it — `"Auth flow gotchas, see [[deploy-checklist]]"` pulls the checklist into results for an auth query. Set `MEMORY_GRAPH_EXPANSION=0` or `false` to disable expansion.

Mechanics:

* **Derived score** — `parentRawSimilarity × linkStrength × damping` (damping `0.7`), so neighbors rank below the hit that pulled them in. The parent's *pre-decay* similarity is used; the neighbor's own recency decay is applied exactly once by the reranker.
* **Cap** — at most 5 new candidates per search, selected by the reranker's composite score (not raw similarity), so stale low-value links can't crowd out better ones.
* **ACL + filters** — neighbors respect the caller's scope (`agent`/`swarm`/`all`), lead visibility, `source` filter, and expiry rules; expansion can never widen what a search is allowed to see.
* **Provenance** — expanded results carry `retrievalSource: "graph"`, so the usefulness readout measures whether graph hits earn citations before the flag is enabled more widely.
* **Fail-open** — an expansion failure never poisons search; the original candidates are returned unchanged. With the flag off, results are byte-identical to no-expansion.

Both memory retrieval features retain explicit rollback flags so operators can compare per-arm citation data or restore the previous retrieval path without changing code.

Usefulness Readout [#usefulness-readout]

`GET /api/memory/usefulness` (query params `days`, `threshold`) answers "is memory useful?" from the measurement tables the raters populate:

* **Volume** — retrieval rows, distinct memories, and search/get event split in the window.
* **Per-arm breakdown** — retrievals and citation rate grouped by `retrievalSource` (search events only), so `vec`/`fts`/`hybrid`/`graph` can be compared head-to-head.
* **Citation rate per memory-source** — how often `manual` vs `file_index` vs `task_completion` vs `session_summary` memories get cited in task evidence (`positive/ratings` in `[0,1]`, plus the signed `avgSignal` mean).
* **Posterior movement** — how many Beta-Binomial usefulness posteriors have moved off the prior, and how many sit above `threshold`.

The same data renders as the **Usefulness panel** on the dashboard's `/memory` page (summary tiles + per-source and per-arm charts).

Manual Search [#manual-search]

Agents can search and manage memories using MCP tools:

* **`memory-store`** — Create a memory: `content`, `name`, `scope` (`agent` or `swarm`), optional `tags`, `taskId`, and `intent`. Long content is chunked on headings and embedded in the background. The owner is always the caller, for both scopes.
* **`memory-search`** — Search with natural language queries. Calls now require an `intent` string so retrievals can be attributed to a concrete task or question, and response payloads may include `rateHint` nudges when a retrieved memory looks worth rating.
* **`memory-get`** — Retrieve full details of a specific memory by ID (increments access count). Like `memory-search`, it requires an `intent` string and records the retrieval event for downstream recall analysis.
* **`memory-edit`** — Edit an existing memory in place without changing its ID. Use `mode: "replace"` to rewrite the full content or `mode: "exact"` for a surgical single-substring replacement guarded by uniqueness and optional version checks.
* **`memory-delete`** — Delete a memory. Agents can delete their own; leads can also delete swarm-scoped memories.
* **`memory_rate`** — Record an explicit usefulness rating (`useful: true | false`) on a retrieved memory so the rater pipeline learns what surfaces well. Optional `referencesSource` (free-form `<source>:<identifier>`, e.g. `github:owner/repo#N`, `linear:KEY-N`) creates an edge from the memory to the external artifact it cites.

Recall Edges & Memory Links [#recall-edges--memory-links]

Search and retrieval events feed a lightweight memory graph:

* **Intent-tagged retrieval rows** capture why a memory was searched or opened, not just that it was touched.
* **Typed memory links** connect memories to other memories (`[[wikilinks]]` in content) and to external artifacts such as GitHub PRs, agent-fs paths, and other entity references resolved from memory content.
* **Forward sequel edges** preserve "this led to that" relationships between related memories, improving future recall and analysis.

The link graph has a full read side:

* **Traversal** — `memory-get` (and `GET /api/memory/:id`) returns the memory's outgoing `links` and inbound `backlinks`, ACL-filtered: linked-memory metadata is only included when the viewer may see the target, and hidden or deleted targets are indistinguishable from unresolved wikilinks.
* **Search expansion** — resolved memory-to-memory links power [Graph Expansion](#graph-expansion) when enabled.
* **Self-maintenance** — editing a memory prunes links derived from removed content (re-resolving links from the new content in one transaction) while preserving `sequel` edges, so the graph tracks what memories actually say. A wikilink that pointed at a not-yet-existing memory re-resolves on the next edit once the target exists.

Writing Memories [#writing-memories]

Store a memory as soon as something worth keeping is learned, with the `memory-store` tool:

```
memory-store({
  name: "API auth header needs the Bearer prefix",
  content: "The API requires the Bearer prefix on all auth headers. Without it you get a misleading 403 instead of 401.",
  scope: "agent",
  tags: ["example-api"],
})
```

The seeded `memory` skill carries the full guidance: what makes a good memory, when to pick `swarm` scope, how to check for a near-duplicate with the `memory-dedup-check` script before storing, and how to triage with `memory-edit`, `memory-delete`, and `memory_rate`. On harnesses with a file hook, a file written under `/workspace/personal/memory/` is indexed too.

What to Save [#what-to-save]

* Solutions to problems you solved
* Codebase patterns you discovered
* Mistakes you made and how to avoid them
* Important configurations
* Instructions from the lead or user

What Not to Save [#what-not-to-save]

* Session-specific context (temporary state)
* Unverified conclusions
* Information that duplicates existing documentation

Memory Categories [#memory-categories]

When the lead injects learnings via `inject-learning`, they're categorized:

| Category             | Purpose                  |
| -------------------- | ------------------------ |
| `mistake-pattern`    | Common mistakes to avoid |
| `best-practice`      | Preferred approaches     |
| `codebase-knowledge` | Facts about the codebase |
| `preference`         | User or team preferences |

Configuration [#configuration]

Reranking parameters are tunable via environment variables:

| Variable                        | Default | Description                                                                                                                                                                                    |
| ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MEMORY_RECENCY_HALF_LIFE_DAYS` | `14`    | Optional global override for recency half-life across all memory sources. Leave unset to use the source-aware defaults above.                                                                  |
| `MEMORY_HYBRID_SEARCH`          | `1`     | Enable reciprocal-rank-fusion hybrid retrieval (vector + full-text) instead of vector-only search. Set to `0` or `false` to disable.                                                           |
| `MEMORY_GRAPH_EXPANSION`        | `1`     | Expand search candidates with 1-hop memory-link neighbors before reranking (`retrievalSource: "graph"` provenance). Set to `0` or `false` to disable. See [Graph Expansion](#graph-expansion). |
| `MEMORY_MIN_SIMILARITY`         | `0.1`   | Minimum raw cosine similarity a candidate must meet before reranking.                                                                                                                          |
| `MEMORY_ACCESS_BOOST_MAX`       | `1.5`   | Maximum access boost multiplier                                                                                                                                                                |
| `MEMORY_ACCESS_RECENCY_HOURS`   | `48`    | Hours within which access counts for full boost                                                                                                                                                |
| `MEMORY_CANDIDATE_MULTIPLIER`   | `3`     | Candidate set size relative to requested limit                                                                                                                                                 |

Architecture [#architecture]

```
src/be/memory/
├── types.ts              # EmbeddingProvider + MemoryStore interfaces
├── constants.ts          # TTL defaults + reranking params (env-overridable)
├── reranker.ts           # Scoring: similarity × source quality × recency × access
├── index.ts              # Singleton getters
└── providers/
    ├── openai-embedding.ts   # OpenAI text-embedding-3-small
    └── sqlite-store.ts       # SQLite + sqlite-vec KNN search
```

The provider interfaces make it straightforward to add alternative implementations (e.g., a different embedding model or a Postgres-backed store) without changing any consumer code.

Related [#related]

* [Hook System](/docs/architecture/hooks) — The PostToolUse hook that auto-indexes memory files
* [Agent Identity & Configuration](/docs/architecture/agents) — How identity files persist across sessions
* [Architecture Overview](/docs/architecture/overview) — System-level view of how memory fits in
* [Memory API Reference](/docs/api-reference/memory) — REST API endpoints for searching and managing agent memories


# Architecture Overview (/docs/architecture/overview)



Agent Swarm follows a hub-and-spoke architecture where a central MCP API server coordinates communication between agents.

System Diagram [#system-diagram]

<Mermaid
  chart="graph TD
    User[&#x22;You (Slack / GitHub / Email / CLI)&#x22;] --> Lead[&#x22;Lead Agent&#x22;]
    Lead <--> API[&#x22;MCP API Server&#x22;]
    API <--> DB[&#x22;SQLite DB&#x22;]
    Lead --> W1[&#x22;Worker&#x22;]
    Lead --> W2[&#x22;Worker&#x22;]
    Lead --> W3[&#x22;Worker&#x22;]
    W1 -.- D1[&#x22;Docker container&#x22;]
    W2 -.- D2[&#x22;Docker container&#x22;]
    W3 -.- D3[&#x22;Docker container&#x22;]"
/>

Core Components [#core-components]

MCP API Server [#mcp-api-server]

The MCP (Model Context Protocol) server is the central coordination point. It:

* Exposes tools via the MCP protocol (both STDIO and HTTP transports)
* Manages agent registration and task assignment
* Stores all state in a SQLite database
* Handles integrations (Slack, GitHub, GitLab, AgentMail, Linear webhooks)
* Runs the task scheduler for recurring automation

The server runs on port `3013` by default and is implemented in `src/http/` (modular route handlers) with tool definitions in `src/server.ts`.

It also runs three background subsystems:

* **Scheduler** — Polls for scheduled tasks and creates them at the configured interval
* **Workflow Engine** — Redesigned DAG executor with executor registry (8 types), checkpoint durability, webhook/schedule/manual triggers, per-step retry, and version history (see [Workflows](/docs/concepts/workflows))
* **Heartbeat** — A lightweight triage module that sweeps the swarm every 90 seconds (configurable via `HEARTBEAT_INTERVAL_MS`). It uses a 3-tier approach: a preflight gate that bails if the swarm is healthy, code-level triage for routine fixes (stall detection, worker status correction, pool task auto-assignment, stale resource cleanup), and Claude escalation only when ambiguous situations require human reasoning. The lead agent also triggers a heartbeat sweep on startup to immediately detect and recover any stalled tasks from before restart. See [Environment Variables](/docs/reference/environment-variables) for heartbeat configuration.

Lead Agent [#lead-agent]

The lead agent is the coordinator. It:

* Receives incoming tasks from external sources (Slack, GitHub, email)
* Has an **inbox** for triaging incoming messages
* Breaks down complex tasks and delegates to workers
* Monitors worker progress and provides feedback
* Communicates results back to the user
* Can inject learnings into worker memories

Worker Agents [#worker-agents]

Workers are the execution layer. Each worker:

* Runs in an isolated Docker container with a full development environment
* Uses a configurable AI harness — Claude Code (default) or pi-mono — selected via `HARNESS_PROVIDER`. See [Harness Configuration](/docs/guides/harness-configuration)
* Has access to git, Node.js, Python, Bun, and common CLI tools
* Executes tasks assigned by the lead agent
* Reports progress via the `store-progress` MCP tool
* Can expose HTTP services on port 3000
* Learns from each session and builds compounding knowledge

Docker Runtime [#docker-runtime]

Each worker container includes:

* **Languages**: Python 3, Node.js 22, Bun
* **Build tools**: gcc, g++, make, cmake
* **Process manager**: PM2 (for background services)
* **CLI tools**: GitHub CLI (`gh`), GitLab CLI (`glab`), sqlite3
* **Agent tools**: `wts` (git worktree manager), `agent-fs` (persistent shared filesystem)
* **Utilities**: git, git-lfs, vim, nano, jq, curl, wget, ssh
* **Runtime user**: Agent processes run as the non-root `worker` user without passwordless sudo

Operator Dashboard [#operator-dashboard]

The dashboard presents live agent and task state over the same API. Operators can give sessions concise custom titles directly from a session header; clearing a title restores the original task text, and session search matches both fields. Agent detail pages also expose an appearance picker for an optional Lucide icon and color, with deterministic defaults when no customization is stored.

Data Flow [#data-flow]

Task Creation [#task-creation]

1. User sends a message (Slack DM, GitHub @mention, email, or API call)
2. MCP server receives the webhook/request
3. Lead agent's inbox receives the message
4. Lead agent triages and creates tasks for workers

Task Execution [#task-execution]

1. Worker polls for or receives a task assignment
2. Worker starts a Claude Code session with the task context
3. Worker executes the task, using MCP tools for coordination
4. Worker reports progress via `store-progress`
5. On completion, output is saved and the lead is notified

Learning Loop [#learning-loop]

1. At session end, a summary model extracts key learnings
2. Learnings are embedded and stored in the memory system
3. On next task, relevant memories are retrieved and included in context
4. The lead can also inject learnings directly into workers

Project Structure [#project-structure]

```
agent-swarm/
├── src/
│   ├── cli.tsx          # CLI entry point (Ink/React)
│   ├── http/            # Modular HTTP route handlers
│   │   ├── index.ts     # Handler registry & dispatch
│   │   ├── tasks.ts     # Task endpoints
│   │   ├── agents.ts    # Agent endpoints
│   │   ├── schedules.ts # Schedule endpoints
│   │   ├── core.ts      # Core endpoints (join, poll, progress)
│   │   ├── config.ts    # Config endpoints
│   │   ├── memory.ts    # Memory endpoints
│   │   ├── db-query.ts  # Read-only SQL query endpoint
│   │   ├── workflows.ts # Workflow CRUD + trigger endpoints
│   │   └── ...          # Additional route modules
│   ├── server.ts        # MCP server setup & tool registration
│   ├── tools/           # MCP tool implementations
│   ├── heartbeat/       # Lightweight swarm triage module
│   │   └── heartbeat.ts # 3-tier heartbeat (gate → code triage → escalation)
│   ├── be/              # Backend (database, business logic)
│   │   ├── db.ts        # SQLite database
│   │   └── migrations/  # Database migration system
│   ├── workflows/      # Workflow automation engine (DAG executor, triggers, nodes)
│   ├── artifact-sdk/    # Artifact SDK (serve files/apps via localtunnel)
│   ├── providers/       # AI provider adapters (formatCommand for provider-aware prompts)
│   │   ├── types.ts     # ProviderAdapter interface
│   │   ├── claude-adapter.ts  # Claude Code adapter
│   │   └── pi-mono-adapter.ts # Pi-mono adapter
│   ├── oauth/           # Generic OAuth module (PKCE, token management)
│   ├── gitlab/          # GitLab webhook handlers
│   ├── commands/        # CLI command implementations
│   │   ├── runner.ts    # Task runner (polls and spawns sessions)
│   │   ├── worker.ts    # Worker agent command
│   │   ├── lead.ts      # Lead agent command
│   │   └── artifact.ts  # Artifact serve/list/stop commands
│   └── hooks/           # Claude Code hooks
├── templates/           # Agent templates (official + community)
├── apps/
│   ├── ui/              # Dashboard UI (React + AG Grid)
│   ├── templates-ui/    # Templates registry (Next.js app)
│   └── evals/           # E2B-backed eval harness + UI
├── deploy/              # Deployment scripts
├── scripts/             # Utility scripts
├── docker-compose.example.yml
├── Dockerfile           # API server image
├── Dockerfile.worker    # Worker image
└── package.json
```

Technology Stack [#technology-stack]

| Component          | Technology                                                                                                                          |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| Runtime            | Bun                                                                                                                                 |
| API Protocol       | MCP (Model Context Protocol)                                                                                                        |
| Database           | SQLite (via `bun:sqlite`)                                                                                                           |
| AI Runtime         | Claude Code (default) or pi-mono — selected via `HARNESS_PROVIDER`. See [Harness Configuration](/docs/guides/harness-configuration) |
| Containerization   | Docker                                                                                                                              |
| Process Management | PM2                                                                                                                                 |
| Memory Embeddings  | OpenAI `text-embedding-3-small`                                                                                                     |
| Dashboard          | React + Vite                                                                                                                        |
| Schema Validation  | Zod                                                                                                                                 |

Next Steps [#next-steps]

* [Agent Identity & Configuration](/docs/architecture/agents) — How agents are personalized and evolve
* [Hook System](/docs/architecture/hooks) — The six hooks that fire during each session
* [Memory System](/docs/architecture/memory) — How agents build compounding knowledge
* [Task Lifecycle](/docs/concepts/task-lifecycle) — How tasks flow through the swarm
* [Deployment Guide](/docs/guides/deployment) — Production deployment options


# Scheduled Tasks (/docs/concepts/scheduling)



Agent Swarm supports both **recurring** and **one-time** schedules to automate work. A schedule can create an agent task (`targetType: "agent-task"`), trigger a workflow directly, or run a saved catalog script without an agent session in the loop.

Creating a Recurring Schedule [#creating-a-recurring-schedule]

```
create-schedule(
  name: "daily-standup",
  taskTemplate: "Generate a daily standup report summarizing yesterday's completed tasks",
  cronExpression: "0 9 * * *",
  timezone: "America/New_York",
  targetType: "agent-task"
)
```

Or use interval-based scheduling:

```
create-schedule(
  name: "health-check",
  scriptName: "service-health-check",
  scriptArgs: { channel: "ops" },
  targetType: "script",
  intervalMs: 3600000
)
```

Creating a One-Time Schedule [#creating-a-one-time-schedule]

One-time schedules run once and then auto-disable. Use `delayMs` for a relative delay or `runAt` for an absolute time:

```
create-schedule(
  name: "deploy-reminder",
  scheduleType: "one_time",
  taskTemplate: "Remind the team about the deployment at 3 PM",
  delayMs: 1800000
)
```

```
create-schedule(
  name: "scheduled-report",
  scheduleType: "one_time",
  taskTemplate: "Generate the quarterly report",
  runAt: "2026-03-15T09:00:00Z"
)
```

Schedule Properties [#schedule-properties]

| Property         | Description                                                                           |
| ---------------- | ------------------------------------------------------------------------------------- |
| `name`           | Unique name for the schedule                                                          |
| `targetType`     | `agent-task` (default), `workflow`, or `script`                                       |
| `taskTemplate`   | Task description created each time. Required for `agent-task` schedules               |
| `workflowId`     | Workflow to trigger when `targetType: "workflow"`                                     |
| `scriptName`     | Global catalog script name to run when `targetType: "script"`                         |
| `scriptArgs`     | JSON arguments passed to the script target                                            |
| `scheduleType`   | `recurring` (default) or `one_time`                                                   |
| `cronExpression` | Cron expression for recurring schedules (e.g., `0 9 * * *`)                           |
| `intervalMs`     | Interval in milliseconds for recurring schedules                                      |
| `delayMs`        | Delay in milliseconds for one-time schedules                                          |
| `runAt`          | ISO datetime for one-time schedules (e.g., `2026-03-15T09:00:00Z`)                    |
| `timezone`       | Timezone for cron schedules (default: UTC)                                            |
| `targetAgentId`  | Agent to assign tasks to (omit for task pool)                                         |
| `priority`       | Task priority 0-100 (default: 50)                                                     |
| `tags`           | Tags applied to created tasks                                                         |
| `taskType`       | Type classification for created tasks                                                 |
| `model`          | Concrete model override for created agent tasks                                       |
| `modelTier`      | Portable model intent for created agent tasks: `smol`, `regular`, `smart`, or `ultra` |
| `enabled`        | Whether the schedule is active (default: true)                                        |

Automation setup checks [#automation-setup-checks]

Bundled schedules can declare `requiredParams` and `requires` metadata. `requiredParams` names values that must be present in the schedule's `params` object. `requires` names integrations, such as Slack or GitHub, that must be configured before the automation can run.

The scheduler checks these requirements immediately before dispatch. An incomplete automation reports `needs_setup` with its missing parameters, missing integrations, and a dashboard fix URL; it does not create a task, start a workflow, or run a script. The schedules dashboard exposes the same status and lets operators supply the missing values, so the UI and dispatcher use one preflight result.

Choosing `targetType` [#choosing-targettype]

Pick the schedule target that matches the work you actually want to fire:

* Use &#x2A;*`agent-task`** when a reasoning agent needs to read the prompt, use tools, and make judgment calls before doing the work.
* Use &#x2A;*`workflow`** when the schedule's only job is to start an existing workflow DAG. Pass `workflowId` and skip agent-task fields that only apply to delegated tasks.
* Use &#x2A;*`script`** when the schedule should run a saved catalog script directly with `scriptName` and optional `scriptArgs`, without opening an interactive agent session.

This avoids a common anti-pattern: creating an `agent-task` schedule whose task body only says "trigger workflow X" or "run script Y". If the work is already captured as a workflow or script, schedule that target directly.

Managing Schedules [#managing-schedules]

List Schedules [#list-schedules]

```
list-schedules()
list-schedules(enabled: true)
list-schedules(name: "daily")
list-schedules(scheduleType: "one_time")
```

By default, completed one-time schedules are hidden. Pass `hideCompleted: false` to include them.

Update a Schedule [#update-a-schedule]

```
update-schedule(name: "daily-standup", cronExpression: "0 10 * * *")
update-schedule(name: "daily-standup", enabled: false)
update-schedule(name: "health-check", targetType: "workflow", workflowId: "<workflow-uuid>")
```

Delete a Schedule [#delete-a-schedule]

```
delete-schedule(name: "daily-standup")
```

Run Immediately [#run-immediately]

Trigger a scheduled task now without waiting for the next interval:

```
run-schedule-now(name: "daily-standup")
```

This creates a task immediately but does not affect the regular schedule timing.

Cron Expression Examples [#cron-expression-examples]

| Expression     | Description              |
| -------------- | ------------------------ |
| `0 9 * * *`    | Every day at 9:00 AM     |
| `0 9 * * 1-5`  | Weekdays at 9:00 AM      |
| `*/30 * * * *` | Every 30 minutes         |
| `0 0 * * 0`    | Every Sunday at midnight |
| `0 9,17 * * *` | At 9 AM and 5 PM daily   |

How Scheduling Works [#how-scheduling-works]

The MCP server runs a scheduler that polls for due schedules at a configurable interval (default: 10 seconds, set via `SCHEDULER_INTERVAL_MS`).

When a schedule fires, execution depends on `targetType`:

* **`agent-task`** — creates a task from `taskTemplate` (the original behavior)
* **`workflow`** — runs the referenced workflow directly and returns `workflowRunIds`
* **`script`** — launches the named global script directly and returns `scriptRunIds`

For `agent-task` schedules:

1. A new task is created with the schedule's `taskTemplate`
2. The task is linked back to its schedule via `scheduleId`
3. If `targetAgentId` is set, the task is assigned to that agent; otherwise it goes into the unassigned pool
4. If `model` or `modelTier` is set, the task inherits that runtime selection
5. The schedule's `lastRunAt` is updated
6. **One-time schedules** are automatically disabled after execution

Direct Workflow and Script Targets [#direct-workflow-and-script-targets]

Native `targetType` replaces the old "workflow trigger references a schedule" indirection for many cases:

```json
{
  "name": "nightly-pipeline",
  "targetType": "workflow",
  "workflowId": "<workflow-uuid>"
}
```

Use `targetType: "script"` plus `scriptName` / `scriptArgs` when the scheduled work is a reusable script and does not need an interactive agent task.

Any registered agent can update or delete schedules.

Related [#related]

* [Task Lifecycle](/docs/concepts/task-lifecycle) — How scheduled tasks flow through the system after creation
* [Environment Variables](/docs/reference/environment-variables) — `SCHEDULER_INTERVAL_MS` and other configuration
* [MCP Tools Reference](/docs/reference/mcp-tools) — Scheduling tools (create-schedule, list-schedules, etc.)
* [Schedules API Reference](/docs/api-reference/schedules) — REST API endpoints for creating and managing schedules


# Service Discovery (/docs/concepts/services)



Workers can run background HTTP services on port 3000 and register them for discovery by other agents. This enables inter-agent communication beyond the MCP tools while keeping the registry constrained to project-owned executables.

<Callout type="warn">
  The service registry MCP tools (`register-service`, `unregister-service`, `list-services`, `update-service-status`) require the `services` capability, which is **disabled by default**. Enable it by setting `CAPABILITIES` on the API server to the full default list plus `services` — the variable replaces the defaults, it is not additive. See the [environment variables reference](/docs/reference/environment-variables).
</Callout>

How It Works [#how-it-works]

Each worker container exposes port 3000. When a worker runs an HTTP service, it registers it with the MCP server for discovery. The registry entry is always keyed by the agent's own ID and URL, so each worker can expose one canonical service endpoint.

Service URL Pattern [#service-url-pattern]

```
https://{agentId}.{SWARM_URL}
```

The URL is automatically derived from the agent's ID and the swarm's base domain.

Starting a Service [#starting-a-service]

1\. Start with PM2 [#1-start-with-pm2]

```bash
pm2 start /workspace/myapp/server.js --name my-api
```

2\. Register for Discovery [#2-register-for-discovery]

Use the `register-service` MCP tool:

```
register-service(
  script: "/workspace/myapp/server.js",
  description: "My API service"
)
```

`register-service` only accepts absolute project-file paths under `/workspace` or `/home/worker`, limits interpreters to `node`, `bun`, or `python3`, and rejects shell-style metacharacters in args. That keeps PM2 restart metadata from turning into an arbitrary shell execution surface.

3\. Mark as Healthy [#3-mark-as-healthy]

```
update-service-status(name: "<your-agent-id>", status: "healthy")
```

Finding Services [#finding-services]

Other agents can discover services using:

```
list-services()
```

Filter by agent or name:

```
list-services(agentId: "worker-uuid")
list-services(name: "my-api")
```

Health Checks [#health-checks]

Services should implement a `/health` endpoint that returns HTTP 200 OK. The swarm monitors service health status.

Health Statuses [#health-statuses]

| Status      | Description                         |
| ----------- | ----------------------------------- |
| `starting`  | Service is initializing             |
| `healthy`   | Service is running and responding   |
| `unhealthy` | Service is not responding correctly |
| `stopped`   | Service has been stopped            |

PM2 Management [#pm2-management]

PM2 is the recommended process manager for background services:

```bash
pm2 start <script> --name <name>    # Start a service locally
pm2 stop|restart|delete <name>       # Manage services
pm2 logs [name]                      # View logs
pm2 list                             # Show running processes
```

Auto-Restart [#auto-restart]

Registered services are automatically restarted on container restart via `ecosystem.config.js`. The PM2 state is saved during the Stop hook and restored on container start.

Stopping a Service [#stopping-a-service]

```bash
# 1. Stop locally
pm2 delete my-api

# 2. Remove from registry
unregister-service(name: "<your-agent-id>")
```

Related [#related]

* [Architecture Overview](/docs/architecture/overview) — How services fit into the swarm architecture
* [Hook System](/docs/architecture/hooks) — The Stop hook saves PM2 state for auto-restart
* [MCP Tools Reference](/docs/reference/mcp-tools) — Service discovery tools (register-service, list-services, etc.)


# Task Lifecycle (/docs/concepts/task-lifecycle)



Tasks are the fundamental unit of work in Agent Swarm. Understanding the task lifecycle is essential for working with the system effectively.

Task States [#task-states]

<Mermaid
  chart="stateDiagram-v2
    [*] --> backlog
    backlog --> unassigned
    unassigned --> offered
    offered --> pending : accepted
    offered --> [*] : rejected
    pending --> in_progress
    in_progress --> completed
    in_progress --> failed
    in_progress --> paused
    paused --> in_progress
    paused --> completed
    paused --> failed"
/>

State Descriptions [#state-descriptions]

| State         | Description                                                         |
| ------------- | ------------------------------------------------------------------- |
| `backlog`     | Task is in the backlog, not yet ready for assignment                |
| `unassigned`  | Task is in the pool, available for any worker to claim              |
| `offered`     | Task has been offered to a specific agent, awaiting accept/reject   |
| `pending`     | Task has been accepted/assigned but work hasn't started yet         |
| `in_progress` | Worker is actively executing the task                               |
| `paused`      | Task was paused (e.g., during container restart) and can be resumed |
| `completed`   | Task finished successfully                                          |
| `failed`      | Task could not be completed                                         |
| `cancelled`   | Task was cancelled by the lead or creator                           |

Task Creation [#task-creation]

Tasks can be created in several ways:

Direct Assignment [#direct-assignment]

The lead sends a task directly to a specific worker:

```
send-task(task: "Fix the login bug", agentId: "worker-uuid")
```

Task Pool [#task-pool]

Tasks can be created without assignment, going into a shared pool:

```
task-action(action: "create", task: "Review PR #42")
```

Workers claim tasks from the pool:

```
task-action(action: "claim", taskId: "task-uuid")
```

Offer Mode [#offer-mode]

Tasks can be offered to a worker who must accept or reject:

```
send-task(task: "Refactor auth module", agentId: "worker-uuid", offerMode: true)
```

External Sources [#external-sources]

Tasks are automatically created from:

* **Slack** — Direct messages to the bot
* **GitHub** — @mentions, issue assignments, PR review requests
* **Email** — Messages to registered AgentMail inboxes
* **Schedules** — Cron-based recurring tasks

Task Dependencies [#task-dependencies]

Tasks can depend on other tasks:

```
send-task(
  task: "Deploy to production",
  dependsOn: ["build-task-id", "test-task-id"]
)
```

A task with dependencies won't be offered or claimable until all dependencies are completed. If an upstream task fails, is cancelled, or is superseded, its dependents are now cascade-failed with a descriptive reason instead of staying blocked forever.

Task Properties [#task-properties]

| Property               | Description                                                                                                                                                                                                        |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `task`                 | Description of what needs to be done                                                                                                                                                                               |
| `priority`             | 0-100 (default: 50). Higher priority tasks are processed first                                                                                                                                                     |
| `tags`                 | Labels for filtering (e.g., `['urgent', 'frontend']`)                                                                                                                                                              |
| `taskType`             | Classification (e.g., `bug`, `feature`, `review`)                                                                                                                                                                  |
| `dependsOn`            | Array of task IDs that must complete first; non-success terminal parents cascade-fail their dependents                                                                                                             |
| `parentTaskId`         | For follow-up continuity — child tasks inherit a bounded prior-task context preamble rebuilt from the task chain, so continuity survives restarts and works the same across every harness                          |
| `followUpConfig`       | Optional control over the lead follow-up created when this task completes or fails. Useful for long-running flows that need custom completion instructions or no follow-up at all                                  |
| `dir`                  | Working directory (absolute path) for the agent to start in. Falls back to repo clone path or default cwd                                                                                                          |
| `model`                | Model override: `haiku`, `sonnet`, or `opus`. Priority: task > `MODEL_OVERRIDE` config > `opus`                                                                                                                    |
| `scheduleId`           | Back-reference to the originating schedule (set automatically for schedule-created tasks)                                                                                                                          |
| `contextKey`           | Uniform ingress-scoped key populated automatically at every ingress site. See [Context Keys](#context-keys) below                                                                                                  |
| `requiredCapabilities` | Optional (`send-task`/`task-action create`) — capabilities a candidate agent must have for task routing. See [Routing Affinity](#routing-affinity) below                                                           |
| `leadOnly`             | Optional (`send-task`/`task-action create`) — explicit authorization boundary for privileged work. Only Lead agents may be assigned, offered, claim, or recover the task; this is never inferred from prompt text. |

Context Keys [#context-keys]

Every task created through an ingress path gets a `contextKey` stamped on it so related tasks can be grouped across channels. The format is `task:<source>:<identifiers>`:

| Source    | Format                                                     | Example                                               |
| --------- | ---------------------------------------------------------- | ----------------------------------------------------- |
| Slack     | `task:slack:{channelId}:{threadTs}`                        | `task:slack:C0ABC:1700000000.000100`                  |
| AgentMail | `task:agentmail:{threadId}`                                | `task:agentmail:thr_abc123`                           |
| GitHub    | `task:trackers:github:{owner}:{repo}:{issue\|pr}:{number}` | `task:trackers:github:desplega-ai:agent-swarm:pr:357` |
| GitLab    | `task:trackers:gitlab:{projectId}:{mr\|issue}:{iid}`       | `task:trackers:gitlab:42:mr:7`                        |
| Linear    | `task:trackers:linear:{issueIdentifier}`                   | `task:trackers:linear:DES-37`                         |
| Schedule  | `task:schedule:{scheduleId}`                               | `task:schedule:b9fe33cb-...`                          |
| Workflow  | `task:workflow:{workflowRunId}`                            | `task:workflow:f8d42a10-...`                          |

Child tasks created via `parentTaskId` (including `send-task` delegations) automatically inherit their parent's `contextKey`. This enables sibling-task awareness: when a worker starts a task, its prompt surfaces recent siblings sharing the same `contextKey` so related work across ingress paths isn't missed.

In addition, follow-up tasks now receive a bounded **context preamble** built from the parent chain before execution begins. The immediate parent contributes inline task/output/artifact detail, older ancestors are included as pointers only, and the whole block is capped by `CONTEXT_PREAMBLE_MAX_TOKENS` (default: 2000) so continuity works across every harness without unbounded context growth.

The column is indexed as `(contextKey, status)` for fast sibling lookup. Historical rows remain `null` — no backfill is performed.

Progress Tracking [#progress-tracking]

Workers report progress using the `store-progress` tool:

```
store-progress(taskId: "...", progress: "Fixed the auth check, running tests now")
```

When done:

```
store-progress(taskId: "...", status: "completed", output: "PR #42 created")
```

Or on failure:

```
store-progress(taskId: "...", status: "failed", failureReason: "Tests still failing after 3 attempts")
```

For automatic or recurring tasks (schedules, heartbeat, monitors, digests), completion memories are skipped unless the task explicitly opts in with `persistMemory: true` on its final `store-progress` call.

Terminal result consistency [#terminal-result-consistency]

Task results use first-call-wins semantics across both `store-progress` and `POST /api/tasks/{id}/finish`. An identical repeat is treated as an idempotent no-op. A later write that would change `output` or `failureReason` is rejected instead of silently reporting success (`store-progress` returns an error result and the REST finish endpoint returns HTTP 409), which makes competing agent and runner results visible while preserving the original `finishedAt` and completion side effects.

To correct result text deliberately, pass `force: true`. A forced correction changes only the explicitly provided `output` and/or `failureReason`; it does not change terminal status or replay events, memory writes, follow-up creation, business-use instrumentation, or capacity updates. Structured output is validated again before an overwrite is accepted.

When terminal output or trusted VCS metadata identifies a GitHub pull request, the server also persists its canonical URL as a generated task attachment. Caller-provided attachments are preserved and duplicate URL forms are reconciled, including after a forced output correction. Shipping reports treat these attachments as authoritative evidence and retain output-string matching only as a compatibility fallback for older tasks.

Graceful Shutdown & Resume [#graceful-shutdown--resume]

When a worker container receives SIGTERM:

1. **Grace period** — Worker waits for active tasks to complete (default: 30s)
2. **Tasks paused** — Any tasks still running are marked as `paused`
3. **State preserved** — Progress is saved to the database
4. **On restart** — Worker automatically resumes paused tasks with full context

This enables zero-downtime deployments.

Stalled Task Auto-Remediation [#stalled-task-auto-remediation]

The heartbeat system automatically detects and recovers stalled tasks — tasks that remain `in_progress` but whose worker has become unresponsive. When the lead agent starts up, it triggers an immediate heartbeat sweep to catch any tasks that stalled while the swarm was down.

Stalled task detection uses the heartbeat's code-level triage: if a task has been `in_progress` for longer than expected without progress updates and its assigned worker is offline, the heartbeat can reassign or fail the task as appropriate.

Crash Recovery & Graceful Resume — Same-Agent Pin + Lead Fallback [#crash-recovery--graceful-resume--same-agent-pin--lead-fallback]

When the heartbeat classifies a task as crashed (its worker has gone unresponsive), or when a worker is paused during graceful shutdown and needs a follow-up resume, the recovery task is **pinned back to the original agent** instead of being released to the role-blind unassigned pool. Agent IDs are stable across a restart and the original agent row survives, so the resume is reclaimed when that same agent comes back — and no wrong-specialization worker can pick it up in the meantime.

If the agent never returns, the resume stays `pending`. After `HEARTBEAT_RESUME_PIN_GRACE_MIN` (default \~10 minutes, measured from crash detection) a heartbeat reaper concludes the agent is gone and escalates: it creates a Lead-owned `task.reroute.decision` follow-up. The Lead then re-delegates the work to an explicitly chosen agent via `send-task` — the work is never returned to the pool. A resume that has already been retried up to `HEARTBEAT_MAX_RESUME_GENERATIONS` times is failed instead of escalated, to bound a flapping task.

<Callout type="info">
  Three environment variables gate this behavior:

  * `HEARTBEAT_RESUME_PIN_GRACE_MIN` (default `10`) — minutes a pinned resume waits to be reclaimed before the reaper escalates it to the Lead. Set to `0` to disable the reaper.
  * `HEARTBEAT_PIN_CRASH_RESUME` (default on) — set to `0` to restore the previous behavior, where crash-recovery resumes fall back to the unassigned pool instead of pinning to their original agent.
  * `HEARTBEAT_PIN_GRACEFUL_RESUME` (default on) — set to `0` to restore the previous behavior for graceful-shutdown resumes, sending them back through the pool instead of pinning them to the original agent.
</Callout>

Routing Affinity [#routing-affinity]

Every path that puts an interrupted task back into the unassigned pool — a resume that falls off its same-agent pin (agent offline, gone, or at capacity), or a reboot-sweep retry child — stamps a `routingAffinity` snapshot (`{ sourceAgentId, role, capabilities, leadOnly? }`) taken from the original agent. A single eligibility check, `isAgentEligibleForTask`, gates every assignment, offer, claim, and recovery path. For ordinary affinity-tagged work, an agent is eligible if it's the task's own `sourceAgentId`, or if its `role` exactly matches the snapshot's `role` **and** its `capabilities` are a superset of the snapshot's. Missing role data on either side means ineligible — there's no fail-open to "any idle worker".

`leadOnly: true` is stricter: the candidate must be a Lead and must satisfy every explicitly required capability. A source-agent or matching-role identity cannot bypass that boundary, and child continuations inherit it from their parent.

A task with a `routingAffinity` and zero currently-registered agents that satisfy it (regardless of status — an offline-but-matching agent still counts) is escalated to the Lead after `POOL_AFFINITY_ESCALATION_MIN` minutes, the same way an unreclaimed pin is escalated above. Fresh pool tasks can also declare a capability requirement directly via `send-task`'s or `task-action create`'s `requiredCapabilities` param, without a role — such a task always escalates to the Lead (since only its own creator could ever match with no `role` set), making it a way to hand the Lead a capability hint rather than to auto-route.

Tasks without a `routingAffinity` are completely unaffected — the pool behaves exactly as before. A malformed persisted affinity is quarantined from assignment instead of being treated as untagged work.

<Callout type="info">
  Two more environment variables:

  * `POOL_AFFINITY_ENFORCEMENT` (default on) — set to `0` to disable role/capability matching for ordinary affinity-tagged tasks. It never disables the `leadOnly` authorization boundary or its explicit capability requirements.
  * `POOL_AFFINITY_ESCALATION_MIN` (default `15`) — minutes an affinity-tagged pool task waits with zero eligible agents before escalating to the Lead.
</Callout>

Related [#related]

* [Scheduled Tasks](/docs/concepts/scheduling) — Automate recurring task creation
* [Architecture Overview](/docs/architecture/overview) — How the task system fits into the overall architecture
* [Deployment Guide](/docs/guides/deployment) — Graceful shutdown and task resume in production
* [Tasks API Reference](/docs/api-reference/tasks) — REST API endpoints for creating, querying, and managing tasks


# Workflows (/docs/concepts/workflows)



Agent Swarm includes a **workflow automation engine** that lets you define multi-step processes as directed acyclic graphs (DAGs). Workflows connect triggers, conditions, and actions into pipelines that execute automatically.

Bundled workflows declare the parameters and integrations they require. If one is enabled before setup is complete, it reports `needs_setup` with exactly what is missing in the dashboard and refuses to trigger until setup is complete. Operator-created workflows remain unaffected unless they opt into the same `params`, `requiredParams`, and `requires` metadata.

Core Concepts [#core-concepts]

A workflow consists of:

* **Nodes** — Individual steps with a `next` field that defines routing (no explicit edges needed)
* **Executors** — Class-based step implementations with Zod-typed config and output schemas
* **Triggers** — Entry points: webhook (HMAC-SHA256, timestamped HMAC, shared token), schedule (ref), or manual
* **Checkpoint durability** — Atomic DB write after every step; resume from last checkpoint on crash
* **Fan-out / convergence** — Parallel execution via array `next`, or item-driven agent-task fan-out via `foreach`, with automatic convergence gating

Executor Types [#executor-types]

The engine includes 12 built-in executor types:

| Executor            | Description                                                                                                                                                                                                                   |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `script`            | Run a shell script or JavaScript expression                                                                                                                                                                                   |
| `swarm-script`      | Run a reusable TypeScript script from the swarm catalog with optional `args`, `scope`, `pinHash`, and a configurable `timeoutMs` wall-clock budget                                                                            |
| `agent-task`        | Create and delegate a task to a swarm agent                                                                                                                                                                                   |
| `foreach`           | Fan out one agent task per array item, wait for every child, and emit one aggregate result                                                                                                                                    |
| `raw-llm`           | Call an LLM directly with a prompt template                                                                                                                                                                                   |
| `vcs`               | Version control operations (create PR, merge, etc.)                                                                                                                                                                           |
| `property-match`    | Route based on matching properties in step input                                                                                                                                                                              |
| `code-match`        | Route based on a custom JavaScript expression in a sandbox                                                                                                                                                                    |
| `notify`            | Send a notification (Slack, email, or internal message)                                                                                                                                                                       |
| `validate`          | Validate step output; can halt or trigger retry                                                                                                                                                                               |
| `human-in-the-loop` | Pause workflow for human approval or input via the dashboard                                                                                                                                                                  |
| `wait`              | Pause for a fixed duration (`mode: "time"`) or until a workflow event matching an optional filter (`mode: "event"`); event mode supports `run` / `global` scope and an optional `timeoutMs` that routes to the `timeout` port |

Each executor has typed config and output schemas available via `GET /api/executor-types` (returns JSON Schemas via Zod v4 `z.toJSONSchema()`).

`swarm-script` is the durable, reusable counterpart to inline `script` nodes. In workflow definitions it accepts `scriptName`, optional `scope` (`agent` or `global`), optional `pinHash`, JSON `args`, and `timeoutMs`. The timeout defaults to `30000` ms, accepts integers from `1000` through `300000` (5 minutes), and raises the script runtime's wall-clock budget for I/O-bound work while still respecting the separate 60-second CPU-time ceiling.

For `config.args`, values written as an **exact single token** such as `{{input.payload}}` preserve the resolved JSON type, so objects, arrays, numbers, and booleans reach the script as native values. If you wrap a token with surrounding text, such as `"prefix-{{input.id}}"`, interpolation falls back to string output. Raw-token resolution is scoped to the node's local interpolation context, including any explicit `inputs` mapping on that node.

Foreach fan-out [#foreach-fan-out]

A `foreach` node resolves `config.over` to an array, creates one `agent-task` child for each item, waits for all children to finish, and exposes an aggregate result to its successor. Use an exact interpolation token for `over` so the value remains an array; `itemKey` must identify a unique, non-empty string property on every item.

```yaml
- id: review-by-agent
  type: foreach
  inputs: { agents: "discover.result.agents" }
  config:
    over: "{{agents}}"
    itemKey: id
    body:
      type: agent-task
      config:
        agentId: "{{item.id}}"
        template: "Review the change as {{item.name}} (index {{index}})"
  next: summarize
```

In v1, `body.type` is limited to `agent-task`, all children fan out together, and `concurrency` is rejected. Child steps use synthetic IDs such as `review-by-agent#agent-id`. The default `onNodeFailure: "fail"` stops the run on a failed child; `"continue"` lets the remaining children finish and records failed entries in the aggregate alongside `okCount` and `failedCount`.

Defining a Workflow [#defining-a-workflow]

Workflows use a **nodes-with-next** schema. Each node declares its `next` field for routing — edges are auto-generated for the UI graph visualization.

The `next` field supports three formats:

| Format                   | Example                                     | Description                        |
| ------------------------ | ------------------------------------------- | ---------------------------------- |
| `string`                 | `"next-node"`                               | Simple chaining to one node        |
| `string[]`               | `["node-a", "node-b"]`                      | Fan-out to multiple parallel nodes |
| `Record<string, string>` | `{ "pass": "ok-node", "fail": "err-node" }` | Port-based conditional routing     |

```json
{
  "name": "pr-review-pipeline",
  "description": "Auto-assign PR reviews on webhook",
  "triggers": [
    { "type": "webhook", "hmacSecret": "secret.GITHUB_WEBHOOK_SECRET" }
  ],
  "definition": {
    "nodes": [
      {
        "id": "check-type",
        "type": "property-match",
        "label": "Check Event Type",
        "config": { "property": "event", "match": "pull_request.opened" },
        "next": { "match": "create-review", "no_match": null }
      },
      {
        "id": "create-review",
        "type": "agent-task",
        "label": "Create Review Task",
        "config": {
          "taskTemplate": "Review PR #{{input.number}}: {{input.title}}",
          "tags": ["review", "automated"],
          "priority": 60
        }
      }
    ]
  }
}
```

Triggers [#triggers]

| Type       | Description                                                                          |
| ---------- | ------------------------------------------------------------------------------------ |
| `webhook`  | HTTP POST to `/api/workflows/:id/trigger` with optional verification                 |
| `schedule` | References a schedule by ID — the schedule creates workflow runs at configured times |
| `manual`   | Triggered via the `trigger-workflow` MCP tool or dashboard UI                        |
| `event`    | Starts from a named internal event; `slack.message` is currently supported           |

An event trigger uses `{ "type": "event", "eventName": "slack.message" }`. The Slack message payload becomes the workflow's `triggerData` and contains `channel`, `text`, `user`, `ts`, and `threadTs`; any configured `triggerSchema` is applied before a run starts.

Workflow runs preserve the human who actually triggered them in `createdBy`. Authenticated HTTP and MCP triggers use the trusted caller context, schedules use their creator when present, and Kapso routing uses its resolved sender. Generic ownerless webhooks and creatorless schedules remain unattributed rather than inheriting the workflow author's identity. Retry, resume, and recovery paths rehydrate this requester from the run so agent tasks created later keep the same attribution.

Webhook verification formats [#webhook-verification-formats]

Webhook triggers verify signatures only when `hmacSecret` is set. The secret can be a literal, an environment reference such as `${WEBHOOK_SECRET}`, or a swarm secret reference such as `secret.SUPERAGENT_WEBHOOK_SECRET`.

Omitting `verification` keeps the legacy behavior: HMAC-SHA256 over the raw request body, accepting either `sha256=<hex>` or bare hex. The verifier checks `hmacHeader` first and then the legacy fallback headers.

```json
{
  "type": "webhook",
  "hmacSecret": "secret.GITHUB_WEBHOOK_SECRET",
  "hmacHeader": "X-Hub-Signature-256"
}
```

Set `verification.format` to make the expected format explicit. Explicit formats read only the configured header.

```json
{
  "type": "webhook",
  "hmacSecret": "secret.GITHUB_WEBHOOK_SECRET",
  "verification": {
    "format": "hmac-sha256",
    "header": "X-Hub-Signature-256"
  }
}
```

Use `timestamped-hmac-sha256` for Stripe/Superagent-style headers such as `t=<timestamp>,v1=<hex>`. The signed payload is `<timestamp>.<raw body>`, multiple `v1` entries are accepted for secret rotation, and timestamps outside the tolerance window are rejected.

```json
{
  "type": "webhook",
  "hmacSecret": "secret.SUPERAGENT_WEBHOOK_SECRET",
  "verification": {
    "format": "timestamped-hmac-sha256",
    "header": "X-Superagent-Signature",
    "timestampKey": "t",
    "signatureKey": "v1",
    "toleranceSeconds": 300
  }
}
```

Use `token-equality` for shared-token providers such as GitLab. The configured `hmacSecret` stores the expected token and the request header must match it.

```json
{
  "type": "webhook",
  "hmacSecret": "secret.GITLAB_WEBHOOK_TOKEN",
  "verification": {
    "format": "token-equality",
    "header": "X-Gitlab-Token"
  }
}
```

Cooldown [#cooldown]

Workflows support a cooldown period to prevent rapid re-triggering:

```json
{
  "cooldown": { "hours": 0, "minutes": 5, "seconds": 0 }
}
```

A second trigger within the cooldown window results in a run with `skipped` status.

Trigger payload validation (`triggerSchema`) [#trigger-payload-validation-triggerschema]

Workflows can attach an optional JSON Schema to validate the `triggerData` payload across every trigger path — manual `/trigger`, webhooks, schedules, and the `trigger-workflow` MCP tool. When set, mismatched payloads are rejected with HTTP 400 (or an MCP error) **before** the workflow starts: no run is created, no nodes execute. When unset, any payload is accepted (current default).

Set or update via `create-workflow` / `update-workflow` / `patch-workflow` (MCP) or `POST` / `PUT` / `PATCH /api/workflows/{id}` (HTTP). The `PUT` / `PATCH` paths accept `null` to clear an existing schema.

The validator supports a deliberate JSON-Schema subset: `type`, `required`, `properties`, `enum`, `const`, `items`. Other keywords (`oneOf`, `anyOf`, `$ref`, `pattern`, `format`, `additionalProperties`, …) are silently ignored. On validation failure, the response echoes the workflow's current `triggerSchema` so callers can self-correct without a follow-up `get-workflow`.

See [runbooks/workflows.md § Trigger schema](https://github.com/desplega-ai/agent-swarm/blob/main/runbooks/workflows.md#trigger-schema) for the full reference and authoring examples.

Execution Model [#execution-model]

When a workflow triggers:

1. The trigger source (webhook, schedule, or manual) creates a new **workflow run**
2. The engine's `walkGraph()` finds ready nodes and executes them in parallel
3. Each node's executor runs and produces output that determines port-based routing via `next`
4. **Checkpoint durability** — after every step, an atomic DB write saves the step result and execution context
5. On crash or restart, execution resumes from the last checkpoint

Fan-Out and Convergence [#fan-out-and-convergence]

Workflows support **fan-out** by specifying an array of node IDs in the `next` field. All target nodes execute in parallel, and downstream convergence nodes automatically wait for all predecessors to complete before executing.

```json
{
  "definition": {
    "nodes": [
      {
        "id": "start",
        "type": "agent-task",
        "next": ["task-a", "task-b", "task-c"]
      },
      { "id": "task-a", "type": "agent-task", "next": "merge" },
      { "id": "task-b", "type": "agent-task", "next": "merge" },
      { "id": "task-c", "type": "agent-task", "next": "merge" },
      {
        "id": "merge",
        "type": "agent-task",
        "inputs": { "a": "task-a", "b": "task-b", "c": "task-c" },
        "config": { "template": "Combine results from {{a.taskOutput}}, {{b.taskOutput}}, {{c.taskOutput}}" }
      }
    ]
  }
}
```

The engine detects convergence nodes (nodes with multiple predecessors) and gates execution until all predecessors complete. This works correctly with async executors like `agent-task` — the resume logic uses convergence-aware node detection.

Node Failure Behavior (`onNodeFailure`) [#node-failure-behavior-onnodefailure]

By default, if any node's task fails or is cancelled, the entire workflow run is marked as failed. You can change this with the `onNodeFailure` field on the workflow definition:

```json
{
  "definition": {
    "onNodeFailure": "continue",
    "nodes": [...]
  }
}
```

| Value              | Behavior                                                                                                                                                  |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"fail"` (default) | Mark the entire run as failed immediately                                                                                                                 |
| `"continue"`       | Treat the failed node as completed with error output and proceed — downstream convergence nodes receive `[FAILED: reason]` and can handle partial results |

This is useful for fan-out patterns where some branches may fail but you still want to collect results from the successful ones.

Per-Step Retry [#per-step-retry]

Each node can define a `retryPolicy`:

```json
{
  "retryPolicy": {
    "maxRetries": 3,
    "backoff": "exponential",
    "initialDelayMs": 1000
  }
}
```

Backoff strategies: `exponential`, `linear`, `static`. The poller detects failed steps and re-executes them according to the policy. Before a retry, the engine restores completed upstream outputs from step checkpoints, so declared inputs resolve to the same values as the original execution. Retrying a failed run also reconstructs the branch selected by each completed step and never revives a branch that was not taken.

Per-Node Timeout [#per-node-timeout]

Timeouts live in each executor's config so the executor and the workflow watchdog use the same budget. For a `swarm-script` node:

```json
{
  "id": "run-report",
  "type": "swarm-script",
  "config": {
    "scriptName": "build-report",
    "timeoutMs": 300000
  }
}
```

For `swarm-script` nodes, set `config.timeoutMs` to raise or lower both the workflow watchdog and the spawned script's wall-clock budget within the allowed `1000`-`300000` ms range. Create, update, bulk-patch, and single-node patch operations reject an out-of-range executor config before saving the workflow.

Inline `script` nodes use `config.timeout` instead. That value is the wall-clock budget for both the inline executor and the workflow step watchdog, so a long-running script is not stopped by the default 30-second watchdog before its configured timeout. It accepts `1000` through `300000` ms and defaults to `30000` ms.

For orchestration that needs more than 5 minutes, launch a durable one-off script workflow with `launch-script-run` and split the operation into bounded, journaled `ctx.step.swarmScript` (or other durable) steps. Durable runs resume across process restarts, but an individual script step still uses its own bounded scripts-runtime window.

Cancelling a Workflow Run [#cancelling-a-workflow-run]

Running or waiting workflow runs can be cancelled using the `cancel-workflow-run` MCP tool. Cancellation terminates all non-terminal steps and their associated tasks. An optional reason can be provided for audit purposes.

Per-Step Validation [#per-step-validation]

The `validate` executor runs after a step to check output quality. It can:

* **Pass** — continue to the next step
* **Halt** — stop the workflow run
* **Retry** — re-execute the previous step

Cross-Node Data Access (`inputs` Mapping) [#cross-node-data-access-inputs-mapping]

By default, upstream step outputs are **not** available for interpolation. Built-in `trigger`, `input`, `workflow`, `swarm`, and `run` context remains available for ordinary config values. To access another node's output, declare an `inputs` mapping on the node:

```json
{
  "id": "summarize",
  "type": "agent-task",
  "inputs": { "cityData": "generate-city" },
  "config": {
    "template": "The city is {{cityData.taskOutput.city}} in {{cityData.taskOutput.country}}"
  }
}
```

* Keys are local names used in `{{interpolation}}`, values are context paths (usually a node ID)
* Agent-task output shape is `{ taskId, taskOutput }` — access fields via `localName.taskOutput.field`
* For trigger data: `{ "pr": "trigger.pullRequest" }` → `{{pr.number}}`
* Without `inputs`, ordinary templates referencing upstream nodes resolve to empty strings and report the token in run diagnostics

Executable source has a stricter boundary. Inline `script` source may interpolate only `input`, `workflow`, `swarm`, and `run`; pass trigger or upstream values through `config.args` so they arrive as argv instead of executable text. Named `swarm-script` source is never workflow-interpolated, so pass all dynamic values through its `args` object. Disallowed or unresolved source tokens fail the node before execution, while unrelated mustache strings remain literal data.

Structured Output (`outputSchema`) [#structured-output-outputschema]

Agent-task nodes can require structured JSON output from the agent via `config.outputSchema`:

```json
{
  "id": "generate-city",
  "type": "agent-task",
  "config": {
    "template": "Pick a random city and return it as JSON",
    "outputSchema": {
      "type": "object",
      "required": ["city", "country"],
      "properties": {
        "city": { "type": "string" },
        "country": { "type": "string" }
      }
    }
  }
}
```

There are **two different `outputSchema` locations** — they validate at different layers:

| Location                                      | Validates                                          |
| --------------------------------------------- | -------------------------------------------------- |
| `config.outputSchema` (inside config)         | The **agent's raw JSON** response                  |
| Node-level `outputSchema` (sibling of config) | The **executor's return** envelope (rarely needed) |

For agent-task nodes, put your schema in `config.outputSchema`. The agent is prompted to produce JSON matching this schema, and `store-progress` validates it inline.

Workspace Scoping [#workspace-scoping]

Workspace scoping can be set at two levels:

**Workflow level** — Set `dir` and `vcsRepo` on the workflow itself. All `agent-task` nodes that don't explicitly set these fields inherit the workflow-level defaults. These values are also available for interpolation:

```json
{
  "name": "my-workflow",
  "dir": "/workspace/myrepo",
  "vcsRepo": "https://github.com/org/repo",
  "definition": {
    "nodes": [
      {
        "id": "build",
        "type": "agent-task",
        "config": {
          "template": "Build and test in {{workflow.dir}}"
        }
      }
    ]
  }
}
```

**Node level** — Set `config.dir` or `config.vcsRepo` on individual `agent-task` nodes. Node-level settings override workflow-level defaults.

Interpolation sources available in node config:

* `{{workflow.dir}}` — Workflow-level dir
* `{{workflow.vcsRepo}}` — Workflow-level vcsRepo
* `{{trigger.*}}` — Trigger payload data
* `{{input.*}}` — Workflow-level resolved inputs

Version History [#version-history]

Every workflow update creates a snapshot in the version history table, recording the previous definition. This provides an audit trail and enables rollback.

MCP Tools [#mcp-tools]

| Tool                  | Description                                                                                                            |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `create-workflow`     | Create a new workflow with a DAG definition                                                                            |
| `get-workflow`        | Get workflow details by ID                                                                                             |
| `list-workflows`      | List all workflows (optionally filter by enabled status)                                                               |
| `update-workflow`     | Update a workflow's definition, name, or enabled status                                                                |
| `patch-workflow`      | Partially update a workflow — create, update, or delete individual nodes with automatic version snapshots              |
| `patch-workflow-node` | Partially update a single node in a workflow with automatic version snapshots                                          |
| `delete-workflow`     | Delete a workflow and its run history                                                                                  |
| `trigger-workflow`    | Manually trigger a workflow execution                                                                                  |
| `get-workflow-run`    | Get details of a specific workflow run                                                                                 |
| `list-workflow-runs`  | List paginated slim run rows (20 by default, 100 max); use `includeContext: true` only when a page needs full contexts |
| `retry-workflow-run`  | Retry a failed workflow run                                                                                            |
| `cancel-workflow-run` | Cancel a running or waiting workflow run                                                                               |

`list-workflow-runs` returns bounded rows by default: run identifiers, status,
timestamps, error, and a trigger-data summary capped at 400 characters. It
omits full `context` and trigger data to keep routine history queries small.
Follow `page.hasMore` / `page.nextOffset` for pagination, call
`get-workflow-run` to retrieve one run with its steps and context, or pass
`includeContext: true` for an explicitly full page.

Dashboard UI [#dashboard-ui]

The dashboard includes a **Workflows** section (under Operations) with:

* **Workflows list** — View all workflows with enable/disable status
* **Workflow detail** — Tabbed view with Definition (node inspector showing config/input/output schemas) and Runs tabs
* **Run detail** — Split layout with interactive graph and collapsible steps panel, JsonTree viewer, bidirectional node selection, expand/collapse all, millisecond duration display

Human-in-the-Loop (HITL) Node [#human-in-the-loop-hitl-node]

The `human-in-the-loop` executor pauses a workflow until a human responds via the dashboard. This enables approval gates, manual input steps, and review checkpoints within automated pipelines.

```json
{
  "id": "approve-deploy",
  "type": "human-in-the-loop",
  "label": "Approve Deployment",
  "config": {
    "title": "Deploy to production?",
    "questions": [
      { "type": "approval", "label": "Approve this deployment?" },
      { "type": "text", "label": "Any notes?" }
    ]
  },
  "next": { "approved": "deploy", "rejected": "notify-rejected" }
}
```

Question types supported: `approval` (yes/no), `text`, `single-select`, `multi-select`, and `boolean`.

When the node executes, it creates an approval request accessible at `/approval-requests/{id}` in the dashboard. The workflow run pauses until a human responds. Upon resolution, a follow-up task is created for the requesting agent and the workflow resumes via the appropriate `next` port. Cancelling the run or its human-in-the-loop step marks the pending request as cancelled, rejects later responses, and updates any linked Slack approval thread so reviewers do not act on a stale gate.

The dashboard validates every required response before submission. Missing or invalid approval, text, select, and boolean answers are marked inline and keep **Submit Response** disabled; if the server rejects a response, its error is shown directly so the user can correct it.

Loop Support [#loop-support]

Workflows support **cycles** (loops) in the DAG. A node can route back to a previous node via its `next` field, enabling iterative patterns like retry-until-success or iterative refinement.

The engine uses **iteration-aware idempotency keys** to distinguish between loop iterations. Each time a node re-executes in a new iteration, it gets a unique checkpoint, allowing the engine to track progress correctly across multiple passes through the same node.

```json
{
  "definition": {
    "nodes": [
      {
        "id": "generate",
        "type": "agent-task",
        "config": { "template": "Generate a draft" },
        "next": "validate"
      },
      {
        "id": "validate",
        "type": "validate",
        "next": { "pass": "publish", "retry": "generate" }
      },
      {
        "id": "publish",
        "type": "agent-task",
        "config": { "template": "Publish the final draft" }
      }
    ]
  }
}
```

In this example, the `validate` node routes back to `generate` on failure, creating a loop that continues until validation passes.

Related [#related]

* [Scheduled Tasks](/docs/concepts/scheduling) — Time-based task automation (complementary to event-driven workflows)
* [Task Lifecycle](/docs/concepts/task-lifecycle) — How tasks created by workflows flow through the system
* [MCP Tools Reference](/docs/reference/mcp-tools) — Full tool documentation
* [Workflows API Reference](/docs/api-reference/workflows) — REST API endpoints for creating and managing workflows


# agent-fs Co-deployment (/docs/guides/agent-fs-co-deployment)



agent-swarm uses `local-fs` by default when no agent-fs endpoint is configured. That keeps fresh installs self-contained, but files stay local to the API process. Set `AGENT_FS_API_URL` and let the API boot seeder provision `API_AGENT_FS_API_KEY` plus per-agent `AGENT_FS_API_KEY` secrets to enable the richer provider.

What Changes [#what-changes]

With agent-fs enabled, task attachments and `/api/fs/*` use a shared filesystem provider instead of the local data directory. The dashboard can upload, preview, download, and delete task files. Workers can also write agent-fs pointers through `store-progress`; before changing task state, the server verifies that every pointer exists in the exact organization and drive supplied on the attachment or resolved from the registering agent's configured defaults. If scope, credentials, or a file is missing, the entire call fails without registering any attachment or terminal result.

Assigned-task prompts now also include a ready-to-run download recipe for each attachment using the provider-agnostic raw route:

```bash
curl -s \
  -H "Authorization: Bearer ${AGENT_SWARM_API_KEY:-$API_KEY}" \
  -H "X-Agent-ID: $AGENT_ID" \
  "$MCP_BASE_URL/api/fs/tasks/<taskId>/files/<attachmentId>/raw" \
  -o /tmp/<filename>
```

That lets workers fetch task files in one call without discovering the underlying storage provider, org, or drive first.

The first-class agent-fs integration has two deliberate v1 limits:

| Limit             | Value             |
| ----------------- | ----------------- |
| Signed URL expiry | 1 hour maximum    |
| Upload file types | All types allowed |
| Upload size       | 50 MB maximum     |

Docker Compose [#docker-compose]

`docker-compose.local.yml` and `docker-compose.example.yml` include MinIO and agent-fs. The API waits for agent-fs, sets `AGENT_FS_API_URL=http://agent-fs:7433`, and registers a service key on first boot when `API_AGENT_FS_API_KEY` is not supplied.

```bash
docker compose -f docker-compose.local.yml up --build
curl http://localhost:7433/health
```

The local recipe uses:

```bash
AGENT_FS_API_URL=http://agent-fs:7433
AGENT_FS_REGISTER_EMAIL=swarm-admin@agent-fs.local
S3_ENDPOINT=http://minio:9000
S3_BUCKET=agentfs
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
EMBEDDING_PROVIDER=local
```

Set `API_AGENT_FS_API_KEY` if you want to pre-provision a stable API-owned bootstrap key. Otherwise, the API seeder calls `/auth/register`, stores the generated key as an encrypted global swarm config secret, creates the shared org and drive, and persists `AGENT_FS_DEFAULT_ORG_ID` plus `AGENT_FS_DEFAULT_DRIVE_ID`. Workers do not receive this bootstrap key; the runner asks the API to create an agent-scoped `AGENT_FS_API_KEY` for each agent. If an agent's canonical registration already exists but its key was lost, provisioning registers a UUID-suffixed recovery identity, invites it to the shared organization as an editor, and stores the new agent-scoped key.

Late Provisioning and Shared-Org Invitations [#late-provisioning-and-shared-org-invitations]

If agent-fs is provisioned after the first filesystem request, provisioning now exports `AGENT_FS_API_URL` and clears the cached provider selection. `POST /api/config/reload` does the same after reloading global config. The next filesystem request can therefore switch from `local-fs` to `agent-fs` without restarting the API process.

A tenant-authenticated control plane can invite a customer into the shared organization without reading the API-owned bootstrap key:

```bash
curl -s -X POST "$MCP_BASE_URL/api/fs/members/invite" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"customer@example.com","role":"editor"}'
```

`role` accepts `viewer`, `editor`, or `admin` and defaults to `editor`. The API performs the invite with its bootstrap identity, never returns a key, and handles repeat calls by skipping the invite when the member's existing role already satisfies the request. See the [`POST /api/fs/members/invite` API reference](/docs/api-reference/fs#post-api-fs-members-invite) for the response schema.

Helm [#helm]

Enable the co-deployed service with `agentFs.enabled=true`:

```yaml title="values.yaml"
agentFs:
  enabled: true
  image:
    tag: 0.13.5
  bucket: agentfs
  s3:
    existingSecret: agent-fs-s3
    endpoint: https://s3.amazonaws.com
    region: us-east-1
  embedding:
    provider: local
```

The referenced secret can contain:

```bash
kubectl create secret generic agent-fs-s3 \
  --from-literal=S3_ACCESS_KEY_ID=... \
  --from-literal=S3_SECRET_ACCESS_KEY=... \
  --from-literal=S3_ENDPOINT=https://s3.amazonaws.com \
  --from-literal=S3_REGION=us-east-1
```

For hosted embeddings, set `agentFs.embedding.provider` to `openai` or `gemini`, set `agentFs.embedding.model`, and provide `agentFs.embedding.apiKey` or `EMBEDDING_API_KEY` in the existing secret.

```bash
helm template charts/agent-swarm --set agentFs.enabled=true | rg 'agent-fs|API_AGENT_FS_API_KEY|EMBEDDING_'
```

Migration Note [#migration-note]

Older deployments could implicitly assume hosted `live.agent-fs.dev` behavior. The no-config default is now `local-fs`. To keep shared agent-fs behavior after upgrading, set `AGENT_FS_API_URL` and provide or let the seeder create `API_AGENT_FS_API_KEY`; per-agent `AGENT_FS_API_KEY` rows are created by the runner provisioning endpoint.

For production, back up both systems:

* Agent Swarm SQLite database and `SECRETS_ENCRYPTION_KEY`
* agent-fs SQLite database plus its S3/MinIO bucket

Verify [#verify]

```bash
curl http://localhost:3013/api/fs/capabilities \
  -H "Authorization: Bearer $API_KEY"
```

The response should show `providerId: "agent-fs"` when the provider is active. If it shows `local-fs`, check `AGENT_FS_API_URL`, the stored `API_AGENT_FS_API_KEY`, and the API boot logs for the provisioning seeder. After late provisioning or a config reload, repeat this request; a process restart is no longer required to re-select the provider.


# Asset Namespaces (/docs/guides/asset-namespaces)



Asset namespace keys provide one directory-like grouping contract across the swarm's primary assets. A key is metadata, not identity: many assets can use the same key, while each asset keeps its existing ID. A task's `contextKey` also remains a separate routing and conversation-context field.

Key format [#key-format]

Every task, workflow, schedule, page, app, and script has a non-null canonical `key`. Existing rows and writes that omit the field use `shared/`.

* `shared/` and descendants such as `shared/releases/`
* `personal/<user-id>/` and descendants such as `personal/<user-id>/drafts/`
* lowercase, relative paths using forward slashes and a trailing slash
* a maximum of 255 characters after Unicode NFKC normalization

Empty segments, `.` or `..` traversal segments, backslashes, absolute paths, NUL bytes, and unknown roots are rejected. Repeated keys are expected and valid; database indexes on `key` are deliberately non-unique.

When a workflow creates a task, the task inherits the workflow key unless the task node supplies one. Scheduler-created tasks inherit the schedule key. New standalone resources receive deterministic shared keys such as `shared/task:<id>/`, `shared/workflow:<id>/`, `shared/schedule:<id>/`, `shared/page:<id>/`, `shared/app:<id>/`, `shared/script:<id>/`, and `shared/fs:agent-fs:<id>/`. Older clients that omit the column at the SQL boundary still fall back safely to `shared/`.

<Callout type="warn">
  `personal/<user-id>/` is a namespace and write-ownership convention. It is **not** a privacy boundary or a read-visibility guarantee. Existing read permissions still govern every entity. Do not store sensitive content based only on a `personal/` key.
</Callout>

Writes to a `personal/<user-id>/` destination require the trusted user resolved from the authenticated request or task context to match the user ID in the key. A caller-supplied user ID is not sufficient. Shared namespaces retain each entity's existing mutation rules.

Cross-entity API [#cross-entity-api]

`GET /api/assets` returns lightweight summaries across tasks, workflows, schedules, pages, apps, scripts, and mapped files. It accepts `keyPrefix`, comma-separated `types`, and `limit` filters. Summaries contain labels and provider references but never task briefs, page bodies, workflow definitions, script source, secrets, or file bytes.

`PATCH /api/assets/{entityType}/{id}/key` moves an asset to another logical namespace. App moves require `app.manage`; an agent-scoped script may be moved by its owning agent or an operator, while global scripts require lead/global write access. Moving an external provider mapping requires operator authentication and changes only local namespace metadata; its provider ID, organization, drive, and provider key remain unchanged, and the server does not call the remote provider. A task attachment mapping moves with its parent task and cannot be detached through the file endpoint.

Operator-only `POST /api/assets/mappings` idempotently associates a provider tuple with a logical key. The tuple is unique, while the namespace key is not. Agent-fs task attachments receive mappings automatically and inherit their parent task's namespace.

SwarmSDK access [#swarmsdk-access]

HTML pages and JSON-page actions can use the domain-grouped asset methods instead of constructing REST requests directly:

```js
const grouped = await window.swarmSdk.assets.list({
  keyPrefix: "shared/releases/",
  types: "task,workflow,schedule,page,app,script,file",
  limit: 100,
});

await window.swarmSdk.assets.move("page", pageId, "shared/releases/");
```

The full domain also exposes `assets.audit()` and `assets.registerMapping(body)`. Those operations remain operator-only; the SDK does not weaken endpoint authentication or personal-namespace write checks.

Audit and rollout [#audit-and-rollout]

Operator-only `GET /api/assets/key-audit` checks all primary assets and mappings, including apps and scripts. Missing or noncanonical keys are structural failures. Unknown personal users, missing attachment mappings, and provider mapping drift are repairable warnings. Namespace moves are blocked while audit issues exist so operators can repair mapping state before introducing more drift.

The same audit runs after migrations during server startup. Structural failures stop startup by default. `ASSET_KEY_AUDIT_DISABLE_STARTUP_HARD_FAIL=true` is a temporary recovery switch that reports the failure without aborting; repair the database and remove the switch before normal operation.

Existing apps and scripts are backfilled to `shared/`; new rows receive their deterministic resource keys. Migration rollback is application-first: deploy the previous application version while leaving the additive columns, indexes, triggers, mapping table, and history table in place. Old code ignores them safely. Do not remove or rewrite the applied migration. If a later cleanup migration is required, take and verify a database backup first and ship it as a new forward-only migration.


# Claude Bridge (/docs/guides/claude-bridge-experimental)





Motivation [#motivation]

Starting **2026-06-15**, `claude -p` and the Claude Agent SDK / GitHub Actions surfaces draw from a **dedicated programmatic-credit pool** rather than the Max/Pro subscription quota ([@ClaudeDevs announcement, 2026-05-13](https://x.com/ClaudeDevs/status/2054610152817619388)). Interactive `claude` sessions stay on the subscription pool.

[`@desplega.ai/claude-bridge`](https://github.com/desplega-ai/claude-bridge) is a Desplega-owned drop-in replacement for common `claude -p` automation. It starts interactive Claude Code inside `tmux`, sends the prompt through the pane, tails Claude's JSONL transcript, and emits Claude-compatible `text`, `json`, or `stream-json` output. From the swarm's perspective, the usual Claude adapter flags still apply.

<Callout type="info">
  Prior art: Claude Bridge was inspired by Shannon's interactive-CLI bridge concept, but the supported path in agent-swarm is Desplega's `@desplega.ai/claude-bridge`.
</Callout>

How to opt in [#how-to-opt-in]

Set one reloadable env on the worker:

```bash
SWARM_USE_CLAUDE_BRIDGE=true
```

Accepted values:

| Value               | Behavior                                                               |
| ------------------- | ---------------------------------------------------------------------- |
| `true`, `1`         | Route the Claude adapter through the installed `claude-bridge` binary  |
| `false`, `0`, unset | Use the normal `CLAUDE_BINARY` resolution path, defaulting to `claude` |

When enabled, the swarm spawns:

```bash
claude-bridge -p "..." --model ... --output-format stream-json ...
```

You do not need to set `CLAUDE_BINARY` for claude-bridge. `SWARM_USE_CLAUDE_BRIDGE=true` wins over `CLAUDE_BINARY`.

Reloadable via swarm_config [#reloadable-via-swarm_config]

`SWARM_USE_CLAUDE_BRIDGE` participates in the same overlay-then-fallback pattern as `CLAUDE_BINARY` and `HARNESS_PROVIDER`. The runner fetches `swarm_config` on the poll loop and applies safe reloadable keys to `process.env`; each new Claude session also receives the resolved env overlay in `config.env`.

Precedence (highest first):

1. `swarm_config` `SWARM_USE_CLAUDE_BRIDGE` (scope: repo > agent > global)
2. `process.env.SWARM_USE_CLAUDE_BRIDGE` (container env)
3. disabled

Set it from your dashboard, the MCP `set-config` tool, or `PUT /api/config`:

```bash
curl -X PUT "$MCP_BASE_URL/api/config" \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"scope":"global","key":"SWARM_USE_CLAUDE_BRIDGE","value":"true"}'
```

In-flight task sessions stay on the binary they spawned with; new spawns pick up the change.

Requirements [#requirements]

* **Bun `>= 1.1`.** The npm package is `@desplega.ai/claude-bridge`; the published bin uses `#!/usr/bin/env bun`.
* **`tmux` on `PATH`.** The bridge starts interactive Claude Code in a detached tmux pane. The swarm fail-fasts at session-create when `tmux` is missing.
* **`claude` on `PATH`, version `>= 2.1.80`.** The bridge launches the local Claude CLI.
* **Auth: an OAuth token is required.** The bridge authenticates the spawned `claude` from `CLAUDE_CODE_OAUTH_TOKEN` only — it deliberately strips `ANTHROPIC_*` from the launched process. Bridge mode is therefore gated on an OAuth token: if `SWARM_USE_CLAUDE_BRIDGE` is set but no `CLAUDE_CODE_OAUTH_TOKEN` is present, the adapter logs a warning and falls back to stock `claude` (which Claude Code authenticates fine from `ANTHROPIC_API_KEY`). API-key billing is identical headless vs interactive, so the bridge buys nothing there.
* **Claude's on-disk transcript must stay enabled.** The bridge reconstructs *all* of its output — cost/token/duration metrics **and** the streamed assistant/tool-use events — by reshaping Claude Code's session transcript (`~/.claude/projects/<slug>/<sessionId>.jsonl`). Anything that suppresses that file breaks the bridge: notably `CLAUDE_CODE_SKIP_PROMPT_HISTORY=1` makes Claude Code emit only `init` + a null-metrics `result` ($0 / 0 tokens / 0ms), with answer text only via the inline `last_assistant_message`. The swarm's Claude runtime guardrails set that var; claude-bridge `>= 0.2.2` strips it from the launched `claude` so the transcript persists.

Prompt pre-clear [#prompt-pre-clear]

The swarm adapter pre-seeds Claude's global project trust/onboarding entries before it spawns Claude Bridge. Bridge mode launches interactive Claude Code inside `tmux`, so this prevents the first-run trust prompt from blocking or exiting before the bridge becomes ready.

Claude Bridge then writes per-workdir `.claude/settings.local.json` for dangerous-mode bypass, launches Claude with `--dangerously-skip-permissions`, and auto-accepts supported startup prompts from the tmux pane.

Legacy compatibility [#legacy-compatibility]

Existing legacy bridge deployments that set `CLAUDE_BINARY` directly continue to work:

```bash
CLAUDE_BINARY="<legacy-bridge-command>"
```

`CLAUDE_BINARY` is still whitespace-split into argv tokens and is still reloadable through `swarm_config`. When the resolved value matches the legacy bridge binary, the adapter:

* emits a deprecation warning pointing to `SWARM_USE_CLAUDE_BRIDGE=true`
* checks that `tmux` is on `PATH`
* runs the same `$HOME/.claude.json` trust pre-seed before spawning

Use this only as a compatibility path while migrating.

Why not a new `HARNESS_PROVIDER`? [#why-not-a-new-harness_provider]

Claude Bridge is a drop-in replacement for the `claude` binary inside the existing `claude` harness: same provider adapter, same MCP plumbing, same Stop-hook behavior, and the same spawned Claude flags. Keeping it as a reloadable env variant avoids a duplicate provider while still letting operators flip the subscription-pool bridge per worker or per repo.

See also: [`runbooks/harness-providers.md`](https://github.com/desplega-ai/agent-swarm/blob/main/runbooks/harness-providers.md) — engineering reference for the bridge and compatibility wiring.


# Cost & context computation (/docs/guides/cost-and-context-computation)



The swarm tracks two related but separate numbers for every model run:

1. **Cost (USD).** Each adapter writes one `session_costs` row per CLI invocation. The API may recompute it from the seeded pricing table.
2. **Context-window usage.** Each adapter emits `context_usage` events; the API persists snapshots and updates aggregate columns on `agent_tasks`.

This page is the single source of truth for how both numbers are produced.

New `session_costs` rows keep two USD values:

* `harnessCostUsd` is the adapter-reported number. It is useful for comparison,
  but advisory: adapters can have stale local rates or incomplete usage.
* `totalCostUsd` is the API's canonical stored total. For priced rows, the API
  recomputes it from the active server-side pricing table at the row timestamp;
  for the other `costSource` paths below, it intentionally equals the harness
  report.

How cost is computed [#how-cost-is-computed]

Cost flows through three layers, each annotated with the path's `costSource` enum value (the dashboard renders this as a small badge next to every cost).

1\. Adapter (worker-local) [#1-adapter-worker-local]

Every adapter emits a `CostData` event with its advisory local total, token breakdowns (`inputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `outputTokens`, `reasoningOutputTokens`, `thinkingTokens`), and a `provider` tag. The dollar value comes from whatever the harness reports — Claude's stream-json carries it directly; Codex doesn't, so the adapter computes locally via `computeCodexCostUsd` (`src/providers/codex-models.ts`); pi-ai self-reports `stats.cost`; etc.

The adapter writes via `POST /api/session-costs` with the `provider` field set.

2\. API recompute [#2-api-recompute]

When the API receives a `POST /api/session-costs` with a `provider` tag, it does a synchronous lookup against the `pricing` table for `(provider, model, token_class)` at the row's `createdAt`. Three outcomes:

| Outcome                                               | `costSource`      | Stored `totalCostUsd`      |
| ----------------------------------------------------- | ----------------- | -------------------------- |
| A tagged provider/model has the required pricing rows | `'pricing-table'` | Canonical server recompute |
| No `provider` tag supplied (legacy caller)            | `'harness'`       | Adapter report             |
| A tag was supplied but pricing cannot be completed    | `'unpriced'`      | Adapter report             |

`harnessCostUsd` always preserves the adapter's submitted `totalCostUsd`, even
when the server replaces `totalCostUsd` with the recomputed value.

Token classes, cache TTLs, and input semantics [#token-classes-cache-ttls-and-input-semantics]

Cached reads and cache creation use their own pricing token classes. For
Anthropic-billed models, `cache_write` is the 5-minute creation class (1.25×
the base input rate) and `cache_write_1h` is the one-hour class (2× the base
input rate). Claude reports both TTL totals. When a `modelBreakdown` entry
does not have its own TTL split, the recompute distributes its writes using the
session's 5m/1h ratio; this is an approximation for sidechains, not a claim
that each sidechain independently exposed TTL data.

Input counts have provider-specific cache-read semantics:

| Provider family                              | Meaning of reported `inputTokens` | Uncached-input calculation              |
| -------------------------------------------- | --------------------------------- | --------------------------------------- |
| `claude`, `claude-managed`, `pi`, `opencode` | Excludes cache reads              | `inputTokens` as reported               |
| `codex`                                      | Includes cache reads              | `max(0, inputTokens - cacheReadTokens)` |

Although OpenCode can route to OpenAI models, the currently shipped recompute
treats its input as disjoint from cache reads; production event evidence showed
that subtracting cache reads would zero most OpenCode input.

Per-model and request-priced usage [#per-model-and-request-priced-usage]

Claude's final `result.modelUsage` is preserved as `modelBreakdown`, including
sidechain/subagent entries. The API prices every entry at that entry's own
model rate, sums those totals, and stores the per-model computed `costUsd` in
the breakdown. Top-level stored token totals are the corresponding breakdown
sums when one exists, rather than only the main-thread usage. Because the
breakdown takes precedence, the adapter refuses to zero-fill it: a missing,
non-finite, or negative token counter on any entry drops the whole breakdown
and the session falls back to top-level usage (advisory fields like
`webSearchRequests` and per-model `costUSD` degrade per-field instead).

`web_search` is a request-priced class, not a token rate. The manual rows for
`claude` and `claude-managed` encode Anthropic's $10 per 1,000 requests
($0.01/request); request counts in a model breakdown are added to that
model's token cost. A missing web-search rate is treated as $0 so a small
search fee cannot discard an otherwise complete token recompute.

`claude-managed` also adds its $0.08/session-hour `runtime_hour` fee during
the server recompute, using the session duration and the manual `runtime_hour`
pricing row.

3\. UI badge [#3-ui-badge]

The task-detail and task-detail-sheet views render the `costSource` next to every cost via `<CostSourceBadge>`. Mixed sources within a task aggregate render as `HARNESS` (the weakest claim).

When both USD values are available and differ, the badge tooltip shows harness
and recomputed numbers. The task cost view adds a visible `Δ` hint above 2%.
The OpenTelemetry counter `agentswarm.cost.drift.usd` records the absolute
non-zero difference with a `drift_sign` attribute, making it the operational
watchdog for stale adapter-local pricing or recompute drift.

Attribution coverage and autonomous work [#attribution-coverage-and-autonomous-work]

The usage summary separates total spend from spend that could truthfully be
assigned to a person. `attributableCostUsd` is `totalCostUsd` minus the cost of
structurally human-free work. Attribution coverage is therefore
`attributedCostUsd / attributableCostUsd`, not attributed cost divided by all
spend. The response also exposes `excludedCostUsd` and `excludedTaskCount` so
the autonomous population remains visible rather than disappearing from the
report.

A task is structurally human-free when its stored task type is `heartbeat`,
`heartbeat-checklist`, or `boot-triage`; when its stored JSON tags contain the
`heartbeat` tag (including legacy rows found by the tags `LIKE` check); or when
it is launched by a schedule with no human creator. The schedule rule covers
both direct scheduled tasks and workflow roots whose run records that
creatorless schedule in its trigger data. Requester-less `system` follow-ups of
requester-less parents are also autonomous.

That classification follows the task tree recursively while descendants have
no human requester. This keeps autonomous fan-out out of the denominator, but
an explicitly attributed child is treated as a human handoff and stops the
classification along that branch. Structurally human-free rows are excluded
from `attributedCostUsd` even if an old or inherited requester id remains on
the row, keeping the numerator and denominator a consistent partition.

The **By Person** view uses the same requester data model, but it is not a
grouping of the cost denominator and does not run the coverage CTE. It reports
work outcomes rather than a cost score: human-requested root tasks supply
Problems Initiated and Problems Shipped, while each person's full task trees
supply Agents Reached, Repos Reached, and Surfaces Reached. Requester-less
autonomous roots and heartbeat-classified roots do not belong to a person. The
metrics stay side by side and are never summed into a composite ranking.

How the pricing table is populated [#how-the-pricing-table-is-populated]

The `pricing` table starts with a boot seed from the vendored models.dev snapshot at `src/be/modelsdev-cache.json` plus a small set of manual overrides for items models.dev doesn't carry. The committed snapshot is now fallback-only for pricing freshness: it gives a cold-start DB usable rows when models.dev is unavailable, while `src/be/pricing-refresh.ts` owns live price updates.

After boot, the API server runs an in-process models.dev refresher once immediately and then every 12 hours. It fetches `https://models.dev/api.json` with `If-None-Match`, projects the response through the same `buildModelsDevSeedRows()` logic, inserts a new effective row only for new models or changed prices, and prunes history to the latest two rows per `(provider, model, token_class)`.

The same refresh also feeds the runtime model catalog at `GET /api/models-catalog` (`src/be/models-catalog.ts`) — a slim projection of the picker-reachable providers. The UI model picker prefers that live catalog, so newly released models appear without redeploying; the committed snapshot (still symlinked at `ui/src/lib/modelsdev-cache.json`) remains the build-time fallback for names, labels, and context windows while the request is in flight or the server predates the endpoint.

* **Projection rules** live in `src/be/seed-pricing.ts`:
  * Anthropic models → rows under both `provider='claude'` AND `provider='claude-managed'`. Shortnames (`opus`/`sonnet`/`haiku`/`fable`/`mythos`) also land under the current default full id; Fable 5.1 and Mythos 5.1 use verified fallback rates until the vendored models.dev snapshot includes them.
  * OpenAI models → `provider='codex'`.
  * OpenRouter models → `provider='opencode'`; `google/*` models also land under `provider='gemini'`.
* **Manual overrides** (claude-managed `runtime_hour` at $0.08/hr, devin `acu` at $2.25): `MANUAL_PRICING_OVERRIDES` in the same file. Each entry carries its source URL and a `verified` date.
* **Runtime refresh**: `src/be/pricing-refresh.ts` updates pricing rows in-place after boot and every 12 hours. It only adds newer effective rows and never deletes pinned entries from the committed snapshot.
* **Snapshot refresh procedure**: run `bun run scripts/refresh-modelsdev-pricing.ts` when the committed fallback/UI catalog needs a source update. Commit it alongside the PR.

Operator reference: [`src/providers/pricing-sources.md`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/pricing-sources.md).

How context-window usage is computed [#how-context-window-usage-is-computed]

The unified formula [#the-unified-formula]

After Phase 9, every adapter uses one formula:

```
contextUsedTokens = inputTokens + cacheReadTokens + cacheCreateTokens + outputTokens
```

Helpers: `computeContextUsedUnified` and `clampContextPercent` in `src/utils/context-window.ts`. The emitted event carries `contextFormula: 'input-cache-output'`.

Pi-mono is the exception: pi-ai owns the formula and we just relay its numbers. Those snapshots are tagged `contextFormula: 'pi-delegated'`. Devin's API doesn't report context info at all; we omit the event rather than fake zeros.

Per-model window resolution [#per-model-window-resolution]

`getContextWindowSize(model)` resolves:

* Shortnames (`opus`/`sonnet`/`haiku`/`fable`/`mythos`)
* Family-versioned ids (`claude-sonnet-4-6`)
* Claude 5.1 premium ids (`claude-fable-5-1`/`claude-mythos-5-1`)
* Dated full ids (`claude-sonnet-4-6-20251004`) — by stripping the 8-digit date suffix and retrying

Fallback is 200k. Pre-Phase 4 the dated form fell to 200k unconditionally — wildly wrong for opus/sonnet 4.x.

peakContextTokens and contextWindowSize [#peakcontexttokens-and-contextwindowsize]

`agent_tasks.peakContextTokens` (renamed from `totalContextTokensUsed` in migration 063) is a monotonic max across all snapshots for the task — never regresses when a later snapshot reports a smaller value. This mirrors Claude Code's status-line "peak context" idea.

`agent_tasks.contextWindowSize` is set on the FIRST snapshot that carries one, not gated on `eventType='completion'`. Subsequent snapshots leave it alone.

Per-provider notes [#per-provider-notes]

* **claude / claude-managed**: token rates from models.dev. claude-managed also has a per-session-hour runtime fee (`token_class='runtime_hour'`); the worker computes a preview locally via `claude-managed-pricing.ts`, and the API's recompute path overrides with the canonical value.
* **codex**: the app-server reports cumulative usage counters. On each terminal turn, including failed and interrupted turns, the adapter converts the latest total to a delta before it updates `CostData` or context usage. This prevents the same usage from being charged again after a later turn. Context uses the reported input total plus output tokens. Cache writes are input details. Codex 0.153.4 reports cache-write tokens separately, and the adapter includes them in `CostData`.
* **pi-mono**: cost passes through verbatim from pi-ai's `stats.cost`. Context snapshots tag `contextFormula: 'pi-delegated'`. `durationMs` is now real wallclock (was hardcoded 0). Per-turn `outputTokens` are derived from session-stats delta.
* **opencode**: passthrough through OpenRouter. The unified formula applies; `contextPercent` is clamped to \[0, 100].
* **devin**: ACU-based pricing (`token_class='acu'`, $2.25 per ACU). No per-token cost. No context events (the API doesn't report context info) — `peakContextTokens` remains `null` for devin tasks.

Gotchas & known limitations [#gotchas--known-limitations]

* **Internal-ai Gemini calls are not yet costed.** `src/utils/internal-ai/models.ts:19-25` routes through OpenRouter for summarization/rating but doesn't yet emit `session_costs` rows. The pricing table now has `gemini` rows ready; instrumentation is a follow-up.
* **Codex context is usage-delta based.** The app-server counters represent cumulative usage. The adapter emits differences between reports, rather than a peak context size. Old `peak-proxy` rows in `task_context_snapshots` remain correct for their original formula and are not directly comparable to current `input-cache-output` rows.
* **Model-id key mismatch.** Some adapters use harness-prefixed ids (`openai-codex/gpt-5.4-mini`); pricing-table seeds use the stripped form (`gpt-5.4-mini`). Pick one convention if you're adding a new mapping.
* **Timestamp convention split.** `session_costs.createdAt` and `task_context_snapshots.createdAt` are TEXT ISO 8601; `pricing.effective_from` / `budgets.createdAt` are INTEGER epoch-ms. Documented in `046_budgets_and_pricing.sql:17-22`; not a near-term cleanup.

Related docs [#related-docs]

* [Harness providers](./harness-providers) — provider-specific quirks
* [`src/providers/pricing-sources.md`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/pricing-sources.md) — operator workflow
* [`BUSINESS_USE.md`](https://github.com/desplega-ai/agent-swarm/blob/main/BUSINESS_USE.md) — flow diagrams for `task` / `agent` / `api` events


# Deployment Guide (/docs/guides/deployment)



Docker Compose (Recommended) [#docker-compose-recommended]

The easiest way to deploy a full swarm with API, workers, and lead agent. You can go from zero to a running swarm in under 5 minutes.

Prerequisites [#prerequisites]

Before you start, make sure you have:

* **Docker & Docker Compose** installed ([install guide](https://docs.docker.com/get-docker/))
* **A Claude Code OAuth token** — run `claude setup-token` in your terminal to get one
* **An API key** — any secret string you choose (all services share this key for authentication)

Step 1: Download the Compose File [#step-1-download-the-compose-file]

```bash
curl -O https://raw.githubusercontent.com/desplega-ai/agent-swarm/main/docker-compose.example.yml
mv docker-compose.example.yml docker-compose.yml
```

Or if you have the repo cloned:

```bash
cp docker-compose.example.yml docker-compose.yml
```

Step 2: Create Your `.env` File [#step-2-create-your-env-file]

Create a `.env` file in the same directory as `docker-compose.yml`:

```bash title=".env"
# ---- Required ----
API_KEY=your-secret-api-key
CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token   # Run `claude setup-token` to get this
SECRETS_ENCRYPTION_KEY=                     # Run `openssl rand -base64 32` and paste here. See "Encryption Key" below.

# Stable agent IDs — run `uuidgen` once for each value and paste it here.
# Keep every value unique and stable across restarts.
LEAD_AGENT_ID=
WORKER_1_AGENT_ID=
WORKER_2_AGENT_ID=
CONTENT_WRITER_AGENT_ID=
CONTENT_REVIEWER_AGENT_ID=
CONTENT_STRATEGIST_AGENT_ID=
UX_PRINCIPLES_AGENT_ID=
DISCOVERABILITY_AGENT_ID=

# ---- Optional ----
GITHUB_TOKEN=ghp_xxxx                      # For git operations inside agents
GITHUB_EMAIL=you@example.com
GITHUB_NAME=Your Name
SWARM_URL=localhost                         # Base domain for service discovery
```

<Callout type="info">
  You can pass multiple OAuth tokens for load balancing: `CLAUDE_CODE_OAUTH_TOKEN=token1,token2,token3`
</Callout>

<Callout type="info">
  If you leave `SECRETS_ENCRYPTION_KEY` blank on a brand-new deploy, the API will auto-generate one and write it to the `swarm_api` volume at `.encryption-key`. For production, set it explicitly so you control where it lives and can back it up alongside your other secrets. See [Encryption Key](#encryption-key).
</Callout>

Step 3: Generate Stable Agent IDs [#step-3-generate-stable-agent-ids]

Each agent needs a stable UUID that persists across restarts. This is critical for task resume — if an agent restarts, it uses its `AGENT_ID` to pick up paused tasks.

The example compose file reads the named values from `.env` and stops with a clear error if one is missing. Generate one unique UUID per agent and paste the output into the corresponding variable above:

```bash
# Generate all required `*_AGENT_ID=value` lines, then paste them into .env.
for name in LEAD_AGENT_ID WORKER_1_AGENT_ID WORKER_2_AGENT_ID CONTENT_WRITER_AGENT_ID CONTENT_REVIEWER_AGENT_ID CONTENT_STRATEGIST_AGENT_ID UX_PRINCIPLES_AGENT_ID DISCOVERABILITY_AGENT_ID; do
  printf '%s=%s\n' "$name" "$(uuidgen)"
done
```

Step 4: Start the Swarm [#step-4-start-the-swarm]

```bash
docker compose up -d
```

The API service starts first. Workers and lead wait until the API health check passes before starting (via `depends_on: condition: service_healthy`).

Step 5: Verify It's Running [#step-5-verify-its-running]

```bash
# Check all services are up
docker compose ps

# Check API health
curl http://localhost:3013/health

# List registered agents (replace YOUR_API_KEY with your actual key)
curl -s -H "Authorization: Bearer YOUR_API_KEY" \
  http://localhost:3013/api/agents | jq '.agents[] | {name, status, isLead}'
```

You should see the lead and workers listed with `status: "idle"`.

<Callout type="info">
  The bundled worker image already includes the Ubuntu runtime libraries required by Playwright's Chromium binary. Browser-driven tasks such as `agent-browser` sessions, Playwright smoke tests, and screenshot workflows no longer need a per-agent `apt install` workaround just to launch the bundled browser.
</Callout>

Startup-script privilege boundary [#startup-script-privilege-boundary]

Since v1.106.0, per-agent `setupScript` / `/workspace/start-up.*` hooks run as the non-root `worker` user after the container drops privileges, and the worker image no longer grants blanket passwordless sudo. This was a security hardening change for the setup-script privilege boundary.

Use per-agent setup for project-local work such as `bun install`, generated config, cache warmup, repo bootstrap, or worker-owned global installs like `bun i -g`. For npm global installs, set an npm prefix under `$HOME` before installing. Move root-requiring steps such as Debian/Ubuntu package installs, writes under `/usr/lib`, service ownership changes, or local database bootstrap into the worker image, the admin-controlled global `SETUP_SCRIPT` config, or the built-in optional service toggles.

During the privileged bootstrap stage, the entrypoint may seed credentials, settings, and skills under `/home/worker/.claude`. Before launching the harness it recursively restores ownership of that directory to the `worker` user so the harness can create and write `.claude/session-env`; operators do not need a separate ownership workaround for those generated files.

`STARTUP_SCRIPT_STRICT` defaults to `false`, so a failed per-agent startup script logs a migration warning and the worker continues booting. Set `STARTUP_SCRIPT_STRICT=true` if you want startup-script failures to stop the container.

<Callout type="info">
  If you are deploying **Codex** workers with ChatGPT OAuth instead of `OPENAI_API_KEY`, follow [Provider Auth: Codex OAuth](/docs/guides/provider-auth/codex-oauth) after the API is up, then restart those workers.
</Callout>

***

What's Included [#whats-included]

The example `docker-compose.yml` sets up:

| Service                       | Role                        | Port   | Template                      |
| ----------------------------- | --------------------------- | ------ | ----------------------------- |
| **api**                       | MCP HTTP server + SQLite DB | `3013` | —                             |
| **lead**                      | Coordinator agent           | `3020` | `official/lead`               |
| **worker-1**                  | Task executor               | `3021` | `official/coder`              |
| **worker-2**                  | Task executor               | `3022` | `official/coder`              |
| **worker-content-writer**     | Content specialist          | `3026` | `official/content-writer`     |
| **worker-content-reviewer**   | Content reviewer            | `3027` | `official/content-reviewer`   |
| **worker-content-strategist** | Content strategist          | `3028` | `official/content-strategist` |

The content agents are optional — remove them from `docker-compose.yml` if you don't need content workflows.

<Callout type="info">
  The example Compose files also include a co-deployed MinIO + agent-fs stack for persistent shared files and dashboard task attachments. See [agent-fs Co-deployment](/docs/guides/agent-fs-co-deployment) for Helm and Compose configuration, migration notes, and verification commands.
</Callout>

***

Volumes & Persistence [#volumes--persistence]

The swarm uses Docker named volumes to persist data across restarts and upgrades. Getting volumes right is essential — without them, you lose your database and agent workspaces on every restart.

Volume Architecture [#volume-architecture]

```
Docker Volume            → Container Path        → What It Stores
─────────────────────────────────────────────────────────────────────
swarm_api                → /app                  → SQLite DB (all swarm state)
swarm_logs               → /logs                 → Session logs (all agents)
swarm_shared             → /workspace/shared     → Shared workspace (all agents)
swarm_lead               → /workspace/personal   → Lead's private workspace
swarm_worker_1           → /workspace/personal   → Worker 1's private workspace
swarm_worker_2           → /workspace/personal   → Worker 2's private workspace
```

What Each Volume Stores [#what-each-volume-stores]

| Volume          | Critical? | Backup?     | Description                                                                                                                                                              |
| --------------- | --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `swarm_api`     | **Yes**   | **Yes**     | Contains the SQLite database (`agent-swarm-db.sqlite`) with all tasks, agents, schedules, and configuration. Losing this = losing all swarm state.                       |
| `swarm_logs`    | No        | Optional    | Session logs from all agents. Useful for debugging. Can be recreated.                                                                                                    |
| `swarm_shared`  | Moderate  | Recommended | Shared workspace. Agents store research, plans, and memory under `/workspace/shared/{thoughts,memory,downloads,misc}/$AGENT_ID`. All agents can read each other's files. |
| `swarm_<agent>` | Low       | No          | Personal workspace per agent (`/workspace/personal`). Isolated — only that agent can see it. Contains cloned repos, working files, and agent-specific state.             |

How Workspaces Work [#how-workspaces-work]

Each agent container has two workspace directories:

* **`/workspace/shared`** — Mounted from `swarm_shared`. All agents share this volume. By convention, each agent writes only to its own subdirectory (e.g., `/workspace/shared/thoughts/<agent-id>/`) but can read from any agent's directory. This is how agents share research, plans, and context.

* **`/workspace/personal`** — Mounted from a per-agent volume (e.g., `swarm_worker_1`). Only that agent can see this. Used for cloned git repos, working files, and private state.

Backing Up the Database [#backing-up-the-database]

<Callout type="warn">
  The `swarm_api` volume contains your SQLite database — the single source of truth for all swarm state. Back it up regularly.
</Callout>

```bash
# Back up the database
docker run --rm -v swarm_api:/app -v $(pwd):/backup alpine \
  cp /app/agent-swarm-db.sqlite /backup/agent-swarm-db-backup.sqlite

# Restore from backup
docker compose down
docker run --rm -v swarm_api:/app -v $(pwd):/backup alpine \
  cp /backup/agent-swarm-db-backup.sqlite /app/agent-swarm-db.sqlite
docker compose up -d
```

<h3 id="database-retention">
  Database retention
</h3>

`SESSION_LOG_RETENTION_DAYS`, `AGENT_LOG_RETENTION_DAYS`, and `EVENTS_RETENTION_DAYS` are disabled until you set them. Each value permanently deletes rows older than its window. Start with `DB_RETENTION_DRY_RUN=true`, confirm the exact would-delete count via the `agentswarm.db.retention.backlog` metric and `GET /api/metrics`, then enable one table at a time. See the [database-retention runbook](https://github.com/desplega-ai/agent-swarm/blob/main/runbooks/db-retention.md) before activation.

Three settings tune the sweep itself, all read fresh on every tick with no restart required:

* `DB_RETENTION_TICK_BUDGET_MS` (default `30000`, accepts `1000`–`300000`) — wall-clock budget for one sweep tick, shared evenly across enabled tables.
* `DB_RETENTION_CATCHUP_INTERVAL_MS` (default `60000`, accepts `5000`–`3600000`) — delay before the next tick when a table is still undrained. The normal cadence stays hourly once every enabled table is drained.
* `DB_RETENTION_MAX_STATEMENT_MS` (default `250`, accepts `25`–`5000`) — target ceiling for one DELETE statement, measured as driver execution time; the adaptive batch sizer tunes its batch size against this.

Each range is enforced in both directions. A value outside it is rejected by the config API, and a value set directly in the environment falls back to the default rather than taking effect.

<Callout type="warn">
  A database backup is useless without the matching [encryption key](#encryption-key). Back up both together.
</Callout>

***

Encryption Key [#encryption-key]

Agent Swarm encrypts all `swarm_config` rows marked as secrets (OAuth tokens, API keys, webhook signing secrets) at rest using AES-256-GCM. The master key is resolved on every boot in this order:

1. `SECRETS_ENCRYPTION_KEY` env var (recommended for production)
2. `SECRETS_ENCRYPTION_KEY_FILE` env var (path to a file containing the key — useful with Docker secrets or k8s `Secret` volume mounts)
3. `<data-dir>/.encryption-key` file on the API's data volume
4. Auto-generated on first boot **only** when the database does not yet contain any encrypted secret rows. Existing databases with encrypted rows fail closed if no key is found, instead of silently generating a different key and orphaning your secrets.

Generating a key [#generating-a-key]

Use either format — both are accepted everywhere (env var, file, on-disk):

```bash
# Recommended: 43-character base64 string
openssl rand -base64 32

# Equivalent: 64-character hex string
openssl rand -hex 32
```

<Callout type="warn">
  **Do NOT use `openssl rand -base64 39`** (or any size other than 32). That produces a 52-character string that decodes to 39 bytes, which the server will reject at boot with `Invalid encryption key ... got 39 bytes after base64 decode`. The number passed to `openssl rand` is the decoded byte count, and AES-256 requires exactly 32.
</Callout>

Backing up the key [#backing-up-the-key]

The encryption key is **just as critical as your database backup**. Losing the key while keeping the database means you lose every encrypted secret with no recovery path — you would have to manually re-add every OAuth token, API key, and webhook secret in the swarm.

**Back up both together**, every time:

```bash
# Back up DB and key in one shot (Docker Compose deployment)
docker run --rm -v swarm_api:/app -v $(pwd):/backup alpine sh -c '
  cp /app/agent-swarm-db.sqlite /backup/agent-swarm-db-backup.sqlite &&
  cp /app/.encryption-key /backup/encryption-key-backup 2>/dev/null || true
'
```

If you set `SECRETS_ENCRYPTION_KEY` via env var instead, store the value itself in your secrets manager (1Password, Vault, AWS Secrets Manager, etc.) — treat it with the same rigor as your database backup.

Common mistakes [#common-mistakes]

| Mistake                           | Symptom                                                    | Fix                                                                                                           |
| --------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Used `openssl rand -base64 39`    | `got 39 bytes after base64 decode` on boot                 | Regenerate with `-base64 32` (or `-hex 32`)                                                                   |
| Wrapped value in quotes in `.env` | Same as above (extra characters change the decoded length) | Remove the quotes — `.env` lines are `KEY=value`, no quoting needed                                           |
| Trailing newline or whitespace    | Same as above                                              | The server trims surrounding whitespace, but if your secrets manager injected embedded characters, regenerate |
| Changed key between deploys       | Decryption errors when reading existing secrets            | Restore the original key — key rotation is not yet supported (planned)                                        |
| Lost the key entirely             | Decryption errors on every secret read                     | Manually delete encrypted rows via the dashboard or `swarm_config` API and re-add them under the new key      |

<Callout type="info">
  **Reserved key names**: `SECRETS_ENCRYPTION_KEY` and `API_KEY` are blocked from being stored in the DB config store (HTTP, MCP, and direct DB layers all reject them, case-insensitive). They must come from the environment.
</Callout>

First-time migration from plaintext secrets [#first-time-migration-from-plaintext-secrets]

If you upgraded from a pre-1.67 deploy that stored secrets in plaintext **without** setting `SECRETS_ENCRYPTION_KEY` ahead of time, the API auto-generates a `.encryption-key` on the data volume and writes a one-time plaintext backup at `<db-path>.backup.secrets-YYYY-MM-DD.env` before encrypting the existing rows. **Delete that backup file immediately after verifying your new encryption key is safely backed up** — it contains every secret in plaintext.

***

Environment Variables [#environment-variables]

These are the key variables for Docker Compose deployment. For the complete reference, see [Environment Variables](/docs/reference/environment-variables).

Required (in `.env`) [#required-in-env]

| Variable                  | Description                                                                                                                         |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `API_KEY`                 | Shared authentication key between API and agents                                                                                    |
| `CLAUDE_CODE_OAUTH_TOKEN` | OAuth token from `claude setup-token`. Supports comma-separated values for load balancing.                                          |
| `SECRETS_ENCRYPTION_KEY`  | Master key for encrypting `swarm_config` secrets at rest. See [Encryption Key](#encryption-key) for generation and backup guidance. |

Per-Agent (in `docker-compose.yml`) [#per-agent-in-docker-composeyml]

| Variable       | Description                                                                                         |
| -------------- | --------------------------------------------------------------------------------------------------- |
| `AGENT_ID`     | Stable UUID. **Keep the same across restarts** for task resume.                                     |
| `AGENT_ROLE`   | `lead` or `worker`                                                                                  |
| `TEMPLATE_ID`  | Template for initial profile (e.g., `official/coder`, `official/lead`). Applied on first boot only. |
| `MCP_BASE_URL` | API server URL. Use `http://api:3013` when in the same Docker network.                              |

Optional (in `.env`) [#optional-in-env]

| Variable          | Description                                              |
| ----------------- | -------------------------------------------------------- |
| `GITHUB_TOKEN`    | Personal access token for git operations                 |
| `GITHUB_EMAIL`    | Git commit email                                         |
| `GITHUB_NAME`     | Git commit name                                          |
| `SWARM_URL`       | Base domain for service discovery (default: `localhost`) |
| `SLACK_BOT_TOKEN` | Enable Slack integration (also needs `SLACK_APP_TOKEN`)  |
| `SLACK_DISABLE`   | Set to `true` to disable Slack (default: `false`)        |

Secrets Encryption [#secrets-encryption]

Starting with `v1.67.0`, `swarm_config` secrets are encrypted at rest using AES-256-GCM. The `docker-compose.example.yml` includes a Docker secrets block for the encryption key:

```bash
# Generate the key file (one-time)
openssl rand -base64 32 > ./encryption_key
chmod 600 ./encryption_key
```

The compose file mounts this as a Docker secret at `/run/secrets/encryption_key` and sets `SECRETS_ENCRYPTION_KEY_FILE` accordingly. If you prefer an inline env var, use `SECRETS_ENCRYPTION_KEY` instead.

<Callout type="warn">
  **Back up the encryption key alongside your SQLite database.** Losing it means losing all encrypted secrets with no recovery path. See [Environment Variables — Secrets Encryption](/docs/reference/environment-variables#secrets-encryption) for details.
</Callout>

***

Adding More Workers [#adding-more-workers]

To scale the swarm, copy an existing worker block in `docker-compose.yml`:

1. Give it a new service name (e.g., `worker-3`)
2. Generate a new `AGENT_ID` UUID: `uuidgen`
3. Pick a new host port (e.g., `3023:3000`)
4. Add a new personal volume (e.g., `swarm_worker_3:/workspace/personal`)
5. Declare the new volume at the bottom of the file under `volumes:`

```yaml title="docker-compose.yml (add a new worker)"
  worker-3:
    image: "ghcr.io/desplega-ai/agent-swarm-worker:latest"
    platform: linux/amd64
    pull_policy: always
    stop_grace_period: 60s
    depends_on:
      api:
        condition: service_healthy
    environment:
      - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN}
      - API_KEY=${API_KEY}
      - AGENT_ID=YOUR-NEW-UUID-HERE
      - AGENT_ROLE=worker
      - TEMPLATE_ID=official/coder
      - MCP_BASE_URL=http://api:3013
      - YOLO=true
      - GITHUB_TOKEN=${GITHUB_TOKEN:-}
      - GITHUB_EMAIL=${GITHUB_EMAIL:-}
      - GITHUB_NAME=${GITHUB_NAME:-}
      - SWARM_URL=${SWARM_URL:-localhost}
    ports:
      - "3023:3000"
    volumes:
      - swarm_logs:/logs
      - swarm_shared:/workspace/shared
      - swarm_worker_3:/workspace/personal
    restart: unless-stopped

volumes:
  # ... existing volumes ...
  swarm_worker_3:
```

<Callout type="warn">
  If using `HARNESS_PROVIDER=pi` for this worker, **do not** include `CLAUDE_CODE_OAUTH_TOKEN` — pass `OPENROUTER_API_KEY` or `ANTHROPIC_API_KEY` instead. Claude credentials in the environment will override the pi-mono provider. See [Harness Configuration](/docs/guides/harness-configuration).
</Callout>

Then:

```bash
docker compose up -d worker-3
```

***

ARM Compatibility (Apple Silicon) [#arm-compatibility-apple-silicon]

All services include `platform: linux/amd64` to avoid `no matching manifest for linux/arm64/v8` errors on Apple Silicon Macs. The Docker images are built for `linux/amd64` and run via Rosetta emulation.

***

Graceful Shutdown & Task Resume [#graceful-shutdown--task-resume]

The docker-compose example uses `stop_grace_period: 60s` to allow graceful task pause during deployments. When a container receives SIGTERM:

1. In-progress tasks are **paused** (not failed)
2. Task state and progress are preserved
3. After restart, paused tasks are automatically **resumed** with context

Configuration [#configuration]

```bash
# Grace period before force-pausing tasks (milliseconds)
SHUTDOWN_TIMEOUT=30000

# Docker compose stop grace period (should be >= SHUTDOWN_TIMEOUT + buffer)
stop_grace_period: 60s
```

Resume Behavior [#resume-behavior]

When a worker starts, it:

1. Registers with the MCP server
2. Checks for paused tasks assigned to its `AGENT_ID`
3. Resumes each paused task with original context and progress

Best Practices [#best-practices]

* **Use stable Agent IDs** — Set explicit `AGENT_ID` for each worker
* **Save progress regularly** — Workers should call `store-progress` during long tasks
* **Test deployments** — Verify tasks resume correctly in staging first

***

Docker Worker (Standalone) [#docker-worker-standalone]

Run individual Claude workers in containers without Compose.

Pull from Registry [#pull-from-registry]

```bash
docker pull ghcr.io/desplega-ai/agent-swarm-worker:latest
```

Build Locally [#build-locally]

```bash
docker build -f Dockerfile.worker -t agent-swarm-worker .

# Override the pinned Claude Code version (default: 2.1.246)
docker build -f Dockerfile.worker --build-arg CLAUDE_CODE_VERSION=2.2.0 -t agent-swarm-worker .
```

Current worker-image defaults in `Dockerfile.worker`:

* `CLAUDE_CODE_VERSION=2.1.246`
* `PI_CODING_AGENT_VERSION=0.84.3`
* `CODEX_VERSION=0.153.4`
* `OPENCODE_VERSION=1.18.23`
* `OPENCODE_SDK_VERSION=1.18.23`

The image also sets `DISABLE_AUTOUPDATER=1` so Claude Code stays on the pinned version instead of self-updating at runtime.

The worker image also includes PostgreSQL 16 server binaries for local test setups. Nothing auto-starts unless you set `SWARM_DEP_POSTGRES_ENABLED=true`; in that case the root-stage entrypoint runs [`scripts/init-local-postgres.sh`](https://github.com/desplega-ai/agent-swarm/blob/main/scripts/init-local-postgres.sh) before the worker privilege drop, then starts the cluster as the `worker` user on `localhost:5433` by default. Override with `LOCAL_POSTGRES_DATA_DIR`, `LOCAL_POSTGRES_PORT`, `LOCAL_POSTGRES_USER`, `LOCAL_POSTGRES_PASSWORD`, and `LOCAL_POSTGRES_DB` as needed.

For local Redis, set `SWARM_DEP_REDIS_ENABLED=true`; the root-stage entrypoint runs [`scripts/init-local-redis.sh`](https://github.com/desplega-ai/agent-swarm/blob/main/scripts/init-local-redis.sh) before privilege drop and starts `redis-server` as the `worker` user.

Both `Dockerfile` and `Dockerfile.worker` now copy the repository `templates/` directory into the image, so system-default skills and templates are available inside compiled deployments without an extra post-build sync step.

The worker image also bundles `/usr/local/bin/install-repo-hooks.sh`. If a registered repo is configured with `hooks: { enabled: true }`, the runner calls that helper after clone/refresh so git hooks are installed automatically inside the worker checkout.

Run [#run]

```bash
docker run --rm -it \
  -e CLAUDE_CODE_OAUTH_TOKEN=your-token \
  -e API_KEY=your-api-key \
  -v ./logs:/logs \
  -v ./work:/workspace \
  ghcr.io/desplega-ai/agent-swarm-worker
```

With Custom System Prompt [#with-custom-system-prompt]

```bash
docker run --rm -it \
  -e CLAUDE_CODE_OAUTH_TOKEN=your-token \
  -e API_KEY=your-api-key \
  -e SYSTEM_PROMPT="You are a Python specialist" \
  -v ./logs:/logs \
  -v ./work:/workspace \
  ghcr.io/desplega-ai/agent-swarm-worker
```

***

Server Deployment (systemd) [#server-deployment-systemd]

Deploy the MCP server to a Linux host with systemd.

Prerequisites [#prerequisites-1]

* Linux with systemd
* Bun installed (`curl -fsSL https://bun.sh/install | bash`)

Install [#install]

```bash
git clone https://github.com/desplega-ai/agent-swarm.git
cd agent-swarm
sudo bun deploy/install.ts
```

This will:

* Copy files to `/opt/agent-swarm`
* Create `.env` file (edit to set `API_KEY`)
* Install systemd service with health checks every 30s
* Start the service on port 3013

Management [#management]

```bash
sudo systemctl status agent-swarm    # Check status
sudo journalctl -u agent-swarm -f    # View logs
sudo systemctl restart agent-swarm   # Restart
sudo systemctl stop agent-swarm      # Stop
```

Update [#update]

```bash
git pull
sudo bun deploy/update.ts
```

***

Related [#related]

* [Getting Started](/docs/getting-started) — Initial setup and first deployment
* [Environment Variables](/docs/reference/environment-variables) — All configuration options
* [Architecture Overview](/docs/architecture/overview) — How the system components fit together
* [Task Lifecycle](/docs/concepts/task-lifecycle) — Graceful shutdown and task resume behavior


# E2B Provider Smoke Tests (/docs/guides/e2b-provider-smoke-tests)



Use E2B provider smoke tests when the swarm API is already deployed in an
environment that cannot run Docker, such as a Kubernetes pod, but you still need
to verify that real workers can execute tasks through each harness provider.

The deployed API should not build worker images. Build and publish an E2B worker
template in CI or during release, then have the deployed swarm launch short-lived
worker sandboxes from that template and point them back at the live API.

<Callout type="info">
  For provider credentials and `HARNESS_PROVIDER` values, see
  [Harness Configuration](/docs/guides/harness-configuration). For production
  deployment basics, see [Deployment Guide](/docs/guides/deployment).
</Callout>

What this verifies [#what-this-verifies]

A provider smoke test should prove that the full task execution path works:

* E2B can launch a worker sandbox from the expected template.
* The worker can reach the deployed swarm API over `MCP_BASE_URL`.
* The worker registers with a deterministic `AGENT_ID`.
* A task assigned to that worker reaches `completed`.
* The task records a provider session id, output, logs, and, when supported,
  cost data.
* The E2B sandbox is killed after the check.

This is intentionally stronger than an API-only task creation check. It runs a
real harness, against a real agent, using the same runner path as normal work.

Build once, launch many times [#build-once-launch-many-times]

There are two separate jobs:

| Job                    | Where it runs                    |                              Docker required? | Purpose                                                          |
| ---------------------- | -------------------------------- | --------------------------------------------: | ---------------------------------------------------------------- |
| Build/publish template | CI or release workflow           | Yes for image build, no for E2B `fromImage()` | Produce an E2B worker template for a specific image or SHA.      |
| Start smoke worker     | Deployed API pod or CI smoke job |                                            No | Launch an E2B sandbox from the prebuilt template and run a task. |

The key invariant is version alignment: the deployed API and the E2B worker
template should come from the same image tag, release version, or commit SHA.
Testing a deployed API against a floating `latest` worker template can produce
misleading results.

Template publishing [#template-publishing]

CI should publish a worker template from the same registry image that will run in
production:

```bash
bun run src/cli.tsx e2b build-template \
  --role worker \
  --source image \
  --image ghcr.io/desplega-ai/agent-swarm-worker:${GITHUB_SHA} \
  --worker-template agent-swarm-worker-${GITHUB_SHA}

bun run src/cli.tsx e2b publish-template agent-swarm-worker-${GITHUB_SHA}
```

The image-backed template build uses the E2B SDK `fromImage()` path and does not
require Docker on the machine that calls E2B. Docker is still needed somewhere
upstream to build and push the registry image. Publishing calls the E2B template
update API with `E2B_API_KEY`; it does not require a separate E2B credential.

Deployed smoke flow [#deployed-smoke-flow]

For each provider you want to validate:

1. Choose a deterministic agent id, such as
   `smoke-${PROVIDER}-${DEPLOYMENT_SHA}`.
2. Start one E2B worker sandbox from the deployed template.
3. Pass runtime env through E2B sandbox `envVars`.
4. Wait until the worker registers at `/api/agents/{agentId}`.
5. Create a trivial task assigned to that worker.
6. Poll `/api/tasks/{taskId}` until it reaches `completed` or `failed`.
7. Assert the task completed and captured the expected execution metadata.
8. Kill the E2B sandbox.

Minimum worker runtime env:

```bash
MCP_BASE_URL=https://swarm.example.com
AGENT_SWARM_API_KEY=<same key accepted by the swarm API>
API_KEY=<same key accepted by the swarm API>
AGENT_ROLE=worker
AGENT_ID=smoke-codex-${DEPLOYMENT_SHA}
HARNESS_PROVIDER=codex
MAX_CONCURRENT_TASKS=1
WORKER_YOLO=true
SLACK_DISABLE=true
GITHUB_DISABLE=true
```

Add the provider credential required by the selected harness:

| Provider    | `HARNESS_PROVIDER` | Typical credential                                                                  |
| ----------- | ------------------ | ----------------------------------------------------------------------------------- |
| Claude Code | `claude`           | `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`                                    |
| Codex       | `codex`            | `OPENAI_API_KEY`, or configured Codex OAuth                                         |
| pi-mono     | `pi`               | `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, or provider-specific backend credentials |
| opencode    | `opencode`         | `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, or `OPENAI_API_KEY`                      |

CLI smoke check [#cli-smoke-check]

From any environment that has the repo checkout, Bun, and E2B credentials, you
can launch a worker against a deployed API without local Docker:

```bash
bun run src/cli.tsx e2b start-worker \
  --template agent-swarm-worker-${DEPLOYMENT_SHA} \
  --api-url https://swarm.example.com \
  --api-key "$SWARM_E2E_API_KEY" \
  --agent-id "smoke-codex-${DEPLOYMENT_SHA}" \
  --provider codex \
  --secret OPENAI_API_KEY="$OPENAI_API_KEY" \
  --timeout-sec 900 \
  --json
```

Then create and poll a task:

```bash
curl -sS -X POST "https://swarm.example.com/api/tasks" \
  -H "Authorization: Bearer $SWARM_E2E_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "task": "Reply with exactly: pong",
    "agentId": "smoke-codex-'"$DEPLOYMENT_SHA"'",
    "source": "api",
    "outputSchema": {
      "type": "object",
      "required": ["reply"],
      "properties": {
        "reply": { "const": "pong" }
      }
    }
  }'
```

The output schema makes the smoke test deterministic: the task should only pass
when the worker returns valid JSON matching the expected payload.

<Callout type="warn">
  Do not pass real secrets as literal CLI arguments in shared shell history when
  avoidable. Prefer environment inheritance, secret files, or an internal API
  path that supplies E2B `envVars` from the deployed secret store.
</Callout>

Embedded smoke runner shape [#embedded-smoke-runner-shape]

For an in-product deployed smoke test, keep the orchestration inside the API
process instead of shelling out to `agent-swarm e2b`:

```ts
type ProviderSmokeRequest = {
  providers: Array<"claude" | "codex" | "pi" | "opencode">;
  template: string;
  apiUrl: string;
  apiKey: string;
  deploymentSha: string;
  timeoutMs: number;
};
```

The implementation should reuse the E2B dispatch helpers directly:

* `createSandbox()` with the worker template and runtime `envVars`.
* `startDetachedProcess()` with `/docker-entrypoint.sh`.
* `waitForAgentRegistration()` for the deterministic `AGENT_ID`.
* Existing task APIs to create, poll, and inspect the ping task.
* `killSandbox()` in a `finally` block.

Return a structured report per provider:

```ts
type ProviderSmokeResult = {
  provider: string;
  ok: boolean;
  sandboxId: string;
  agentId: string;
  taskId?: string;
  taskStatus?: "completed" | "failed" | "cancelled" | "in_progress" | "pending";
  failureReason?: string;
};
```

Persisting this report gives operators a concrete answer to "can this deployed
swarm currently run real work through Codex, Claude, pi, and opencode?" without
requiring Docker inside the pod.

Cleanup and safety [#cleanup-and-safety]

Always clean up sandboxes, even after task failure:

```bash
bun run src/cli.tsx e2b kill <sandbox-id>
```

Use short TTLs for smoke sandboxes, for example `--timeout-sec 900`. Prefix
agent ids with `smoke-` so they are easy to identify in the Agents view and in
logs. Keep `MAX_CONCURRENT_TASKS=1` so the smoke worker only handles the task
created for that check.


# Evals Harness (/docs/guides/evals-harness)





The `apps/evals/` sub-project is Agent Swarm's evaluation harness. It runs a **scenario × harness-config matrix** against fresh swarm stacks in **E2B sandboxes**, captures the resulting transcripts and artifacts, and grades each attempt with deterministic checks plus optional LLM or agentic judges.

Use it when you need a repeatable answer to questions like:

* Which harness/provider/model combination actually passes this workflow?
* Did a runtime change improve quality, cost, or speed over the last version?
* Can a multi-worker scenario still complete after a change to memory, routing, or provider boot logic?

What It Runs [#what-it-runs]

Each matrix cell is one **scenario** paired with one **harness config**:

* **Scenario** defines the tasks, optional seeding, checks, and judging rubric.
* **Harness config** defines the worker provider/model setup for that attempt.
* **Best\@n** retries are supported per cell so you can compare pass rate, not just one lucky run.

The harness boots a fresh stack for every attempt:

1. Start one API sandbox and one or more worker sandboxes in E2B.
2. Optionally seed SQL fixtures, memories, or workspace files.
3. Create the scenario's tasks and wait for terminal outcomes.
4. Grade the result with deterministic checks and optional judges.
5. Persist artifacts, transcripts, task records, costs, and sandbox metadata.
6. Tear the sandboxes down, even on failure.

<Callout type="info">
  Eval-launched API and worker sandboxes pin `DESPLEGA_TELEMETRY_ENV=test`, so their activity stays out of production telemetry cohorts. The API still runs with `NODE_ENV=production` to preserve its production runtime and security behavior.
</Callout>

What It Stores [#what-it-stores]

The harness is designed for post-run inspection, not just a pass/fail bit. Each attempt can persist:

* Flattened transcript output
* Raw swarm session-log events
* Harness session files from the worker filesystem
* Task records and dependency outcomes
* Seed command outputs
* Session cost and token snapshots
* Roster snapshots for multi-worker runs
* Worker/API log tails
* Captured API and worker version metadata

Results are stored in a Turso-backed libsql replica, so local runs can be resumed and compared over time.

Quick Start [#quick-start]

```bash
cd apps/evals
bun install
bun src/cli.ts registry
bun src/cli.ts run --scenarios memory-seeded-recall --configs claude-haiku
bun src/cli.ts serve
```

The designated smoke scenario is `memory-seeded-recall`. It is the cheapest meaningful end-to-end check because it proves real server-side memory embedding and retrieval without paying for judge-model work.

<Callout type="info">
  `memory-seeded-recall` requires `EMBEDDING_API_KEY` in `apps/evals/.env`. The old `OPENAI_API_KEY` fallback is intentionally no longer injected for seeded-memory runs.
</Callout>

Core Concepts [#core-concepts]

Scenarios [#scenarios]

Scenarios live under `apps/evals/scenarios/`. They define:

* The worker roster (`workers` or `lead` + workers)
* Optional seeding (`sqlDump`, `memories`, `exec`)
* One or more tasks, including native `dependsOn` relationships
* Outcome checks and pass thresholds
* Optional LLM or agentic-judge rubric

Harness Configs [#harness-configs]

Harness configs live under `apps/evals/configs/`. They map a scenario onto a concrete provider/model environment such as Claude, pi, Codex, or opencode. Heterogeneous rosters are supported through per-member overrides.

Judges [#judges]

Three grading modes can cooperate:

* **Deterministic checks** for concrete invariants
* **LLM judge** for rubric-style scoring over transcripts
* **Agentic judge** for live verification via tools like `run_command`, `read_file`, and `api_get`

The agentic judge is the strongest option when transcript-only grading is too trusting.

Common Uses [#common-uses]

* Regression testing memory behavior, task routing, or dependency semantics
* Comparing providers or model tiers on the same scenario
* Verifying multi-worker handoffs and lead/worker orchestration
* Measuring cost, duration, and pass-rate trends across runtime versions

Where To Look Next [#where-to-look-next]

* Repo source of truth: [`apps/evals/README.md`](https://github.com/desplega-ai/agent-swarm/blob/main/apps/evals/README.md)
* E2B runtime model: [E2B Provider Smoke Tests](/docs/guides/e2b-provider-smoke-tests)
* Worker/provider setup: [Harness Configuration](/docs/guides/harness-configuration)
* Provider implementation details: [Adding a Harness Provider](/docs/guides/harness-providers)


# Harness Configuration (/docs/guides/harness-configuration)





Agent Swarm uses a **harness** abstraction to decouple task execution from the underlying AI provider. Each worker runs one harness — the harness spawns sessions, manages credentials, and normalizes events so the rest of the system doesn't care which provider is underneath.

Supported Providers [#supported-providers]

| Provider                        | `HARNESS_PROVIDER` | Description                                                                                                                                                                                                                                                                                              |
| ------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Claude Code**                 | `claude` (default) | Anthropic's Claude Code CLI. Recommended for most use cases                                                                                                                                                                                                                                              |
| **Codex**                       | `codex`            | OpenAI Codex CLI with API-key or ChatGPT OAuth authentication                                                                                                                                                                                                                                            |
| **opencode**                    | `opencode`         | OpenCode coding agent powered by OpenRouter. Uses Qwen Coder Flash by default; supports any OpenRouter, Anthropic, or OpenAI model                                                                                                                                                                       |
| **pi-mono**                     | `pi`               | Open-source coding agent by [@badlogic](https://github.com/badlogic/pi-mono). Supports multiple model backends                                                                                                                                                                                           |
| **Devin**                       | `devin`            | Cognition's Devin via the `/sessions` API — session executes in Devin's managed cloud, ACU-based cost tracking                                                                                                                                                                                           |
| **Claude Managed Agents**       | `claude-managed`   | Anthropic's managed cloud sandbox — sessions execute outside the worker. Requires one-time setup CLI to create the Anthropic-side Agent + Environment                                                                                                                                                    |
| **ACP (Agent Client Protocol)** | `acp`              | Any [Agent Client Protocol](https://agentclientprotocol.com) agent, spawned as a subprocess and driven over stdio. No swarm-side model-provider credential — the target owns its own model auth. It does receive the worker's swarm API key as the swarm MCP bearer, same as every other spawned harness |

How It Works [#how-it-works]

The `HARNESS_PROVIDER` environment variable selects which provider adapter is used. The runner creates the adapter at startup:

```
HARNESS_PROVIDER=claude          →  ClaudeAdapter         →  spawns `claude` CLI process
HARNESS_PROVIDER=codex           →  CodexAdapter          →  starts an isolated `codex app-server`
HARNESS_PROVIDER=opencode        →  OpencodeAdapter       →  spawns `opencode` CLI process
HARNESS_PROVIDER=pi              →  PiMonoAdapter         →  creates in-process pi-mono session
HARNESS_PROVIDER=devin           →  DevinAdapter          →  POSTs /sessions, polls events
                                                            (session executes in Devin cloud)
HARNESS_PROVIDER=claude-managed  →  ClaudeManagedAdapter  →  opens SSE stream against
                                                            Anthropic's managed sandbox
                                                            (session executes server-side)
HARNESS_PROVIDER=acp             →  ACPAdapter            →  spawns `ACP_TARGET_COMMAND`,
                                                            speaks ndjson ACP over stdio
```

Both adapters implement the same `ProviderAdapter` interface, producing normalized `ProviderEvent` streams (session init, tool calls, cost data, context usage, etc.) that the runner consumes identically.

For local coding harnesses, Agent Swarm now wires in the `context-mode` MCP server by default for Claude Code, Codex, and opencode. That gives those providers the same `ctx_*` compressed-search / fetch-and-index tools out of the box. Hook guidance now nudges agents toward `ctx_execute` / `ctx_batch_execute` after every 3 qualifying external-MCP calls by default (override with `CONTEXT_MODE_EXTERNAL_MCP_NUDGE_EVERY`). Set `CONTEXT_MODE_DISABLED=true` to opt a worker out.

When the runner refreshes an already-cloned repo for a new task, dirty working trees are auto-stashed instead of forcing a skipped pull. Any resulting `swarm-autostash` refs are appended to the composed prompt so the agent can restore them intentionally with `git stash apply <ref>` or `git stash pop <ref>` when that work matters to the current task.

***

Claude Code (Default) [#claude-code-default]

Claude Code is the default and recommended harness. It spawns the `claude` CLI as a subprocess with `--output-format stream-json` for structured event streaming.

Authentication Methods [#authentication-methods]

Claude Code supports two authentication methods, checked in priority order:

| Method                        | Env Var                   | How to Get It                                               |
| ----------------------------- | ------------------------- | ----------------------------------------------------------- |
| **OAuth token** (recommended) | `CLAUDE_CODE_OAUTH_TOKEN` | Run `claude setup-token` in your terminal                   |
| **API key**                   | `ANTHROPIC_API_KEY`       | From [console.anthropic.com](https://console.anthropic.com) |

OAuth is preferred because it uses your Claude Code subscription (Pro/Max/Team) with its included usage, rather than consuming pay-per-token API credits.

Getting an OAuth Token [#getting-an-oauth-token]

```bash
# Interactive — opens browser for OAuth flow
claude setup-token

# The output contains a token like: sk-ant-oat01-...
# Copy this value into your .env file
```

Or use the onboard wizard which runs this automatically:

```bash
bunx @desplega.ai/agent-swarm onboard
```

Environment Variables [#environment-variables]

| Variable                  | Required | Default         | Description                                                                                                                                                                                                  |
| ------------------------- | -------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `CLAUDE_CODE_OAUTH_TOKEN` | Yes\*    | —               | OAuth token from `claude setup-token`. Supports [multi-credential pools](#multi-credential-pools)                                                                                                            |
| `ANTHROPIC_API_KEY`       | Alt\*    | —               | Anthropic API key (alternative to OAuth). Also supports multi-credential pools                                                                                                                               |
| `SWARM_USE_CLAUDE_BRIDGE` | No       | `false`         | Enables the bundled `@desplega.ai/claude-bridge` path for interactive Claude sessions. Requires `CLAUDE_CODE_OAUTH_TOKEN`; if only `ANTHROPIC_API_KEY` is available, the worker falls back to stock `claude` |
| `CLAUDE_BINARY`           | No       | `claude`        | Path to the Claude CLI binary (if not in `$PATH`)                                                                                                                                                            |
| `CLAUDE_QUEUE_STEERING`   | No       | automatic probe | Controls raw Claude queue steering. Unset requires stock Claude Code `>=2.1.205`; `0`/`false`/`off`/`no` keeps the legacy `-p` path; `1`/`true`/`on`/`yes` forces stream-json input.                         |

\* One of `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY` is required.

Model Selection [#model-selection]

The Claude adapter passes the model string directly to the `claude` CLI via `--model`. Common values:

| Model    | Description                                       |
| -------- | ------------------------------------------------- |
| `opus`   | Claude Opus 5 (highest capability; 1M context)    |
| `sonnet` | Claude Sonnet (balanced)                          |
| `haiku`  | Claude Haiku (fastest)                            |
| `fable`  | Claude Fable 5.1 (1M-context premium shortname)   |
| `mythos` | Claude Mythos 5.1 (invite-only premium shortname) |

Models can be set per-task via the API or per-agent via the agent profile. The `opus` shortname resolves to `claude-opus-5`; `fable` and `mythos` resolve to their 5.1 model IDs. These shortnames also propagate through MCP task-creation surfaces (`send-task`, `task-action`, and schedules). See the [Anthropic models overview](https://platform.claude.com/docs/en/about-claude/models/overview) for all available model IDs.

If you want provider-agnostic intent instead of a concrete model string, use `modelTier` on tasks, schedules, or workflow `agent-task` nodes. The tier values are `smol`, `regular`, `smart`, and `ultra`; the claiming worker resolves them to concrete models for its own harness/provider, and local env overrides (`MODEL_TIER_MAP`, `MODEL_TIER_<TIER>`) can adjust the mapping without changing the task payload.

Reasoning / Effort [#reasoning--effort]

Set a per-agent reasoning/effort level with `PATCH /api/agents/{id}/runtime` (`reasoning_effort: off | low | medium | high | xhigh`), persisted as the `REASONING_EFFORT_OVERRIDE` swarm config key. Claude translates this into the `CLAUDE_CODE_EFFORT_LEVEL` env var — no CLI flag is used (`--effort` is buggy in `-p` mode). `off` on a legacy model that still exposes a numeric thinking budget instead sets `MAX_THINKING_TOKENS=0`.

**Precedence**: if an operator's `additionalArgs` includes `--effort`, the CLI flag wins over `CLAUDE_CODE_EFFORT_LEVEL` — the standard "`additionalArgs` is an escape hatch" behavior, not special-cased for effort. Not every model supports every level (e.g. some Opus variants don't support `off`); the dashboard's effort selector greys out unsupported levels per selected model, and the API 400s an unsupported `(model, level)` combo.

***

Codex [#codex]

Codex runs through `codex app-server` and supports direct OpenAI API keys and ChatGPT OAuth.

Each task runs inside a throwaway `codex-session-runner` subprocess. That process starts one fresh `codex app-server` process.

The worker sends session configuration and control messages over stdin. The runner returns line-delimited events and results over stdout. Both processes exit with the task.

Authentication Methods [#authentication-methods-1]

Codex checks credentials in this order:

| Method                             | Source               | Notes                                 |
| ---------------------------------- | -------------------- | ------------------------------------- |
| **OpenAI API key**                 | `OPENAI_API_KEY`     | Standard API billing                  |
| **Auth file**                      | `~/.codex/auth.json` | Native Codex CLI auth file            |
| **ChatGPT OAuth via config store** | `codex_oauth_<slot>` | Restored automatically at worker boot |

For ChatGPT OAuth setup, see [Provider Auth: Codex OAuth](/docs/guides/provider-auth/codex-oauth).

Environment Variables [#environment-variables-1]

| Variable           | Required    | Default                            | Description                                                     |
| ------------------ | ----------- | ---------------------------------- | --------------------------------------------------------------- |
| `HARNESS_PROVIDER` | Yes         | —                                  | Must be set to `codex`                                          |
| `OPENAI_API_KEY`   | No          | —                                  | Optional when using direct OpenAI API access                    |
| `API_KEY`          | Yes         | —                                  | Swarm API key used to fetch `codex_oauth` from the config store |
| `MCP_BASE_URL`     | Yes         | `http://host.docker.internal:3013` | Swarm API URL reachable by the worker                           |
| `AGENT_ID`         | Recommended | Auto-generated                     | Keep stable across restarts for task resume                     |

Swarm Config Keys (Codex) [#swarm-config-keys-codex]

The following keys are stored in the swarm config store (via `PUT /api/config` or the `set-config` MCP tool) rather than as environment variables:

| Key                                   | Default         | Description                                                                                                                                                                                                                                                                    |
| ------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS` | `7200000` (2 h) | How long a Codex OAuth slot is held out of the pool after a workspace-credits-exhausted error. Must be a positive integer of milliseconds; clamped to \[5 min, 7 days]. See [Workspace credits exhausted](/docs/guides/provider-auth/codex-oauth#workspace-credits-exhausted). |

Model Selection [#model-selection-1]

The default model baked into the worker image is `gpt-5.4`. You can override it with `MODEL_OVERRIDE` if needed.

Reasoning / Effort [#reasoning--effort-1]

Set the per-agent reasoning/effort level (`off | low | medium | high | xhigh`) via `PATCH /api/agents/{id}/runtime` — Codex translates it into the `model_reasoning_effort` config field, with `off` mapping to `'none'`. `show_raw_agent_reasoning` stays pinned `false` regardless of the effort level: setting `high` costs reasoning tokens (visible in `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. The API 400s an unsupported `(model, level)` combo before it's persisted.

Codex Specifics [#codex-specifics]

* **Cross-keyType failover**: when Codex workers have both `OPENAI_API_KEY` slots and `codex_oauth_*` slots available, the runner now fails over across key types instead of retrying a known-exhausted pool. A successful task also clears stale rate-limit state for the credential that proved healthy.
* **Per-task subprocess isolation**: each task gets a fresh `codex-session-runner` and `codex app-server` process. This keeps worker memory stable on hot workers.
* **Small spawn argv**: large system prompts are staged to a temp file and passed via `--append-system-prompt-file`, avoiding Linux `MAX_ARG_STRLEN` / `E2BIG` failures on prompt-heavy repos.
* **Actionable failure reporting**: subprocess startup / parse failures are emitted back to the parent as structured errors, and non-TTY runs no longer leak cursor escape sequences into the JSON pipe.

***

Opencode [#opencode]

[opencode](https://opencode.ai) is a terminal-based AI coding agent that ships its own session loop and MCP client. The swarm spawns the `opencode` CLI as a subprocess and attaches the agent-swarm plugin for heartbeat, cancellation, identity sync, and compaction hooks.

When to Use opencode [#when-to-use-opencode]

* You want access to OpenRouter's full model catalog (100+ models) without writing provider glue.
* You prefer a lightweight, quickly-iterating open-source CLI over the heavier Claude Code toolchain.
* You need cost-effective throughput — the default `openrouter/qwen/qwen3-coder-flash` model is fast and inexpensive.

Authentication Methods [#authentication-methods-2]

opencode checks credentials in this priority order:

| Method                               | Env Var                             | Notes                                                         |
| ------------------------------------ | ----------------------------------- | ------------------------------------------------------------- |
| **OpenRouter API key** (recommended) | `OPENROUTER_API_KEY`                | Access 100+ models via [openrouter.ai](https://openrouter.ai) |
| **Anthropic API key**                | `ANTHROPIC_API_KEY`                 | Direct Anthropic API billing                                  |
| **OpenAI API key**                   | `OPENAI_API_KEY`                    | Direct OpenAI API billing                                     |
| **Auth file**                        | `~/.local/share/opencode/auth.json` | Native opencode CLI auth file                                 |

At least one credential source is required. The Docker entrypoint validates this on startup.

Environment Variables [#environment-variables-2]

| Variable              | Required | Default                        | Description                                                                                                |
| --------------------- | -------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| `HARNESS_PROVIDER`    | Yes      | —                              | Must be set to `opencode`                                                                                  |
| `OPENROUTER_API_KEY`  | One of\* | —                              | OpenRouter API key (primary — gives access to all OpenRouter models)                                       |
| `OPENROUTER_BASE_URL` | No       | `https://openrouter.ai/api/v1` | Route OpenRouter provider, model-refresh, and session-summary traffic through an OpenAI-compatible gateway |
| `ANTHROPIC_API_KEY`   | One of\* | —                              | Anthropic API key for Claude models                                                                        |
| `OPENAI_API_KEY`      | One of\* | —                              | OpenAI API key for GPT models                                                                              |
| `OPENCODE_BINARY`     | No       | `opencode`                     | Path to the opencode CLI binary (if not in `$PATH`)                                                        |

\* At least one credential source is required (`OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `~/.local/share/opencode/auth.json`).

Model Selection [#model-selection-2]

The default model is `openrouter/qwen/qwen3-coder-flash`. Set `MODEL_OVERRIDE` to use a different model:

| Format     | Example                             | Notes                                                                 |
| ---------- | ----------------------------------- | --------------------------------------------------------------------- |
| OpenRouter | `openrouter/qwen/qwen3-coder-flash` | Default. See [OpenRouter model catalog](https://openrouter.ai/models) |
| Anthropic  | `anthropic/claude-sonnet-4-6`       | Requires `ANTHROPIC_API_KEY`                                          |
| OpenAI     | `openai/gpt-4o`                     | Requires `OPENAI_API_KEY`                                             |

Reasoning / Effort [#reasoning--effort-2]

Set the per-agent reasoning/effort level (`off | low | medium | high | xhigh`) via `PATCH /api/agents/{id}/runtime`. opencode has no single reasoning knob — the swarm translates the level into provider-keyed options in the per-task `opencode.json`: `anthropic/*` models get `thinking.budgetTokens` (an internal numeric translation, not a user-facing knob), `openrouter/*` models get `reasoning.effort`, and OpenAI-compatible models get `reasoningEffort`. `off` omits reasoning keys entirely — opencode has no explicit off switch.

opencode Specifics [#opencode-specifics]

* **Agent-swarm plugin**: the `plugin/opencode-plugins/agent-swarm.ts` plugin is automatically injected at session creation. It handles heartbeat, task cancellation, identity sync, system-prompt transformation, compaction, and idle hooks — no manual configuration needed.
* **Per-task isolation**: each session gets its own agent file (`.opencode/agents/swarm-<taskId>.md`), config file (`/tmp/opencode-<taskId>.json`), and data directory (`/tmp/opencode-data-<taskId>`) to prevent cross-task state bleed.
* **MCP tool discovery**: the swarm MCP endpoint is wired in automatically via the per-task config; installed MCP servers are also discovered and merged in.

***

pi-mono [#pi-mono]

[pi-mono](https://github.com/badlogic/pi-mono) is an open-source coding agent that runs as a library (no external CLI process). It supports multiple LLM backends through a provider/model system. See the [coding agent README](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md) for detailed configuration and usage.

Authentication [#authentication]

pi-mono supports several authentication methods depending on which model provider you use:

| Provider       | Env Var                 | Description                                                                            |
| -------------- | ----------------------- | -------------------------------------------------------------------------------------- |
| **Anthropic**  | `ANTHROPIC_API_KEY`     | Direct Anthropic API access                                                            |
| **OpenRouter** | `OPENROUTER_API_KEY`    | Access 100+ models via OpenRouter                                                      |
| **OpenAI**     | `OPENAI_API_KEY`        | Direct OpenAI API access                                                               |
| **Google**     | `GOOGLE_API_KEY`        | Direct Google AI API access (only honored when `MODEL_OVERRIDE` starts with `google/`) |
| **Auth file**  | `~/.pi/agent/auth.json` | Pre-configured auth file                                                               |

At least one of these must be available. The Docker entrypoint validates this on startup. When `MODEL_OVERRIDE` starts with a provider prefix, only the matching key is required; when it's unset, any one of `ANTHROPIC_API_KEY` / `OPENROUTER_API_KEY` / `OPENAI_API_KEY` suffices.

Environment Variables [#environment-variables-3]

| Variable              | Required      | Default                        | Description                                                                             |
| --------------------- | ------------- | ------------------------------ | --------------------------------------------------------------------------------------- |
| `HARNESS_PROVIDER`    | Yes           | —                              | Must be set to `pi`                                                                     |
| `ANTHROPIC_API_KEY`   | One of\*      | —                              | Anthropic API key for Claude models                                                     |
| `OPENROUTER_API_KEY`  | One of\*      | —                              | OpenRouter API key for multi-provider access                                            |
| `OPENROUTER_BASE_URL` | No            | `https://openrouter.ai/api/v1` | Route OpenRouter model and session-summary traffic through an OpenAI-compatible gateway |
| `OPENAI_API_KEY`      | One of\*      | —                              | OpenAI API key for GPT models                                                           |
| `GOOGLE_API_KEY`      | When prefixed | —                              | Google AI API key — required only when `MODEL_OVERRIDE` uses a `google/` prefix         |

\* At least one credential source is required (API key or `~/.pi/agent/auth.json`).

`OPENROUTER_BASE_URL` is shared across the swarm's OpenRouter consumers. Set it on API and worker processes when a deployment must route harnesses, credential checks, internal summaries, and raw-LLM or validation workflow nodes through the same gateway. Unset or blank values keep the direct `https://openrouter.ai/api/v1` default.

<Callout type="warn">
  **Do not pass `CLAUDE_CODE_OAUTH_TOKEN`** when using `HARNESS_PROVIDER=pi`. If Claude credentials are present in the environment, the harness will attempt to use them instead of the configured pi-mono provider, causing misconfiguration. Only pass the credentials relevant to your selected provider (`OPENROUTER_API_KEY` or `ANTHROPIC_API_KEY`).
</Callout>

Model Selection [#model-selection-3]

pi-mono resolves models using a `provider/model-id` format. Set the model via `MODEL_OVERRIDE` in your environment:

The prefix before the first `/` selects the provider and the matching credential — the rest is passed through as the model ID (so OpenRouter IDs that themselves contain `/` work fine).

| Format            | Example                                                                                                                 | Required Credential                                   |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| Shortname         | `opus`, `sonnet`, `haiku`, `fable`                                                                                      | `ANTHROPIC_API_KEY` (maps to Anthropic Claude models) |
| `anthropic/<id>`  | `anthropic/claude-sonnet-4-6` ([Anthropic model IDs](https://platform.claude.com/docs/en/about-claude/models/overview)) | `ANTHROPIC_API_KEY`                                   |
| `openrouter/<id>` | `openrouter/moonshotai/kimi-k2.5` ([OpenRouter catalog](https://openrouter.ai/models))                                  | `OPENROUTER_API_KEY`                                  |
| `openai/<id>`     | `openai/gpt-4o`                                                                                                         | `OPENAI_API_KEY`                                      |
| `google/<id>`     | `google/gemini-2.5-pro`                                                                                                 | `GOOGLE_API_KEY`                                      |

Reasoning / Effort [#reasoning--effort-3]

Set the per-agent reasoning/effort level (`off | low | medium | high | xhigh`) via `PATCH /api/agents/{id}/runtime` — pi-mono translates it directly into the `thinkingLevel` session option, a top-level sibling of `model` on `CreateAgentSessionOptions`. pi's native vocabulary already includes `off`, so no special-casing is needed there.

pi-mono Specifics [#pi-mono-specifics]

* **Runtime-scoped credentials**: pi 0.81 uses a per-session `ModelRuntime`. The adapter injects the resolved task credential into that runtime instead of mutating process-wide provider state, and refreshed OAuth credentials are persisted through pi's provider-owned auth store.
* **AGENTS.md symlink**: pi-mono reads `AGENTS.md` for project instructions (equivalent to Claude's `CLAUDE.md`). The adapter automatically creates a symlink `AGENTS.md → CLAUDE.md` during sessions so your existing project instructions work with both providers.
* **Lazy provider import**: the worker only imports `@earendil-works/pi-coding-agent` when `HARNESS_PROVIDER=pi`, so non-pi workers are no longer exposed to pi-mono's module-level side effects at boot.
* **MCP tool discovery**: pi-mono discovers swarm MCP tools at session creation via HTTP and registers them as custom tools. This is handled automatically — no `.mcp.json` needed for the swarm connection (though installed MCP servers are also discovered).
* **Skills sync**: The worker mirrors skills into `~/.claude/skills/`, `~/.pi/agent/skills/`, `~/.codex/skills/`, `~/.opencode/skills/`, and `~/.agents/skills/` so local harnesses share the same skill inventory.
* **Per-task hot-reload**: between tasks the worker polls a cheap `GET /api/agents/:id/skills/signature` (hash) endpoint and only re-syncs the filesystem when the installed-skill set actually changes. Newly installed / uninstalled skills appear on the next task without restarting the worker, while unchanged sessions skip the work. Foreign skills (`~/.claude/skills/<name>/SKILL.md` files not owned by the swarm) are preserved across re-syncs. See `src/utils/skills-refresh.ts`.

***

Claude Managed Agents [#claude-managed-agents]

`claude-managed` runs sessions in Anthropic's managed cloud sandbox. The worker becomes a thin SSE relay that maps Anthropic's `client.beta.sessions.events.stream` output to the swarm's `ProviderEvent` union — no LLM process, no local CLI, no skill filesystem syncing on the worker.

One-Time Setup [#one-time-setup]

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

This bootstrap CLI (run from your laptop, **not** inside a worker container):

1. Creates an Anthropic-side **Environment** (sandbox configuration).
2. Uploads each `plugin/commands/*.md` skill via `client.beta.skills.create`.
3. Creates an Anthropic-side **Agent** with those skills attached.
4. Persists the resulting `MANAGED_AGENT_ID` + `MANAGED_ENVIRONMENT_ID` to `swarm_config` (encrypted at rest); deployed workers restore them at boot.

Re-run with `--force` to recreate the agent + environment from scratch (rare — only needed if Anthropic rotates IDs upstream or you intentionally reset).

Environment Variables [#environment-variables-4]

| Variable                  | Required | Default           | Description                                                                                               |
| ------------------------- | -------- | ----------------- | --------------------------------------------------------------------------------------------------------- |
| `HARNESS_PROVIDER`        | Yes      | —                 | Must be set to `claude-managed`                                                                           |
| `ANTHROPIC_API_KEY`       | Yes      | —                 | Anthropic API key. The setup CLI and runtime adapter both use this                                        |
| `MANAGED_AGENT_ID`        | Yes      | —                 | Anthropic Agent ID; written by `claude-managed-setup`                                                     |
| `MANAGED_ENVIRONMENT_ID`  | Yes      | —                 | Anthropic Environment ID; written by `claude-managed-setup`                                               |
| `MCP_BASE_URL`            | Yes      | —                 | Must be **HTTPS-public** so Anthropic's sandbox can reach `/mcp`                                          |
| `MANAGED_AGENT_MODEL`     | No       | `claude-sonnet-5` | Default model on `sessions.create`; per-task `task.model` overrides                                       |
| `MANAGED_GITHUB_VAULT_ID` | No       | —                 | Anthropic vault ID holding a GitHub PAT, for repo-bound tasks (recommended for prod)                      |
| `MANAGED_GITHUB_TOKEN`    | No       | —                 | Literal GitHub PAT injected as `authorization_token` on `github_repository` resources (dev-only fallback) |

<Callout type="warn">
  **`MCP_BASE_URL` must be HTTPS and publicly reachable.** Anthropic's managed sandbox calls `/mcp` from the cloud — `localhost`, `host.docker.internal`, or self-signed HTTPS will fail. In development, expose the API server via ngrok / Cloudflare Tunnel; in production, point at your deployed swarm API. The adapter and `docker-entrypoint.sh` both fail-fast at boot if `MCP_BASE_URL` is unset or doesn't start with `https://`. (Same constraint already documented for the Jira webhook setup.)
</Callout>

Model Selection [#model-selection-4]

Default: `claude-sonnet-5`. Override per-worker via `MANAGED_AGENT_MODEL`, or per-task via the standard `task.model` field. Cost computation lives in [`src/providers/claude-managed-models.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/claude-managed-models.ts) — token rates per [Anthropic pricing](https://platform.claude.com/docs/en/about-claude/pricing) plus Anthropic's $0.08/session-hour runtime fee.

claude-managed Specifics [#claude-managed-specifics]

* **No skill filesystem on the worker.** Skills are uploaded to Anthropic via `beta.skills.create` during setup and referenced by ID on the Agent.
* **System prompt rides in the user message** with a `cache_control: { type: "ephemeral" }` breakpoint between the static prefix and the per-task body, so the static prefix is cache-hit across tasks for the same agent.
* **Repo provisioning** uses `resources: [{ type: "github_repository", url, authorization_token, checkout: { type: "branch", name: "main" } }]` on `sessions.create` when the task carries `vcsRepo`. Anthropic clones into `/workspace/<repo-name>` server-side before the agent runs.

For full provider design rationale (why we don't `agents.create` at runtime, the SDK shape deviations, the prompt-cache breakpoint decision), see [Adding a Harness Provider §12](/docs/guides/harness-providers#12-claude-managed-agents-pre-existing-agent-environment-pattern).

***

Agent Client Protocol (ACP) [#agent-client-protocol-acp]

`acp` drives any agent that speaks the [Agent Client Protocol](https://agentclientprotocol.com) — an open, editor-agnostic JSON-RPC protocol for driving coding agents over stdio. If your agent already has an ACP server mode (or you build one), you can run it as a swarm worker with no adapter code of your own. See the protocol's own [Get started → Agents](https://agentclientprotocol.com/get-started/agents) guide for what it takes for an agent to speak ACP.

The dashboard offers a curated **OpenCode** preset and a free-form **Custom** target. ACP has no live steering (see the steering table in [Adding a Harness Provider](/docs/guides/harness-providers#live-task-steering) — ACP exposes a single in-flight `session/prompt` and the only interrupt is a full `session/cancel`).

How it works [#how-it-works-1]

The `ACPAdapter` spawns the selected target as a subprocess and speaks ndjson ACP over its stdio using [`@agentclientprotocol/sdk`](https://www.npmjs.com/package/@agentclientprotocol/sdk)'s `ClientSideConnection`. On session start it calls `initialize` then `newSession`, passing the swarm MCP server (`{apiUrl}/mcp`, over HTTP with the standard `Authorization` / `X-Agent-ID` / `X-Source-Task-Id` headers) plus every other MCP server installed on the agent, translated into ACP's `McpServer` shape. It reads the returned `configOptions` and applies configured knobs with `session/set_config_option` before the first prompt. Missing or rejected options use the preset fallback and do not fail the session. Sanitized advertised options are reported to the dashboard. Session notifications (`session/update`) are translated into the swarm's normalized `ProviderEvent` stream the same way every other provider's events are.

Because the target process owns its own model credentials, `acp` reports `ready: true` with no required env vars from the swarm's credential-wait gate, and its cost rows settle at `costSource: "unpriced"` — the swarm has no visibility into what the target agent is billed for a turn.

Environment Variables [#environment-variables-5]

| Variable                 | Required    | Default  | Description                                                                                                                                                                                                     |
| ------------------------ | ----------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `HARNESS_PROVIDER`       | Yes         | —        | Must be set to `acp`                                                                                                                                                                                            |
| `ACP_TARGET`             | No          | `custom` | `opencode` for the curated OpenCode preset, or `custom` for an operator-supplied command                                                                                                                        |
| `ACP_TARGET_COMMAND`     | Custom only | —        | Executable to spawn as the custom ACP agent (also accepts the shorter `ACP_COMMAND` alias). Must resolve on `$PATH` or be an absolute path                                                                      |
| `ACP_TARGET_ARGS`        | No          | —        | Arguments to the target command. Accepts a JSON array (`["acp", "--flag"]`) or a plain whitespace-separated string                                                                                              |
| `ACP_TARGET_ENV_KEYS`    | No          | `[]`     | JSON array (or comma-separated list for hand-authored config) of environment/config keys explicitly allowed into a custom target process                                                                        |
| `ACP_MODEL_ENV_KEY`      | No          | —        | Custom target environment variable that receives `MODEL_OVERRIDE` when the ACP `model` option is unavailable                                                                                                    |
| `ACP_CONFIG_OPTIONS`     | No          | `{}`     | JSON object of additional string or boolean ACP option values to apply when advertised                                                                                                                          |
| `ACP_SYSTEM_PROMPT_PATH` | No          | —        | Where to write the composed system prompt before spawning, for targets that read their prompt from a file instead of an ACP field. Relative paths resolve against the task `cwd`; absolute paths are used as-is |

Use the dashboard runtime editor when possible. It updates the harness, `MODEL_OVERRIDE`, and ACP fields atomically. Selecting ACP from another harness defaults to OpenCode; existing ACP agents with no `ACP_TARGET` stay custom for backward compatibility.

Example targets [#example-targets]

Any ACP-speaking agent works. A few starting points:

<Tabs items="[&#x22;opencode&#x22;, &#x22;claude-code-acp&#x22;, &#x22;Gemini CLI&#x22;, &#x22;Custom&#x22;]">
  <Tab value="opencode">
    [opencode](https://opencode.ai) ships a built-in ACP server:

    ```bash
    HARNESS_PROVIDER=acp
    ACP_TARGET=opencode
    MODEL_OVERRIDE=opencode/big-pickle
    ```

    The adapter applies `MODEL_OVERRIDE` through ACP's advertised `model` option. It also merges the model into `OPENCODE_CONFIG_CONTENT` before spawn as a fallback. OpenCode resolves its own model credentials from `~/.local/share/opencode/auth.json` or the preset's allowlisted provider env vars (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, etc.).
  </Tab>

  <Tab value="claude-code-acp">
    Zed's [`claude-code-acp`](https://github.com/zed-industries/claude-code-acp) bridges Claude Code to ACP:

    ```bash
    HARNESS_PROVIDER=acp
    ACP_TARGET_COMMAND=npx
    ACP_TARGET_ARGS=["-y", "@zed-industries/claude-code-acp"]
    ```

    Needs `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY` in the worker env — the bridge process picks it up the same way the `claude` CLI does. This is a different code path from the swarm's native `HARNESS_PROVIDER=claude` adapter; prefer `claude` directly unless you specifically need the ACP transport.
  </Tab>

  <Tab value="Gemini CLI">
    Gemini CLI has an experimental ACP mode:

    ```bash
    HARNESS_PROVIDER=acp
    ACP_TARGET_COMMAND=gemini
    ACP_TARGET_ARGS=["--experimental-acp"]
    ```

    Needs `GEMINI_API_KEY` (or whatever credential the installed Gemini CLI version expects) in the worker env.
  </Tab>

  <Tab value="Custom">
    Any executable that speaks ACP over stdio (reads ndjson on stdin, writes ndjson on stdout) works without modification:

    ```bash
    HARNESS_PROVIDER=acp
    ACP_TARGET_COMMAND=/usr/local/bin/my-acp-agent
    ACP_TARGET_ARGS=["--stdio"]
    ```

    See [agentclientprotocol.com/get-started/agents](https://agentclientprotocol.com/get-started/agents) for what your agent needs to implement.
  </Tab>
</Tabs>

Tool availability [#tool-availability]

An ACP agent's *own* tools (file edit, shell, etc.) are whatever the target process implements — the swarm doesn't grant or restrict them; that's between you and the target agent's build. What the swarm adapter does provide is the **swarm MCP server** (`store-progress`, `send-task`, `memory-search`, and the rest) plus every MCP server already installed on the calling agent, both passed into `newSession`'s `mcpServers` array. Whether the target agent actually calls them depends on the target honoring ACP's `mcpServers` field the same way opencode's and Zed's ACP servers do — check your target's docs if tool calls aren't appearing. There's no fallback path today if a target ignores `mcpServers`: unlike `claude`/`pi`/`codex`, which fail open and keep the harness usable without MCP, an ACP target that doesn't wire the passed servers into its own tool loop simply won't call swarm tools at all.

ACP Specifics [#acp-specifics]

* **The target receives the worker's swarm API key.** `newSession`'s swarm MCP server entry carries the worker's bearer as an `Authorization` header (see [How it works](#how-it-works) above) — the same credential every other spawned harness process receives (`claude`, `codex`, and the rest all pass their swarm bearer to the spawned CLI/subprocess the same way). `ACP_TARGET_COMMAND` is operator-configured and runs in the worker container as the worker; point it only at a binary you trust with that credential.
* **No swarm-side credential gate.** The `acp` adapter has no `readyCheck` — the worker reports `ready: true` immediately. If `ACP_TARGET_COMMAND` is unset or unresolvable, the failure surfaces at session-spawn time (task fails with `ACP target failed during startup: ...`), not at boot.
* **No live steering.** `deliverSteering` is not implemented; mid-run messages promote to follow-up tasks the same way an unsupported provider always handles them.
* **No skill filesystem sync.** The generic worker image still seeds `~/.claude/skills`, `~/.opencode/skills`, etc. at boot, so a target that reads one of those directories natively (like opencode) still sees skills; a fully custom target sees whatever `ACP_SYSTEM_PROMPT_PATH` writes and nothing more.

For full adapter internals (the `SwarmAcpClient`/`ACPSession` classes, MCP server translation, event mapping), see [`src/providers/acp-adapter.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/acp-adapter.ts) and [Adding a Harness Provider](/docs/guides/harness-providers).

***

Choosing a Provider [#choosing-a-provider]

| Consideration            | Claude Code                           | Codex                                         | opencode                                      | pi-mono                               | Claude Managed                                                   | ACP                                                                 |
| ------------------------ | ------------------------------------- | --------------------------------------------- | --------------------------------------------- | ------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------- |
| **Setup complexity**     | Minimal — install CLI + OAuth token   | Minimal — CLI plus API key or OAuth bootstrap | Minimal — CLI plus OpenRouter key             | Requires API key(s)                   | One-time `claude-managed-setup` CLI; HTTPS-public `MCP_BASE_URL` | Depends entirely on the target agent                                |
| **Billing model**        | Uses Claude Pro/Max/Team subscription | OpenAI API billing or ChatGPT OAuth           | Pay-per-token via OpenRouter/Anthropic/OpenAI | Pay-per-token via API                 | Anthropic API tokens + $0.08/session-hour runtime fee            | Owned by the target process; swarm reports `costSource: "unpriced"` |
| **Model flexibility**    | Claude models only                    | Codex/OpenAI models                           | 100+ models via OpenRouter                    | Any provider via OpenRouter           | Claude models only                                               | Whatever the target agent supports                                  |
| **Follow-up continuity** | DB-backed context preamble            | DB-backed context preamble                    | DB-backed context preamble                    | DB-backed context preamble            | DB-backed context preamble                                       | DB-backed context preamble                                          |
| **MCP integration**      | Native (`.mcp.json` config)           | Native (`~/.codex/config.toml`)               | Auto-wired per-task via config file           | HTTP-based tool discovery             | Server-side on the Anthropic Agent                               | Passed via ACP `newSession.mcpServers`; target must honor it        |
| **Where session runs**   | Worker container                      | Worker container                              | Worker container                              | Worker container                      | Anthropic's managed cloud sandbox                                | Worker container (subprocess)                                       |
| **Maturity**             | Production-grade, well-tested         | Production-grade, well-tested                 | Early support (2026-05)                       | Community project, actively developed | Public beta (2026-04)                                            | Experimental — one generic target, no named profiles                |

**Recommendation**: Use Claude Code with OAuth for most setups. Use Codex when you specifically want the OpenAI/Codex toolchain or ChatGPT OAuth-backed workers. Use opencode when you want a lightweight open-source runner with access to the full OpenRouter model catalog at low cost. Consider pi-mono when you need a library-based open-source runner. Choose `claude-managed` when you want managed sandboxing (faster cold-start, vault-based credential isolation) and don't need a local runtime. Choose `acp` when you already have (or are building) an ACP-speaking agent and want to run it as a swarm worker without writing a new adapter.

***

Multi-Credential Pools [#multi-credential-pools]

Claude Code [#claude-code]

Both `CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY` support comma-separated values for load balancing across multiple subscriptions:

```bash
# Multiple OAuth tokens — one is randomly selected per session
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat-token1,sk-ant-oat-token2,sk-ant-oat-token3

# Also works with API keys
ANTHROPIC_API_KEY=sk-ant-api-key1,sk-ant-api-key2
```

Each session randomly selects one credential from the pool. A log line indicates the selected index (never the credential itself). Single values work unchanged.

This is useful when running multiple concurrent workers that would otherwise hit rate limits on a single subscription.

Codex OAuth Pool [#codex-oauth-pool]

Codex workers support a **database-backed** multi-credential pool stored as `codex_oauth_0`, `codex_oauth_1`, ... in the swarm config store. Unlike the Claude comma-separated approach, Codex slots are provisioned via `codex-login` and persisted centrally so all workers can access them.

Key properties:

* **Rate-limit-aware selection**: the runner queries `/api/keys/available?keyType=CODEX_OAUTH` before each task spawn and picks from non-rate-limited slots only. When a task hits a rate limit, the slot is marked unavailable: workspace-credits-exhausted errors use a **2-hour** cooldown (tunable via `CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS`); other unparseable rate-limit errors fall back to \~5 minutes. See [Provider Auth: Codex OAuth — Workspace credits exhausted](/docs/guides/provider-auth/codex-oauth#workspace-credits-exhausted).
* **Locked refresh + keep-warm**: task-time revalidation and `POST /api/oauth/keep-warm/codex` share the same refresh-lock path, so rarely-used slots can still refresh on a roughly weekly cadence without racing the runner.
* **Token-refresh write-back**: refreshed OAuth tokens are written back to the same `codex_oauth_<slot>` key the task started with. Other slots are never touched.
* **Fail-fast auth errors**: a rejected slot refresh now stops the task with the upstream auth failure instead of silently falling back to a stale pool auth file.
* **Backwards compatible**: single-credential deploys work unchanged. The boot entrypoint seeds `codex_oauth_0` from the legacy `codex_oauth` key on first run.
* **Global scope**: no per-agent or per-task affinity — any worker can pick any slot.

Provision additional slots:

```bash
# Run once per ChatGPT account; each call appends a new slot
bun run src/cli.tsx codex-login --api-url http://localhost:3013 --api-key YOUR_API_KEY
```

For the full provisioning guide, rate-limit detection details, and verification commands, see [Provider Auth: Codex OAuth](/docs/guides/provider-auth/codex-oauth#multi-credential-pool).

***

Docker Configuration Examples [#docker-configuration-examples]

Claude Code Worker [#claude-code-worker]

```bash
# .env.docker
HARNESS_PROVIDER=claude              # Optional — claude is the default
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat-...
API_KEY=your-api-key
MCP_BASE_URL=http://host.docker.internal:3013
AGENT_ID=your-worker-uuid
GITHUB_TOKEN=ghp_...
```

opencode Worker [#opencode-worker]

```bash
# .env.docker
HARNESS_PROVIDER=opencode
OPENROUTER_API_KEY=sk-or-...         # Or ANTHROPIC_API_KEY / OPENAI_API_KEY
MODEL_OVERRIDE=openrouter/qwen/qwen3-coder-flash  # Default; see https://openrouter.ai/models
API_KEY=your-api-key
MCP_BASE_URL=http://host.docker.internal:3013
AGENT_ID=your-worker-uuid
GITHUB_TOKEN=ghp_...
```

pi-mono Worker [#pi-mono-worker]

```bash
# .env.docker
HARNESS_PROVIDER=pi
OPENROUTER_API_KEY=sk-or-...         # Or ANTHROPIC_API_KEY
MODEL_OVERRIDE=openrouter/moonshotai/kimi-k2.5  # See https://openrouter.ai/models
API_KEY=your-api-key
MCP_BASE_URL=http://host.docker.internal:3013
AGENT_ID=your-worker-uuid
GITHUB_TOKEN=ghp_...
# Do NOT include CLAUDE_CODE_OAUTH_TOKEN — it will override the pi provider
```

Mixed Swarm [#mixed-swarm]

You can run different providers for different agents in the same swarm. For example, a Claude Code lead with pi-mono workers:

```bash
# .env.docker-lead
HARNESS_PROVIDER=claude
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat-...
AGENT_ROLE=lead

# .env.docker-worker
HARNESS_PROVIDER=pi
OPENROUTER_API_KEY=sk-or-...
```

***

Troubleshooting [#troubleshooting]

Worker is parked in `waiting_for_credentials` [#worker-is-parked-in-waiting_for_credentials]

The container booted but no harness credentials are present. This is expected — the worker waits at runtime instead of crash-looping. Set the missing key via `PUT /api/config` (scope=agent or scope=global) and the worker picks it up within ≤30s. See [Worker Credential Recovery](/docs/guides/worker-credential-recovery) for the full lifecycle, the `credentialMissing` payload, and the configuration knobs (`BOOT_INITIAL_BACKOFF_MS`, `BOOT_MAX_BACKOFF_MS`, `BOOT_MAX_WAIT_SECONDS`).

Claude Code: "Claude CLI not found" [#claude-code-claude-cli-not-found]

The `claude` binary must be in `$PATH` inside the Docker container. The worker Docker image includes it by default. If using a custom image, set `CLAUDE_BINARY=/path/to/claude`.

Wrong provider selected [#wrong-provider-selected]

Check the startup logs — the entrypoint prints `Harness Provider: <value>`. If it says `claude` when you expected `pi`, ensure `HARNESS_PROVIDER=pi` is in your env file and not being overridden.

ACP: "ACP target failed during startup" [#acp-acp-target-failed-during-startup]

`ACP_TARGET_COMMAND` didn't resolve, or the target process exited/errored during `initialize`/`newSession`. Confirm the binary is on `$PATH` inside the worker image (or use an absolute path), and that any args in `ACP_TARGET_ARGS` are valid for that target — try running the exact `ACP_TARGET_COMMAND` + `ACP_TARGET_ARGS` combination by hand first.

ACP: agent never calls swarm MCP tools [#acp-agent-never-calls-swarm-mcp-tools]

The target agent has to honor ACP's `newSession.mcpServers` field itself; the swarm adapter passes the swarm MCP server and every other installed MCP server into that field, but whether the target *connects* to them is up to its own implementation. There's no swarm-side fallback here — check the target agent's ACP docs for its MCP support status.

Related [#related]

* [Model Gateways](/docs/guides/provider-auth/model-gateways) — Route supported harnesses through OpenRouter or an OpenRouter-compatible proxy, with current Ramp Router gaps
* [Environment Variables](/docs/reference/environment-variables) — Full reference for all configuration variables including auth credentials
* [Deployment Guide](/docs/guides/deployment) — Deploy agents to production with Docker Compose
* [Getting Started](/docs/getting-started) — Initial setup including credential configuration
* [Architecture Overview](/docs/architecture/overview) — How harnesses fit into the overall system
* [Harness Providers](/docs/guides/harness-providers) — Engineering reference for the `ProviderAdapter` contract, including the reasoning/effort capability lookup and per-harness translation


# Adding a Harness Provider (/docs/guides/harness-providers)





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

<Callout type="info">
  For **configuring** an existing provider, see [Harness Configuration](/docs/guides/harness-configuration). This guide is for **implementing a new one**.
</Callout>

For an operator-facing comparison of the install choices, see the [Provider Capability Matrix](/docs/guides/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` (in-process [`@opencode-ai/sdk`](https://opencode.ai) server with SSE event mapping. Experimental rollout across DES-295 to DES-299), `acp` (a curated OpenCode preset or a custom [Agent Client Protocol](https://agentclientprotocol.com) command).

***

1\. The `ProviderAdapter` contract [#1-the-provideradapter-contract]

Source of truth: [`src/providers/types.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/types.ts).

```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 [#what-each-member-does]

| Member                        | Responsibility                                                                                                                                                |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`                        | Short identifier used in logs (`"claude"`, `"pi"`, `"codex"`).                                                                                                |
| `traits`                      | Advertise 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.onEvent`             | Register 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 [#providersessionconfig-inputs]

```ts
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) [#providerevent-the-normalized-stream]

Every provider translates its native events into this tagged union:

```ts
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. &#x2A;*Implementing this translation is the bulk of the work for a new provider.**

***

2\. Reference implementations [#2-reference-implementations]

| File                                                                                                                                      | Transport                                                                                                                                                                                                           | Auth                                                                                                                                                                                                                                                                        |
| ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`src/providers/claude-adapter.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/claude-adapter.ts)                 | `Bun.spawn` of `claude` CLI with `--output-format stream-json`, JSONL stdout parsing                                                                                                                                | `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`                                                                                                                                                                                                                            |
| [`src/providers/pi-mono-adapter.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/pi-mono-adapter.ts)               | In-process via `createAgentSession` from `@earendil-works/pi-coding-agent`; no subprocess                                                                                                                           | `ANTHROPIC_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](#pi-mono-amazon-bedrock-auth) below) |
| [`src/providers/codex-adapter.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/codex-adapter.ts)                   | Parent 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.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/claude-managed-adapter.ts) | Anthropic SDK `client.beta.sessions.events.stream` (SSE); session executes in Anthropic's managed cloud sandbox, worker is a thin relay                                                                             | `ANTHROPIC_API_KEY` plus pre-existing `MANAGED_AGENT_ID` + `MANAGED_ENVIRONMENT_ID` from one-time `claude-managed-setup` CLI (see §13)                                                                                                                                      |
| [`src/providers/devin-adapter.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/devin-adapter.ts)                   | Cognition Devin v3 REST API; worker polls the remote session                                                                                                                                                        | `DEVIN_API_KEY` + `DEVIN_ORG_ID`                                                                                                                                                                                                                                            |
| [`src/providers/opencode-adapter.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/opencode-adapter.ts)             | In-process `@opencode-ai/sdk` server with SSE event mapping                                                                                                                                                         | Provider-specific API key, commonly `OPENROUTER_API_KEY`                                                                                                                                                                                                                    |
| [`src/providers/acp-adapter.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/acp-adapter.ts)                       | `Bun.spawn` of the selected OpenCode preset or custom target, driven over stdio with `@agentclientprotocol/sdk`'s ndjson `ClientSideConnection`                                                                     | None swarm-side for the model provider — the ACP target owns its own model auth. It receives the worker's swarm API key as the swarm MCP bearer; a custom `ACP_TARGET_COMMAND` is operator-configured and trusted with that credential                                      |

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.

Local subprocess teardown [#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.

Live task steering [#live-task-steering]

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.

| Provider         | Advertised modes | `mode: "steer"` result | Notes                                                                                                                                      |
| ---------------- | ---------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `pi`             | `steer`, `queue` | `steered`              | Native `steer()` and `followUp()`.                                                                                                         |
| `claude-managed` | `steer`, `queue` | `steered`              | Sends ordered `user.message` events.                                                                                                       |
| `opencode`       | `queue`          | `queued`               | `promptAsync` can add input at a turn boundary; abort-and-re-prompt is not advertised because live E2E found it undeliverable.             |
| `devin`          | `queue`          | `queued`               | The message API accepts working sessions but does not guarantee interruption.                                                              |
| `claude`         | `queue`          | `queued`               | Raw CLI stream-json can queue only.                                                                                                        |
| `codex`          | `steer`, `queue` | `steered` or `queued`  | The per-task app-server receives native `turn/steer`. Queued input starts after the active turn ends. See below.                           |
| `acp`            | none             | n/a                    | ACP 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-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 [#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 [#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` remains disabled because Codex drops its `additionalContext`.

For user-facing modes, degradation, entry points, and message states, see [Steer a running task](/docs/guides/task-steering).

pi-mono + Amazon Bedrock auth (alpha) [#pi-mono--amazon-bedrock-auth-alpha]

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

Mode selection [#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).

`BEDROCK_AUTH_MODE=bearer` is a declared/validated value reserved for future bearer-token support; for now, workers in `bearer` mode fall through to the standard credential check (key / auth.json).

`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 [#credential-probe]

When Bedrock SDK 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, satisfiedBy: "sdk-delegated"`. 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`):

| Category       | Trigger example                                        | Hint                                       |
| -------------- | ------------------------------------------------------ | ------------------------------------------ |
| `aws-auth`     | `ExpiredTokenException`, `CredentialsProviderError`    | Run `aws sso login` or refresh credentials |
| `aws-throttle` | `ThrottlingException`, `ServiceQuotaExceededException` | Wait / request quota increase              |
| `aws-access`   | `AccessDeniedException: not authorized`                | Check IAM policy for `bedrock:*`           |
| `aws-model`    | `ValidationException`, `ResourceNotFoundException`     | Check `MODEL_OVERRIDE` and region          |

Accepted credential sources [#accepted-credential-sources]

Anything 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 [#configuration-reference]

| Key                 | Values                     | Default                                                                          |
| ------------------- | -------------------------- | -------------------------------------------------------------------------------- |
| `BEDROCK_AUTH_MODE` | `sdk` \| `bearer`          | inferred from `MODEL_OVERRIDE` prefix                                            |
| `AWS_REGION`        | any Bedrock-enabled region | **required** — 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 [#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) [#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 [#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](./cost-and-context-computation).

All four are wired together by the factory at [`src/providers/index.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/index.ts):

```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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/commands/runner.ts) so credential pool selection uses the same resolved value.

Switching providers without restart [#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 [#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:

| Provider         | Supported | Notes                                                                                                                          |
| ---------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `claude`         | Yes       | Via MCP `store-progress`; CLI extraction fallback if missed                                                                    |
| `claude-managed` | Yes       | Via MCP `store-progress`                                                                                                       |
| `codex`          | Yes       | Via MCP `store-progress`                                                                                                       |
| `opencode`       | Yes       | Via MCP `store-progress`                                                                                                       |
| `pi` (`pi-mono`) | Yes       | Via MCP `store-progress`                                                                                                       |
| `devin`          | Yes       | Via 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.

Reasoning / effort control [#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 and Codex-only: non-Codex harnesses 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 }`.

| Provider   | Transport                                                | Notes                                                                                                                                                                                                                                                                                                                                                                                                                  |
| ---------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `claude`   | `CLAUDE_CODE_EFFORT_LEVEL` env var                       | `off` 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. |
| `codex`    | `model_reasoning_effort` config field                    | `off` 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.    |
| `pi`       | `thinkingLevel` session option                           | Top-level sibling of `model` on `CreateAgentSessionOptions`; pi's native vocabulary already includes `off`.                                                                                                                                                                                                                                                                                                            |
| `opencode` | Provider-keyed `options` in the per-task `opencode.json` | `anthropic/*` models: `thinking.budgetTokens` (an internal numeric translation, not a user-facing knob). `openrouter/*` models: `reasoning.effort`. OpenAI-compatible models: `reasoningEffort`. `off` omits reasoning keys entirely (a noop application) — Opencode has no explicit off switch.                                                                                                                       |

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`](https://github.com/desplega-ai/agent-swarm/blob/main/thoughts/taras/research/2026-05-26-agent-reasoning-effort-runtime-control.md) for the full cross-harness normalization derivation.

***

3\. How a harness run fits into a task lifecycle [#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 [#the-flow]

All steps live in [`src/commands/runner.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/commands/runner.ts).

| Step                        | What it does                                                                                                                                       |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pollForTrigger`            | `GET /api/poll` until a `Trigger` is returned                                                                                                      |
| `buildPromptForTrigger`     | Constructs the user prompt from the trigger                                                                                                        |
| `fetchRelevantMemories`     | Enriches context (memory vectors + installed skills)                                                                                               |
| `buildSystemPrompt`         | Composes the full system prompt (see §6)                                                                                                           |
| `spawnProviderProcess`      | Calls `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.waitForCompletion` | Blocks until the adapter resolves                                                                                                                  |
| `ensureTaskFinished`        | `POST /api/tasks/{id}/finish`                                                                                                                      |
| `syncProfileFilesToServer`  | Session-end FS → DB sync of the agent's self-editable files (see below). Runs for every `hasLocalEnvironment` harness.                             |

<Callout type="info">
  **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.
</Callout>

**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`](https://github.com/desplega-ai/agent-swarm/blob/main/runbooks/harness-providers.md#per-task-outputschema-support) for the full precedence order.

Session-end identity / config sync (FS → DB) [#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`](https://github.com/desplega-ai/agent-swarm/blob/main/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:

| Provider                            | local environment | Session-end sync                                         |
| ----------------------------------- | ----------------- | -------------------------------------------------------- |
| `claude`, `pi`, `codex`, `opencode` | yes               | **Yes** — when the finished session ran on this provider |
| `devin`, `claude-managed`           | no                | No — 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.
* **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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/hooks/hook.ts)) and the pi extension ([`src/providers/pi-mono-extension.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/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` [#what-fields-on-the-task-become-providersessionconfig]

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

| `ProviderSessionConfig` field           | Comes from                                                                                                                                                                                              |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prompt`                                | `buildPromptForTrigger(trigger, task, memories, ...)`, optionally prefixed with a bounded follow-up context preamble when `task.parentTaskId` is set                                                    |
| `systemPrompt`                          | `buildSystemPrompt()` + `task.additionalSystemPrompt` (see §6)                                                                                                                                          |
| `model`                                 | `task.model` → `MODEL_OVERRIDE` env → `""`                                                                                                                                                              |
| `agentId`, `taskId`, `apiUrl`, `apiKey` | Worker env + task row                                                                                                                                                                                   |
| `cwd`                                   | `task.dir` → `repoContext.clonePath` → `process.cwd()` (L3050–3072)                                                                                                                                     |
| `resumeSessionId`                       | **Deprecated** — 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`. |
| `iteration`                             | Retry counter (runner state)                                                                                                                                                                            |
| `logFile`                               | `${logDir}/${timestamp}-${taskIdSlice}.jsonl` (L3102) — see §4                                                                                                                                          |
| `env`                                   | Merged worker env + task-provided env                                                                                                                                                                   |

API endpoints touched per task [#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 [#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`](https://github.com/desplega-ai/agent-swarm/blob/main/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` via `buildResumePrompt` (L809–829); 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.

Session-summary transcript sourcing [#session-summary-transcript-sourcing]

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 [#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 [#path-convention]

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

Constructed at [`runner.ts:3102`](https://github.com/desplega-ai/agent-swarm/blob/main/src/commands/runner.ts). `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` [#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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/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 [#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.

<Callout type="warn">
  **Secret scrubbing is mandatory at every log egress.** Import `scrubSecrets` from [`src/utils/secret-scrubber.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/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).
</Callout>

How raw logs reach the task details page [#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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/commands/runner.ts). 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`](https://github.com/desplega-ai/agent-swarm/blob/main/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`](https://github.com/desplega-ai/agent-swarm/blob/main/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 [#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 [#5-exposing-the-swarm-mcp-to-the-runtime]

<Callout type="warn">
  **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.
</Callout>

Where the MCP server lives [#where-the-mcp-server-lives]

The swarm API exposes its MCP server at `{apiUrl}/mcp`. Tools are defined under [`src/tools/`](https://github.com/desplega-ai/agent-swarm/tree/main/src/tools).

How each reference adapter wires it [#how-each-reference-adapter-wires-it]

| Provider   | Wiring                                                                                                                                                                                                                                  | File                                                                                                                                                                                                                                            |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Claude** | Discovers 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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/claude-adapter.ts) L117–184, L251                                                                                                                      |
| **Pi**     | Constructs `McpHttpClient(apiUrl, apiKey, agentId, taskId)`, calls `listTools()`, wraps each as a pi-mono `ToolDefinition` with prefix `mcp__<name>__`.                                                                                 | [`pi-mono-adapter.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/pi-mono-adapter.ts) L410–421, [`pi-mono-mcp-client.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/pi-mono-mcp-client.ts) L53 |
| **Codex**  | `buildCodexConfig` 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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/codex-adapter.ts) L132–243, L857–861                                                                                                                    |

Required headers [#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 [#key-tools-a-harness-will-call-mid-run]

Pretty labels at [`runner.ts:254–317`](https://github.com/desplega-ai/agent-swarm/blob/main/src/commands/runner.ts). 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 [#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](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/pi-mono-adapter.ts)) — 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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/codex-swarm-events.ts) and [`src/providers/pi-mono-extension.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/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 [#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 [#how-it-is-built]

Composed by `getBasePrompt(args)` in [`src/prompts/base-prompt.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/prompts/base-prompt.ts) and orchestrated by `buildSystemPrompt()` in [`src/commands/runner.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/commands/runner.ts). Static blocks come first, per-task blocks last, so the cached prefix stays stable across tasks. The pieces, in order:

| Source                                                                                 | Section                                                                                                                           | Gate                                                                                                                                                                |
| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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, secrets | Composite picked by traits, then role: no MCP → `worker.remote`, MCP without a local environment → `{lead\|worker}.managed`                                         |
| Template `system.agent.scripts_only_mode`                                              | Code-mode instructions                                                                                                            | `scriptsOnly`                                                                                                                                                       |
| Template `system.agent.outputs` or `...outputs.no_agent_fs`                            | `## Outputs`                                                                                                                      | MCP + `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 steering`                                                                                                           | `STEERING_ENABLED`, adapter `steerModes`, `core` capability                                                                                                         |
| Template `system.agent.tools_skills` (skills + MCP server lines interpolated)          | `## Tools and skills`                                                                                                             | MCP. 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 codex/opencode/pi, content differs from the generated default. Claude loads `~/.claude/CLAUDE.md` and `/workspace/CLAUDE.md` natively   |
| Template `system.agent.repository` (per-task pieces interpolated)                      | `## Repository`                                                                                                                   | Task 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 Profile`                                                                                                            | Requester has role, notes, or comms                                                                                                                                 |
| `SYSTEM_PROMPT` env / `--system-prompt`                                                | Appended                                                                                                                          | Operator config                                                                                                                                                     |
| `task.additionalSystemPrompt`                                                          | Appended per-task                                                                                                                 | Task field                                                                                                                                                          |

Inject-when-edited: IDENTITY.md and CLAUDE.md are compared (whitespace-normalized) with the generators in [`src/prompts/defaults.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/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.

<Callout type="info">
  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`.
</Callout>

How each adapter delivers the prompt [#how-each-adapter-delivers-the-prompt]

* **Claude** — CLI flag: `cmd.push("--append-system-prompt", this.config.systemPrompt)` at [`claude-adapter.ts:245–247`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/claude-adapter.ts).
* **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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/codex-adapter.ts); implementation at [`codex-agents-md.ts:56–119`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/codex-agents-md.ts). 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], ... })` passed via `CreateAgentSessionOptions` at [`pi-mono-adapter.ts:508–519`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/pi-mono-adapter.ts).

What this means for a new provider [#what-this-means-for-a-new-provider-1]

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) [#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 [#the-three-patterns]

<Tabs items="[&#x22;Native slash-commands&#x22;, &#x22;Prefixed slash-commands&#x22;, &#x22;Inline resolver&#x22;]">
  <Tab value="Native slash-commands">
    **Claude.** The CLI already knows about skills installed under `~/.claude/skills/<name>/SKILL.md`. The adapter just returns `/<name>`.

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

    See [`claude-adapter.ts:601`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/claude-adapter.ts).
  </Tab>

  <Tab value="Prefixed slash-commands">
    **Pi.** Pi-mono supports skills but namespaces them:

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

    See [`pi-mono-adapter.ts:541`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/pi-mono-adapter.ts). Skills live at `~/.pi/agent/skills/<name>/SKILL.md`.
  </Tab>

  <Tab value="Inline resolver">
    **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`](https://github.com/desplega-ai/agent-swarm/blob/main/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.
  </Tab>
</Tabs>

Where skill files come from [#where-skill-files-come-from]

The swarm still seeds skills into each provider's skill dir at container boot in [`docker-entrypoint.sh`](https://github.com/desplega-ai/agent-swarm/blob/main/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 [#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 [#step-1--scaffold-the-adapter]

Create `src/providers/foo-adapter.ts`:

```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 [#step-2--register-in-the-factory]

Edit [`src/providers/index.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/index.ts):

```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` [#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:**

* Claude JSONL branching: [`src/providers/claude-adapter.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/claude-adapter.ts) around L368–470.
* Codex `ThreadEvent` dispatcher: [`src/providers/codex-adapter.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/codex-adapter.ts) around L463–618.
* Pi `AgentSessionEvent` dispatcher: [`src/providers/pi-mono-adapter.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/pi-mono-adapter.ts) around L161+.

Step 4 — Emit `CostData` [#step-4--emit-costdata]

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

```ts
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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/codex-models.ts) for the Codex model/pricing resolver pattern).

Step 5 — Model selection [#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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/codex-models.ts) and `resolveModel` in [`src/providers/pi-mono-adapter.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/pi-mono-adapter.ts).

Step 6 — Credentials & auth [#step-6--credentials--auth]

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

<Tabs items="[&#x22;Env-var only&#x22;, &#x22;OAuth in swarm_config&#x22;, &#x22;File-based&#x22;]">
  <Tab value="Env-var only">
    Claude-style. Validate at adapter start; throw a clear error if missing. Example: `validateClaudeCredentials()` in [`src/providers/claude-adapter.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/claude-adapter.ts).
  </Tab>

  <Tab value="OAuth in swarm_config">
    Codex ChatGPT-style. See §6 below. This is the most involved path but required for desktop-login-style flows.
  </Tab>

  <Tab value="File-based">
    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.
  </Tab>
</Tabs>

<Callout type="warn">
  **Secret scrubbing**: any credential you log must go through the project's scrubber. See [`src/utils/secret-scrubber.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/utils/secret-scrubber.ts) and the `CLAUDE.md` "Secret scrubbing" section.
</Callout>

Step 7 — MCP server injection [#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** instantiates an `McpHttpClient` per HTTP/SSE server and registers tools prefixed `mcp__<name>__` (`pi-mono-adapter.ts` L408–493).
* **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) [#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`](https://github.com/desplega-ai/agent-swarm/blob/main/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`](https://github.com/desplega-ai/agent-swarm/blob/main/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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/hooks/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 [#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).
* **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`](https://github.com/desplega-ai/agent-swarm/blob/main/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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/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) [#step-10--worker-bootstrap-docker-entrypoint]

Edit [`docker-entrypoint.sh`](https://github.com/desplega-ai/agent-swarm/blob/main/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`](https://github.com/desplega-ai/agent-swarm/blob/main/Dockerfile.worker).

Step 11 — Login CLI (only if OAuth) [#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`](https://github.com/desplega-ai/agent-swarm/blob/main/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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/codex-oauth/storage.ts).
3. Add the CLI command at `src/commands/<foo>-login.ts`. Reference: [`src/commands/codex-login.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/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`](https://github.com/desplega-ai/agent-swarm/blob/main/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 [#step-12--types--enums]

Update union-type entries that enumerate providers:

* [`src/types.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/types.ts) — `HarnessProvider` union.
* [`templates/schema.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/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 [#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 [#step-14--tests]

Add at minimum:

* `src/tests/<foo>-adapter.test.ts` — unit tests for event translation (feed fake native events, assert emitted `ProviderEvent`s).
* 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 [#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](/docs/guides/harness-configuration) with the new provider's setup instructions.
* If the provider needs new HTTP endpoints, regenerate OpenAPI: `bun run docs:openapi`.

***

9\. Codex OAuth: the full reference flow [#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`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/codex-oauth/auth-json.ts) L37–49.

See also: [Codex OAuth setup guide](/docs/guides/provider-auth/codex-oauth).

Boot-time credential restoration: precedence and the standalone fallback [#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 [#10-pre-pr-checklist-for-a-new-provider]

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

```bash
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 [#11-files-to-touch--quick-checklist]

| Concern                                            | File(s)                                                                                           |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Adapter implementation                             | `src/providers/<foo>-adapter.ts`                                                                  |
| Factory registration                               | `src/providers/index.ts`                                                                          |
| Types / enums                                      | `src/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 wiring                                         | `src/cli.tsx` (`COMMAND_HELP`, command routing)                                                   |
| Docker bootstrap                                   | `docker-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 / pricing (optional)                        | `src/providers/<foo>-models.ts`                                                                   |
| Tests                                              | `src/tests/<foo>-*.test.ts`                                                                       |
| Integrations UI (optional)                         | `ui/src/lib/integrations-catalog.ts`                                                              |
| Docs                                               | `CLAUDE.md`, `README.md`, this guide, [Harness Configuration](/docs/guides/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 [#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 [#a-we-dont-agentscreate-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 [#b-the-claude-managed-setup-cli]

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

Implementation: [`src/commands/claude-managed-setup.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/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 `plugin/commands/*.md` skill 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 [#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**:

```ts
[
  { 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 [#d-x-source-task-id-is-dropped]

The MCP integration §5 calls out that every adapter MUST set `X-Source-Task-Id&#x60; on the swarm MCP connection. &#x2A;*`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` [#e-skill-upload-via-betaskillscreate]

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 skill content is the same `plugin/commands/*.md` body that other providers copy into `~/.claude/skills/<name>/SKILL.md` — so the source of truth stays in the repo.

f. SDK shape deviations to be aware of [#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`](https://github.com/desplega-ai/agent-swarm/blob/main/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 [#13-further-reading]

* [Provider Capability Matrix](/docs/guides/provider-capability-matrix) — what each install-time provider keeps, loses, or can unlock with another key.
* [Harness Configuration](/docs/guides/harness-configuration) — how to **use** the existing providers.
* [Codex OAuth setup](/docs/guides/provider-auth/codex-oauth) — end-user OAuth flow.
* [`CLAUDE.md`](https://github.com/desplega-ai/agent-swarm/blob/main/CLAUDE.md) — project-wide rules, especially "Architecture invariants" (DB boundary) and "Secret scrubbing".
* [`src/providers/types.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/types.ts) — canonical interface definitions.
* [`src/providers/codex-adapter.ts`](https://github.com/desplega-ai/agent-swarm/blob/main/src/providers/codex-adapter.ts) — the most feature-complete reference (OAuth + skills resolver + hooks + model resolver).


# Multi-runtime agents (/docs/guides/multi-runtime-agents)



Multi-runtime mode lets several worker processes serve one logical agent. Each
process gets its own runtime identity and liveness record, while the workers
still share the agent's task policy and queue identity.

When to use it [#when-to-use-it]

Use multi-runtime mode when you want to scale one logical agent horizontally.
Give every replica the same `AGENT_ID`. The control plane then treats the
replicas as one agent for task routing, status, and the logical
`AGENT_MAX_TASKS` limit.

Register separate agents instead when the workers need independent task
policies, credentials, workspace state, or operational ownership. Give each
agent its own `AGENT_ID`. Multi-runtime mode does not synchronize arbitrary
files between containers and does not turn separate workers into isolated
agents.

Enable it in this order [#enable-it-in-this-order]

Update every worker that will serve the agent before enabling the flag on the
API server:

1. Deploy a worker version that sends its runtime identity during registration,
   polling, and shutdown. The runner generates a random UUID once per process
   boot. You do not configure a runtime ID, and a restarted process receives a
   new one.
2. Keep `MULTI_RUNTIME_ENABLED` off while rolling the workers. Confirm that
   the updated workers have started and can register normally.
3. Enable `MULTI_RUNTIME_ENABLED` on the API server, then reload or restart
   the API as required by your configuration deployment.

Once the flag is on, registration without a runtime identity returns `400`, and
`POST /close` without `X-Runtime-Instance-ID` also returns `400`. This is why
the worker rollout must finish first. An old worker can otherwise fail to
register on its next reconnect, and its anonymous shutdown cannot take the
legacy agent-wide close path.

Authenticated clients can read `multiRuntimeEnabled` from `GET /api/stats` to
check the effective server setting. Use this field before interpreting an empty
runtime-instance list: it distinguishes "the feature is off" from "the feature
is on, but no runtime has registered yet." The value is evaluated per request,
so it follows configuration reloads without exposing feature flags on the
unauthenticated health endpoint.

The task-limit seed [#the-task-limit-seed]

On the first multi-runtime registration for an agent, Agent Swarm creates the
agent-scoped `AGENT_MAX_TASKS` policy from the agent's persisted `maxTasks`
value. This preserves the concurrency policy already in force. Later runtime
registrations do not overwrite that policy with their own reported capacity.

An existing `AGENT_MAX_TASKS` row is authoritative and repairs the agent's
enforcement mirror when a runtime registers. Change the policy through the
agent-scoped configuration setting when you want to change the logical limit.
Each runtime's reported slots remain process-level observability; they are not
an alias for the agent's logical policy.

Liveness and expiry [#liveness-and-expiry]

`RUNTIME_STALE_THRESHOLD_MIN` controls how long a runtime may go without a
fresh ping before it stops counting as live. The default is 5 minutes. Worker
traffic from the runtime, including the polling loop, refreshes `last_seen_at`.

* A graceful shutdown calls `POST /close` and retires only that process's
  runtime row. Sibling runtimes keep the logical agent online. If it was the
  last live runtime, the agent goes offline immediately.
* A crash, OOM kill, or network partition cannot call `/close`. The heartbeat
  sweep finds the stale row after the threshold, retires and prunes it, then
  recomputes the agent from the surviving runtime rows. The agent goes offline
  only when no live runtime remains.
* Expiry prevents new work from being dispatched to the dead process. It does
  not delete active sessions or decide that in-flight work failed. Session and
  task remediation uses the separate heartbeat crash-recovery paths.
* A task still in the `offered` state when the offeree goes offline — because
  its last runtime expired or closed — is released back to `unassigned` by the
  same heartbeat sweep, so it can be auto-assigned to another eligible agent
  in that same tick
  ([#1207](https://github.com/desplega-ai/agent-swarm/pull/1207)).

Shared workspace state [#shared-workspace-state]

Sharing an `AGENT_ID` means a task can continue on a different runtime than the
one that started it. If continuation depends on local repositories, generated
files, or other workspace state, mount a shared persistent workspace volume at
the worker's workspace path. If you cannot share the workspace, keep task
continuation reconstructible from the repository, control plane, or task
attachments.

This complete Compose file runs one API and three worker replicas. The worker
replicas use one `AGENT_ID` and one named volume at `/workspace/personal`. The
runtime identity is generated inside each worker at boot, so there is no
per-container runtime setting in the file.

Create a `.env` beside the file first:

```dotenv title=".env"
API_KEY=replace-with-a-secret
AGENT_ID=replace-with-one-stable-uuid
CLAUDE_CODE_OAUTH_TOKEN=replace-with-your-token
```

```yaml title="docker-compose.yml"
services:
  api:
    image: ghcr.io/desplega-ai/agent-swarm:latest
    pull_policy: always
    environment:
      API_KEY: ${API_KEY:?Set API_KEY in .env}
      MCP_BASE_URL: http://api:3013
    ports:
      - "3013:3013"
    volumes:
      - swarm_api:/app
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:3013/health || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 12
      start_period: 20s
    restart: unless-stopped

  worker:
    image: ghcr.io/desplega-ai/agent-swarm-worker:latest
    pull_policy: always
    depends_on:
      api:
        condition: service_healthy
    deploy:
      replicas: 3
    stop_grace_period: 60s
    environment:
      API_KEY: ${API_KEY:?Set API_KEY in .env}
      AGENT_ID: ${AGENT_ID:?Set AGENT_ID in .env}
      AGENT_ROLE: worker
      CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:?Set CLAUDE_CODE_OAUTH_TOKEN in .env}
      MCP_BASE_URL: http://api:3013
      TEMPLATE_ID: official/coder
      YOLO: "true"
    volumes:
      - swarm_logs:/logs
      - shared_agent_workspace:/workspace/personal
    restart: unless-stopped

volumes:
  shared_agent_workspace:
  swarm_api:
  swarm_logs:
```

Start it with:

```bash
docker compose up -d
docker compose ps
```

For a deployment that already has an API, keep the `worker` service and point
`MCP_BASE_URL` at that API instead. Do not give replicas separate workspace
volumes when the task workflow depends on local state.

Rollback [#rollback]

To roll back, turn `MULTI_RUNTIME_ENABLED` off on the API server and deploy the
legacy worker version if needed. Existing `runtime_instances` rows become
inert: legacy registration, ping, polling, and close semantics resume, and the
heartbeat expiry sweep does not retire those rows while the flag is off. The
agent-scoped `AGENT_MAX_TASKS` row remains stored but no longer controls
legacy registration. Re-enable multi-runtime only after all workers again
support the runtime identity contract.


# OAuth callback migration (/docs/guides/oauth-callback-migration)



The connections redesign consolidated every generic OAuth app onto a **single
static callback URL**. If you registered a script-connections OAuth app before
the redesign, add the new callback to your provider's app registration —
otherwise the next authorization will fail.

What changed [#what-changed]

Previously, each generic OAuth app authorized against a per-provider callback of
the shape:

```
<your-base-url>/api/oauth/<provider>/callback
```

After the redesign, every generic authorization redirects to one static callback
that never changes:

```
<your-base-url>/api/oauth/callback
```

The value is derived from the server's public base URL
(`PUBLIC_MCP_BASE_URL`). You can copy the exact URL from the OAuth-app
create/edit dialog or the app detail page in the dashboard.

Who is affected [#who-is-affected]

**Affected — generic script-connections OAuth apps** registered through the
pre-redesign flow (the ones you manage under **Connections → OAuth Apps**).
Their stored redirect URI still points at the legacy
`/api/oauth/<provider>/callback` path, but new authorizations are sent to the
static `/api/oauth/callback`. The dashboard flags these apps with an amber
warning triangle.

**Not affected:**

* **Tracker apps** (Linear, Jira) — they use their own dedicated callbacks,
  which are unchanged, and authorization still uses their stored redirect URIs.
* **MCP dynamic-client-registration (DCR) apps** — they use their MCP-specific
  callback and are never migrated.

The symptom [#the-symptom]

Existing tokens and token refresh keep working. The problem only surfaces on a
**new authorization or re-authorization**: the provider rejects the request with

```
error=redirect_uri_mismatch
```

because the static callback the swarm now sends is not in the provider app's
list of registered redirect URIs.

The fix [#the-fix]

Add the static callback to your provider app's registered redirect URIs:

1. Copy the static callback from the OAuth-app dialog or detail page — it looks
   like `<your-base-url>/api/oauth/callback`.
2. Open your provider's app/console (e.g. GitHub OAuth App settings, Google
   Cloud console, etc.).
3. Add `<your-base-url>/api/oauth/callback` to the **Authorized redirect URIs**
   (or equivalent) list.
4. Save, then re-authorize the app from **Connections → OAuth Apps** in the
   dashboard.

It is safe to keep the old `/api/oauth/<provider>/callback` entry registered
during the transition — the legacy callback route still completes any in-flight
authorizations. Once every app is re-authorized against the static callback, you
can remove the legacy entries.


# Observability with OpenTelemetry (/docs/guides/observability-opentelemetry)



Agent Swarm can emit OpenTelemetry traces and OTLP metrics from the API server and worker runners. Telemetry is disabled by default and turns on when `OTEL_EXPORTER_OTLP_ENDPOINT` is set.

The same wiring works with SigNoz Cloud, self-hosted SigNoz, Jaeger through an OTLP collector, Honeycomb, Grafana Tempo, Datadog, and other OTLP-compatible backends.

Setup [#setup]

SigNoz Cloud [#signoz-cloud]

Create or copy an ingestion key from SigNoz Cloud, then set these variables for the API and every worker:

```bash title=".env"
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.eu2.signoz.cloud
OTEL_EXPORTER_OTLP_HEADERS=signoz-ingestion-key=your-ingestion-key
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_SERVICE_NAME=agent-swarm
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,env=production,service.namespace=agent-swarm
```

Use the region-specific endpoint from your SigNoz Cloud account. For the EU2 region, use `https://ingest.eu2.signoz.cloud` as the base endpoint — the SDK appends `/v1/traces` automatically per the OTLP HTTP spec.

<Callout type="warn">
  Do not commit ingestion keys. `OTEL_EXPORTER_OTLP_HEADERS` is treated as a secret by Agent Swarm's scrubber, but it is still an active credential and should live in your secret manager or deployment environment.
</Callout>

Local Docker Compose [#local-docker-compose]

`docker-compose.local.yml` passes the OpenTelemetry variables through to the API, lead, Pi worker, and Codex worker services.

```bash
export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.eu2.signoz.cloud"
export OTEL_EXPORTER_OTLP_HEADERS="signoz-ingestion-key=your-ingestion-key"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_SERVICE_NAME="agent-swarm"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=local,env=local,service.namespace=agent-swarm"

docker compose -f docker-compose.local.yml up --build
```

The important local filter tags are:

| Attribute                 | Value                                             | Purpose                                                                                                                 |
| ------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `service.name`            | `agent-swarm-api` (API) / `agent-swarm` (workers) | The API process and worker processes report distinct service names — see [Service names](#service-names-api-vs-worker). |
| `deployment.environment`  | `local`                                           | Standard OpenTelemetry environment label.                                                                               |
| `env`                     | `local`                                           | Short convenience label for filtering local/dev traffic.                                                                |
| `service.namespace`       | `agent-swarm`                                     | Groups the API and worker services together — use this to query across both.                                            |
| `agentswarm.service.role` | `api`, `lead`, or `worker`                        | Distinguishes API, lead runner, and worker runner spans.                                                                |

Production Docker Compose [#production-docker-compose]

For production, add the same variables to the API service and every worker service in your compose file:

```yaml title="docker-compose.yml"
environment:
  - OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT}
  - OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS}
  - OTEL_EXPORTER_OTLP_PROTOCOL=${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf}
  - OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-agent-swarm}
  - OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-deployment.environment=production,env=production,service.namespace=agent-swarm}
```

`OTEL_SERVICE_NAME` sets the **base** service name (default `agent-swarm`). Agent Swarm derives the per-process `service.name` from it — see [Service names](#service-names-api-vs-worker) — so a single shared `OTEL_SERVICE_NAME` is all you need; no per-service wiring.

Service names: API vs worker [#service-names-api-vs-worker]

The API process and the worker (and lead) processes report **distinct** `service.name` values so they show up as separate service cards in SigNoz:

| Process              | `service.name`    |
| -------------------- | ----------------- |
| API server           | `agent-swarm-api` |
| Worker / lead runner | `agent-swarm`     |

Both are derived per process from `OTEL_SERVICE_NAME` (the base name, default `agent-swarm`): the API appends an `-api` suffix, workers use the base name unchanged. The suffix is applied even when `OTEL_SERVICE_NAME` is set identically across every process — a shared env var can't collapse the API and workers onto one name.

Both processes still set `service.namespace=agent-swarm`, so `service.namespace = 'agent-swarm'` is the filter to use when you want spans from **both** services in one query.

Filtering poll noise [#filtering-poll-noise]

Worker long-polls run continuously and account for the majority of span volume in a steady-state deployment. To keep your observability backend focused on real work, Agent Swarm skips the `worker.poll` (worker side) and the `/api/poll` request span (API side) by default.

To opt in — useful when debugging queue or claim behavior — set on every API and worker process:

```bash
OTEL_TRACE_POLL=1
```

Truthy values: `1`, `true`, `yes`, `on` (case-insensitive). Anything else (including unset/empty) keeps poll spans off. Expect span volume to roughly double when this is enabled.

How It Works [#how-it-works]

At startup, the API server and workers call `initOtel()`. If `OTEL_EXPORTER_OTLP_ENDPOINT` is absent, all tracing functions are no-ops. If it is present, Agent Swarm starts the OpenTelemetry Node SDK with an OTLP HTTP trace exporter.

The API and workers share trace context over HTTP headers. When a worker calls the API or an MCP tool triggers server-side work, Agent Swarm injects and extracts OpenTelemetry propagation headers so related spans can appear in the same trace where the execution path supports it.

Agent Swarm sets resource attributes once per process:

| Attribute                 | Source                                                                                                     |
| ------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `service.name`            | Per process: API → `<base>-api`, workers → `<base>`; base from `OTEL_SERVICE_NAME` (default `agent-swarm`) |
| `service.version`         | `package.json` version                                                                                     |
| `service.namespace`       | `OTEL_RESOURCE_ATTRIBUTES` or `agent-swarm`                                                                |
| `service.instance.id`     | `AGENT_ID` or a generated UUID                                                                             |
| `deployment.environment`  | `OTEL_RESOURCE_ATTRIBUTES`, `NODE_ENV`, or `development`                                                   |
| `env`                     | `OTEL_RESOURCE_ATTRIBUTES.env` or `deployment.environment`                                                 |
| `agentswarm.service.role` | Runtime role: `api`, `lead`, or `worker`                                                                   |

Sensitive exception messages, status messages, tool previews, and OTLP auth headers are scrubbed before they leave the process.

Traces Emitted [#traces-emitted]

API HTTP Requests [#api-http-requests]

Every API request is wrapped in a span named after its route, following the OpenTelemetry HTTP server semantic conventions: `{METHOD} {route-template}` — for example `GET /api/tasks/{id}` or `POST /api/tasks`.

The route template is **low-cardinality**: a request to `/api/tasks/abc-123` and one to `/api/tasks/def-456` share the span name `GET /api/tasks/{id}`, so SigNoz can group and aggregate by endpoint without raw IDs fragmenting the data. A handful of static core paths that don't go through the `route()` factory — `/health`, `/openapi.json`, `/docs`, `/me`, `/cancelled-tasks`, `/internal/reload-config`, `/mcp`, `/mcp-user` — are still fixed literal paths, so they get their own `http.route` too (e.g. `GET /health`). Everything else that doesn't match a registered route, including the MCP transport at a deeper path and genuine 404s, falls back to `{METHOD} /{first-segment}` (e.g. `POST /mcp/session-xyz/messages` → `POST /mcp`), or a bare `{METHOD}` for the root path. The full raw request path is always preserved on the `url.path` attribute.

The same route template is also published on the `http.route` span attribute, so SigNoz can group, filter, and aggregate by endpoint as a first-class field instead of parsing it out of the span name. `http.route` is **omitted** (not fabricated) for requests that don't match a registered route or one of the static core paths above.

Inbound API request spans are emitted with span kind `SERVER` (`SpanKind.SERVER`). This matters specifically for Datadog: its APM resource-name derivation only appends `http.route` to the resource name for `SpanKindServer` spans, so a `SERVER`-kind span with `http.route` set resolves to a per-endpoint Datadog resource (e.g. `GET /api/tasks/{id}`) instead of every request collapsing into one resource per HTTP method. Every other span kind emitted by Agent Swarm — the `mcp.tool` spans and worker-side spans — stays `INTERNAL`, since Datadog's method-based resource-name shortcut only fires on the HTTP method attribute regardless of kind and internal spans aren't inbound HTTP requests.

Request handling runs inside the HTTP server span's active context, so server-side spans created while serving the request — notably the `mcp.tool` spans from MCP tool calls — nest underneath it as children rather than appearing as disconnected root spans.

<Callout type="info">
  Earlier releases named every API request span `http.server`. If you have saved SigNoz queries or dashboards that filter on `name = 'http.server'`, switch them to filter on the `agentswarm.component = 'api'` attribute (set on every API span) and group by span `name` or the `http.route` attribute to get a per-endpoint breakdown.
</Callout>

Common attributes:

| Attribute                     | Description                                                                                                                                                  |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `http.request.method`         | HTTP method                                                                                                                                                  |
| `http.route`                  | Low-cardinality route template, e.g. `/api/tasks/{id}`. Also set for the static core paths (`/health`, `/mcp`, etc.). Omitted for genuinely unmatched paths. |
| `url.path`                    | Raw request path (full path, including IDs)                                                                                                                  |
| `url.scheme`                  | Request scheme — `https` or `http` (honors `X-Forwarded-Proto`)                                                                                              |
| `server.address`              | Request host with the port stripped (honors `X-Forwarded-Host`)                                                                                              |
| `network.protocol.version`    | HTTP protocol version, e.g. `1.1` or `2`                                                                                                                     |
| `user_agent.original`         | Raw `User-Agent` request header                                                                                                                              |
| `http.response.status_code`   | HTTP response status code (SigNoz surfaces this as the `responseStatusCode` column). Omitted on the premature-close path when no headers were ever sent.     |
| `agentswarm.component`        | `api`                                                                                                                                                        |
| `agentswarm.http.duration_ms` | Server-side request duration                                                                                                                                 |
| `agentswarm.http.aborted`     | `true` when the response connection closed before completion. Omitted (never `false`) otherwise.                                                             |

Premature response close [#premature-response-close]

Node emits `close` on a `ServerResponse` for both a completed response and a connection terminated early, so this path establishes that the response did not complete — not that the client was the actor. Agent Swarm's policy for it:

* `agentswarm.http.aborted` is set to `true`, and omitted entirely otherwise, so `agentswarm.http.aborted:true` is the query.
* `http.response.status_code` is omitted when no headers were sent, rather than reporting the pre-`writeHead` default of `200` for a response that never went out.
* The span status is left **Unset**. `close` gives the server no way to tell an intentional client cancellation from a response-write failure, and the dominant case here is the former: a normal SSE teardown on `/mcp` or `/mcp-user`. Marking every premature close an error would put routine traffic into the service error rate. This is a local policy choice, not a claim that such closes are never errors — filter on `agentswarm.http.aborted` rather than on span status to find them.

Useful for:

* API latency and error-rate dashboards
* task creation, polling, and completion request inspection
* checking whether workers are reaching the API

MCP Tool Calls [#mcp-tool-calls]

Server-side MCP tool handlers emit one span per call, named `mcp.tool <tool-name>` — for example `mcp.tool store-progress` — so the executed tool is readable straight from the trace tree. Tool names are a fixed enum, so the span name stays low-cardinality. These spans nest under the API HTTP request span that triggered them.

Common attributes:

| Attribute                        | Description                          |
| -------------------------------- | ------------------------------------ |
| `mcp.tool.name`                  | Registered MCP tool name             |
| `mcp.tool.result_content_count`  | Number of returned content items     |
| `mcp.tool.is_error`              | Whether the MCP result is an error   |
| `agentswarm.task.id`             | Source task ID when available        |
| `agentswarm.tool.args_preview`   | Scrubbed, truncated argument preview |
| `agentswarm.tool.result_preview` | Scrubbed, truncated result preview   |

Useful for:

* finding slow or failing MCP tools
* confirming tool calls are attached to a task
* comparing tool usage across agents and harnesses

Worker Polling [#worker-polling]

Worker poll loops emit `worker.poll` spans.

Common attributes:

| Attribute                            | Description                                                                               |
| ------------------------------------ | ----------------------------------------------------------------------------------------- |
| `agentswarm.poll.result`             | `empty`, `task_assigned`, `task_offered`, `pool_tasks_available`, or another trigger type |
| `agentswarm.worker.poll_timeout_ms`  | Long-poll timeout                                                                         |
| `agentswarm.worker.poll_interval_ms` | Worker poll interval                                                                      |

Useful for:

* seeing whether workers are idle or receiving tasks
* debugging queue and claim behavior
* spotting excessive polling or API contention

Worker Sessions [#worker-sessions]

Worker task execution emits `worker.session.create` and `worker.session` spans.

Common attributes:

| Attribute                        | Description                                              |
| -------------------------------- | -------------------------------------------------------- |
| `agentswarm.task.id`             | Logical task ID                                          |
| `agentswarm.task.real_id`        | Real task ID after pool-task claim resolution            |
| `agentswarm.agent.role`          | Agent role from the registered agent                     |
| `agentswarm.harness_provider`    | `claude`, `pi`, `codex`, `opencode`, or another provider |
| `agentswarm.provider.session_id` | Provider session ID when available                       |
| `agentswarm.session.cwd`         | Session working directory                                |
| `agentswarm.session.vcs_repo`    | VCS repo attached to the session when set                |
| `agentswarm.session.duration_ms` | Session duration                                         |
| `agentswarm.session.exit_code`   | Runner exit code                                         |
| `agentswarm.session.outcome`     | `ok` or `error`                                          |
| `gen_ai.request.model`           | Requested model                                          |
| `gen_ai.response.model`          | Model reported by the provider, when cost data exists    |
| `gen_ai.usage.input_tokens`      | Input token count, when available                        |
| `gen_ai.usage.output_tokens`     | Output token count, when available                       |
| `agentswarm.cost.total_usd`      | Provider-reported or computed session cost               |

Useful for:

* full task execution timelines
* model and provider comparisons
* task cost and token usage inspection
* finding sessions that failed before calling `store-progress`

Worker Tool Executions [#worker-tool-executions]

Worker-side provider events emit `worker.tool` or `worker.mcp.tool` spans.

Common attributes:

| Attribute                         | Description                                                                                                                                                                              |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agentswarm.tool.name`            | Raw tool name reported by the harness                                                                                                                                                    |
| `agentswarm.tool.normalized_name` | Normalized tool name                                                                                                                                                                     |
| `agentswarm.tool.kind`            | `mcp`, shell/tool, or provider-specific kind                                                                                                                                             |
| `agentswarm.tool.call_id`         | Provider tool-call ID when available                                                                                                                                                     |
| `mcp.tool.name`                   | MCP tool name when the worker calls an MCP tool                                                                                                                                          |
| `agentswarm.tool.duration_ms`     | Tool duration                                                                                                                                                                            |
| `agentswarm.tool.args_preview`    | Scrubbed, truncated args                                                                                                                                                                 |
| `agentswarm.tool.result_preview`  | Scrubbed, truncated result                                                                                                                                                               |
| `agentswarm.tool.missing_start`   | Result arrived without a matching start event                                                                                                                                            |
| `agentswarm.tool.implicit_close`  | Span closed at the assistant-message boundary because the adapter didn't emit a per-tool completion event. Applies to BOTH `worker.tool` and `worker.mcp.tool` under the Claude harness. |
| `agentswarm.tool.unclosed`        | Session ended before any `tool_end` or assistant-message boundary fired — should be very rare                                                                                            |

How tool spans close [#how-tool-spans-close]

Under the Claude SDK adapter, neither harness-side tools (Bash/Read/Edit/etc.)
nor MCP tools receive per-tool completion events in the JSONL stream. Both
kinds therefore close on the same path:

* **`worker.tool` and `worker.mcp.tool` (Claude harness)** — close at the next
  assistant-message boundary, tagged `agentswarm.tool.implicit_close=true`.
  `duration_ms` is wall-clock from `tool_start` until the next assistant turn,
  which includes tool execution plus the model round-trip after the tool
  result returned. Slight overcount, but real-ish; covers the typical case.
* **Other adapters that DO emit explicit `tool_end`** (e.g. Claude Managed
  Agents, Codex, opencode) — close on the `tool_end` event with
  `duration_ms` set to true execution time. No `implicit_close` attribute.
* **`agentswarm.tool.unclosed=true`** — safety net for spans where the
  session ended before *any* assistant-message boundary arrived (e.g. the
  session crashed mid-tool). Should be very rare; treat its presence as a
  signal worth investigating.

Useful for:

* shell command latency
* MCP usage inside full worker traces
* detecting tools that never completed
* finding repeated tool loops or unusually expensive tool phases

Provider, Progress, Context, and Compaction Events [#provider-progress-context-and-compaction-events]

Provider stream events are attached to active spans as attributes or events where possible.

Common attributes:

| Attribute                                | Description                         |
| ---------------------------------------- | ----------------------------------- |
| `agentswarm.provider.name`               | Provider name                       |
| `agentswarm.provider.event_name`         | Provider custom event name          |
| `agentswarm.provider.event_data_preview` | Scrubbed, truncated provider data   |
| `gen_ai.message.role`                    | Message role                        |
| `gen_ai.message.content_preview`         | Scrubbed, truncated message content |
| `agentswarm.progress.message`            | Scrubbed, truncated progress text   |
| `agentswarm.context.used_tokens`         | Context tokens used                 |
| `agentswarm.context.total_tokens`        | Total context window                |
| `agentswarm.context.percent`             | Context usage percent               |
| `agentswarm.compaction.trigger`          | Compaction trigger                  |
| `agentswarm.compaction.pre_tokens`       | Tokens before compaction            |

Claude Code Telemetry [#claude-code-telemetry]

The Claude Code CLI emits its own OpenTelemetry signal — metrics, log events, and (in beta) traces — from inside the worker subprocess. This is separate from the Agent Swarm spans described above: it is the model's own view of each interaction (`claude_code.interaction`), every LLM request, and every tool call.

Agent Swarm leaves Claude Code's telemetry **off by default** and never force-enables it. Two independent controls govern it:

1. **The operator enables Claude Code's exporters** through swarm config — the env vars below. This is what makes Claude Code emit anything at all.
2. **The `SWARM_ENABLE_HARNESS_OTEL` gate** makes the adapter inject a `TRACEPARENT` at spawn time so the harness's spans nest inside the worker's trace (and, for Claude Code, pins privacy-safe logging defaults). This is what makes the two services show up as **one end-to-end trace**. The same gate also covers Codex — see [Codex Telemetry](#codex-telemetry).

Enabling Claude Code's exporters [#enabling-claude-codes-exporters]

Claude Code reads the standard `OTEL_*` exporter variables — endpoint, headers, protocol — which Agent Swarm already forwards to the subprocess. To turn its telemetry on, set these as swarm config (global, or agent-scoped to roll out gradually):

```bash
CLAUDE_CODE_ENABLE_TELEMETRY=1        # master switch — required
CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1 # beta traces (claude_code.interaction et al.)
OTEL_TRACES_EXPORTER=otlp             # traces
OTEL_METRICS_EXPORTER=otlp            # token / cost / lines-of-code metrics
OTEL_LOGS_EXPORTER=otlp               # user_prompt / tool_use log events
```

These are independent of the `SWARM_ENABLE_HARNESS_OTEL` gate by design: an operator can run Claude Code telemetry on a single agent without any code change, and separately flip the gate when cross-service trace linking should turn on.

The `SWARM_ENABLE_HARNESS_OTEL` gate [#the-swarm_enable_harness_otel-gate]

`SWARM_ENABLE_HARNESS_OTEL` is the single gate for harness-subprocess trace linking — it covers **both Claude Code and Codex**. When it is truthy (`1`, `true`, `yes`, `on` — case-insensitive), the adapter, on every session spawn:

* **Injects `TRACEPARENT`** (and `TRACESTATE` when present) derived from the active `worker.session` span. A harness launched with this env var parents its own root span to the worker's trace instead of starting a disconnected root. (Claude Code reads `TRACEPARENT` in non-interactive `-p` mode; Codex's Rust OpenTelemetry SDK reads it via the standard `tracecontext` propagator.)
* **Pins Claude-Code-specific privacy defaults** (claude only) — `OTEL_LOG_USER_PROMPTS=0`, `OTEL_LOG_TOOL_DETAILS=0`, `OTEL_LOG_TOOL_CONTENT=0`, `OTEL_METRICS_INCLUDE_ACCOUNT_UUID=false`. These are only set when the operator has not already set them explicitly. Codex does not read these env vars — it has no equivalent.

The gate is read per-spawn from the resolved swarm config, so flipping it takes effect on the next session — no container restart required. When the gate is off, spawn behavior is unchanged: a harness with its own exporters enabled still emits, but its root span is disconnected from the worker trace.

**Migration note.** The gate was originally introduced as `SWARM_ENABLE_CLAUDE_CODE_OTEL`. That name is kept as a **deprecated alias** — a truthy value of *either* `SWARM_ENABLE_HARNESS_OTEL` or `SWARM_ENABLE_CLAUDE_CODE_OTEL` turns injection on for every harness. Prefer `SWARM_ENABLE_HARNESS_OTEL` in new config; the alias may be removed in a future release.

Expected SigNoz behavior [#expected-signoz-behavior]

* **A new `claude-code` service card** appears. Claude Code overrides `OTEL_SERVICE_NAME` with its own `service.name` for its spans — this is by design and expected. Agent Swarm does not strip or re-set it.
* **Span hierarchy.** With the gate on, Claude Code's spans nest inside the worker trace:

  ```
  worker.session
  └── worker.session.create
      └── claude_code.interaction
          ├── claude_code.llm_request
          └── claude_code.tool
  ```

  A complete-task query (`agentswarm.task.id = '<task-id>'`) then returns the worker's `worker.tool` spans **and** Claude Code's `claude_code.*` spans in a single trace.
* **Log events.** `user_prompt` and `tool_use` events are emitted with content redacted by default (see Privacy below).

<Callout type="warn">
  **Privacy posture.** Agent Swarm's `scrubSecrets` does **not** run on Claude Code's exported payloads — they travel straight from the Claude Code process to your OTLP backend. The gate keeps `OTEL_LOG_USER_PROMPTS`, `OTEL_LOG_TOOL_DETAILS`, and `OTEL_LOG_TOOL_CONTENT` all at `0` so prompt and tool content never leave the process. Do not flip these to `1` without a scrubbing story for Claude Code's payloads.
</Callout>

Rollout [#rollout]

`SWARM_ENABLE_HARNESS_OTEL` is off by default. Recommended sequence: enable Claude Code's exporters on one agent, flip the gate agent-scoped on that same agent to validate the nested trace in SigNoz, watch span volume, then widen to global config.

Codex Telemetry [#codex-telemetry]

The Codex CLI also emits its own OpenTelemetry traces. Like Claude Code, it starts a fresh root span unless it is handed a W3C trace context at spawn — so the same `SWARM_ENABLE_HARNESS_OTEL` gate injects `TRACEPARENT` into the Codex subprocess env, and Codex's Rust OpenTelemetry SDK parents its spans to the worker trace via the standard `tracecontext` propagator.

Enabling Codex's exporters [#enabling-codexs-exporters]

Codex configures its OTLP exporter through **TOML**, not env vars — an `[otel.exporter]` block in `~/.codex/config.toml` (`endpoint`, `headers`, `protocol`, plus `otel.trace_exporter` / `otel.metrics_exporter`). Setting that up is an operator config step and is **out of scope for the trace-linking gate**: `SWARM_ENABLE_HARNESS_OTEL` only injects `TRACEPARENT`; it does not enable Codex telemetry itself.

What the gate does for Codex [#what-the-gate-does-for-codex]

When `SWARM_ENABLE_HARNESS_OTEL` (or the deprecated `SWARM_ENABLE_CLAUDE_CODE_OTEL` alias) is on and a sampled `worker.session` span is active, the codex-adapter injects `TRACEPARENT` (and `TRACESTATE` when present) into the minimal env it builds for the Codex subprocess.

No privacy-default env vars are set for Codex — the `OTEL_LOG_*` switches are Claude-Code-specific and Codex does not read them. Codex's own redaction is governed by its TOML `otel.*` settings, which the operator controls separately.

With Codex's exporters enabled and the gate on, Codex's spans appear in the same end-to-end trace as `worker.session`, exactly as Claude Code's do.

Metrics [#metrics]

Agent Swarm emits both traces and OTLP metrics. The metric exporter runs on the same OTLP pipeline as traces (same `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_HEADERS`), so no additional endpoint configuration is needed.

Session Cost and Token Counters [#session-cost-and-token-counters]

Two cumulative counters are emitted by the API server each time a session-cost record is finalized (POST `/api/session-costs`). They cover every harness — `claude`, `claude-managed`, `codex`, `pi`, `opencode`, `devin`, `gemini` — with a single chokepoint.

| Metric                | Unit      | Description                                                                                       |
| --------------------- | --------- | ------------------------------------------------------------------------------------------------- |
| `agentswarm.cost.usd` | `{usd}`   | USD cost per finalized cost record. Not emitted for zero-cost sessions.                           |
| `agentswarm.tokens`   | `{token}` | Token count per finalized cost record, split by `token_type`. Not emitted for zero-count classes. |

Both counters share the same set of low-cardinality attributes:

| Attribute     | Values                                                            | Description                                                                                                                                                                             |
| ------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `harness`     | `claude`, `codex`, `pi`, `opencode`, `devin`, `gemini`, `unknown` | Harness provider that produced the session. `unknown` when the request omits the `provider` field.                                                                                      |
| `model`       | model identifier                                                  | The model key sent by the adapter (e.g. `claude-sonnet-4-6`, `gpt-4o`). Stripped of routing prefixes by the pricing-normalize path. Scrubbed through the secret scrubber before export. |
| `cost_source` | `harness`, `pricing-table`, `unpriced`                            | How the cost figure was derived. `harness` = adapter-reported; `pricing-table` = recomputed from seeded pricing rows; `unpriced` = provider/model pair has no pricing data.             |
| `is_error`    | `true`, `false`                                                   | Whether the session ended with an error.                                                                                                                                                |

`agentswarm.tokens` additionally carries:

| Attribute    | Values                                                                | Description                                                  |
| ------------ | --------------------------------------------------------------------- | ------------------------------------------------------------ |
| `token_type` | `input`, `output`, `cacheRead`, `cacheWrite`, `reasoning`, `thinking` | Token class. Only classes with a non-zero count are emitted. |

Database Retention Sweep [#database-retention-sweep]

The `src/be/db-retention.ts` sweep emits one metric point per table **attempt**, on both the success and the error path, so a failure is never silently absent from `retention: {}` on `GET /api/metrics` — it shows up as `outcome:error` here instead.

A tick makes up to two passes: pass 1 gives every enabled table its slice, and pass 2 revisits the tables pass 1 left undrained while budget remains. A table can therefore emit two points in one tick. A dry run runs pass 1 only, so it emits exactly one point per table per tick. Aggregate by `table` before reading a rate.

Emission is best-effort. Every span and metric call in the sweep is wrapped so a throwing exporter cannot fail a tick or turn a completed `DELETE` into a failed sweep.

| Metric                                          | Instrument | Unit      | Emitted                | Meaning                                                                                                                                                                                     |
| ----------------------------------------------- | ---------- | --------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agentswarm.db.retention.sweeps`                | Counter    | `{sweep}` | once per table attempt | One point per table attempt. `outcome` tag carries the terminal state.                                                                                                                      |
| `agentswarm.db.retention.rows_deleted`          | Counter    | `{row}`   | once per table attempt | Rows deleted. Always 0 when `dry_run:true`. An `outcome:error` point carries the rows that attempt had already committed before it failed — each batch autocommits, so those rows are gone. |
| `agentswarm.db.retention.backlog`               | Gauge      | `{row}`   | once per table attempt | Rows still older than the horizon at the end of the slice.                                                                                                                                  |
| `agentswarm.db.retention.batches`               | Counter    | `{batch}` | once per table attempt | DELETE statements issued.                                                                                                                                                                   |
| `agentswarm.db.retention.table_duration_ms`     | Histogram  | `ms`      | once per table attempt | Wall clock of one table's slice.                                                                                                                                                            |
| `agentswarm.db.retention.slowest_statement_ms`  | Gauge      | `ms`      | once per table attempt | Slowest single DELETE in the slice, measured as driver execution time. The event-loop stall signal.                                                                                         |
| `agentswarm.db.retention.statement_duration_ms` | Histogram  | `ms`      | once per DELETE        | Distribution of statement durations, measured as driver execution time.                                                                                                                     |
| `agentswarm.db.retention.batch_size`            | Gauge      | `{row}`   | once per table attempt | The adaptive batch size the table settled on.                                                                                                                                               |

"Driver execution time" is the time the statement spent inside the SQLite driver. It excludes the time the statement waited its turn behind other database operations and the backoff sleeps it spent bridging an external write lock, because nothing is executing then. Use `table_duration_ms` when you want the wall clock of a whole slice, including that waiting.

Tags on every metric:

| Tag                                                  | Values                                   |
| ---------------------------------------------------- | ---------------------------------------- |
| `table`                                              | `session_logs`, `agent_log`, `events`    |
| `dry_run`                                            | `true`, `false`                          |
| `outcome` (on `sweeps` and `table_duration_ms` only) | `converged`, `budget_exhausted`, `error` |

`outcome` takes `converged` (the table's backlog is fully drained), `budget_exhausted` (the tick's time budget ran out before the table drained — expected during a large initial backlog), or `error` (the sweep threw). An error is also visible as an ERROR span status on the `db.retention.table` span, with the exception recorded on that span.

Metric Temporality [#metric-temporality]

Temporality is NOT hardcoded. Set the following for Datadog or any delta-preferred backend:

```bash
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta
```

Omit it for backends (SigNoz, Prometheus) that prefer cumulative temporality (the SDK default). The retention histograms (`table_duration_ms`, `statement_duration_ms`) depend on the backend's OTLP histogram mapping like any other histogram; `agentswarm.db.retention.slowest_statement_ms` is a plain gauge and does not.

Example Dashboard Panels [#example-dashboard-panels]

| Panel                   | Metric query                                                                       |
| ----------------------- | ---------------------------------------------------------------------------------- |
| Total cost by harness   | `sum(agentswarm.cost.usd)`, group by `harness`                                     |
| Cost by model           | `sum(agentswarm.cost.usd)`, group by `model`                                       |
| Cost by pricing source  | `sum(agentswarm.cost.usd)`, group by `cost_source`                                 |
| Input tokens by harness | `sum(agentswarm.tokens)` where `token_type = 'input'`, group by `harness`          |
| Output tokens by model  | `sum(agentswarm.tokens)` where `token_type = 'output'`, group by `model`           |
| Cache efficiency        | `sum(agentswarm.tokens)` where `token_type = 'cacheRead'` ÷ `token_type = 'input'` |
| Error session cost      | `sum(agentswarm.cost.usd)` where `is_error = true`                                 |

Retention Monitors [#retention-monitors]

| # | Monitor              | Query                                                                                                                  | Catches                                                                                                                                                                                            |
| - | -------------------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Sweep errors         | sum of `agentswarm.db.retention.sweeps` where `outcome = error`, grouped by `table`, above 0 over 2 h                  | A sweep that throws. Fires within 2 ticks.                                                                                                                                                         |
| 2 | Backlog not draining | max of `agentswarm.db.retention.backlog`, grouped by `table`; alert when the 6-hour change is ≥ 0 and the value is > 0 | Every silent non-completion: errors, a too-slow sweep, a regression to the old decay. Build this one first — it is stated in the operator's terms and does not depend on knowing the failure mode. |
| 3 | Sweep absent         | no data for `agentswarm.db.retention.sweeps` for 3 h                                                                   | The sweep stopped running: crashed timer, lost config, a pod that never started it.                                                                                                                |
| 4 | Stall guard          | max of `agentswarm.db.retention.slowest_statement_ms`, grouped by `table`, above 2000 over 1 h                         | The adaptive sizer failing to hold the statement bound, before the 10-second liveness probe notices. Only driver execution counts, so waiting for the database lock cannot raise this on its own.  |

Trace-derived Metrics [#trace-derived-metrics]

In addition to the standalone counters above, SigNoz supports creating operational metrics from trace aggregations:

| Metric                       | Query shape                                                                                                                                                           |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| API request rate             | `rate(count())` over `agentswarm.component = 'api'`, grouped by span `name`                                                                                           |
| API p95 latency              | `p95(durationNano)` over `agentswarm.component = 'api'`, grouped by span `name`                                                                                       |
| API errors                   | `count()` where `hasError = true` or `responseStatusCode >= 500`                                                                                                      |
| Worker task throughput       | `count()` over `name = 'worker.session'`, grouped by `agentswarm.service.role` and `agentswarm.harness_provider`                                                      |
| Worker session p95 duration  | `p95(durationNano)` over `name = 'worker.session'`                                                                                                                    |
| Tool call volume             | `count()` over `name IN ('worker.tool', 'worker.mcp.tool')` plus `name LIKE 'mcp.tool %'` for server-side spans, grouped by `agentswarm.tool.name` or `mcp.tool.name` |
| Slow tools                   | `p95(durationNano)` over tool spans, grouped by tool name                                                                                                             |
| Cost by model (trace)        | `sum(agentswarm.cost.total_usd)` over session spans, grouped by `gen_ai.response.model`                                                                               |
| Token usage by model (trace) | `sum(gen_ai.usage.input_tokens)` and `sum(gen_ai.usage.output_tokens)`, grouped by model                                                                              |
| Poll behavior                | `count()` over `worker.poll`, grouped by `agentswarm.poll.result`                                                                                                     |

Useful SigNoz Queries [#useful-signoz-queries]

Use these in SigNoz Traces Explorer or as dashboard widget filters.

<Callout type="info">
  The API process reports `service.name = 'agent-swarm-api'` and workers report `service.name = 'agent-swarm'` (see [Service names](#service-names-api-vs-worker)). Queries that should span **both** filter on `service.namespace = 'agent-swarm'` instead; worker-only queries keep `service.name = 'agent-swarm'`.
</Callout>

All Local Agent Swarm Traffic [#all-local-agent-swarm-traffic]

```text
service.namespace = 'agent-swarm' AND env = 'local'
```

Production Traffic Only [#production-traffic-only]

```text
service.namespace = 'agent-swarm' AND deployment.environment = 'production'
```

A Complete Task Execution [#a-complete-task-execution]

```text
service.namespace = 'agent-swarm' AND agentswarm.task.id = '<task-id>'
```

Start here when debugging a concrete task. You should see session spans, tool spans, and any server-side MCP spans that carried the task ID.

Worker Sessions for One Harness [#worker-sessions-for-one-harness]

```text
service.name = 'agent-swarm'
AND name = 'worker.session'
AND agentswarm.harness_provider = 'pi'
```

Replace `pi` with `claude`, `codex`, `opencode`, or another provider.

Slow Worker Tools [#slow-worker-tools]

```text
service.name = 'agent-swarm'
AND name IN ('worker.tool', 'worker.mcp.tool')
AND durationNano > 5000000000
```

`durationNano > 5000000000` means slower than five seconds.

MCP Tool Calls for a Task [#mcp-tool-calls-for-a-task]

```text
service.namespace = 'agent-swarm'
AND agentswarm.task.id = '<task-id>'
AND (name LIKE 'mcp.tool %' OR name = 'worker.mcp.tool')
```

Server-side `mcp.tool <tool-name>` spans live under `agent-swarm-api` and `worker.mcp.tool` under `agent-swarm`, so this query filters on `service.namespace` to catch both. The `LIKE 'mcp.tool %'` pattern matches every per-tool span name (`mcp.tool store-progress`, `mcp.tool poll-task`, …).

Failed or Error-Spans [#failed-or-error-spans]

```text
service.namespace = 'agent-swarm' AND hasError = true
```

For HTTP 5xx responses:

```text
service.name = 'agent-swarm-api'
AND agentswarm.component = 'api'
AND responseStatusCode >= 500
```

Unclosed Tool Calls [#unclosed-tool-calls]

```text
service.name = 'agent-swarm'
AND agentswarm.tool.unclosed = true
```

This should be a rare safety-net signal: the session ended (or crashed)
before either an explicit `tool_end` or the assistant-message boundary fired.
For the typical Claude-harness flow, both `worker.tool` and `worker.mcp.tool`
spans close on the boundary with `agentswarm.tool.implicit_close=true`,
not `unclosed=true`.

Implicit-Closed Tool Calls [#implicit-closed-tool-calls]

```text
service.name = 'agent-swarm'
AND agentswarm.tool.implicit_close = true
```

This is the expected closure path under the Claude adapter for BOTH
`worker.tool` spans (Bash/Read/Edit/etc.) AND `worker.mcp.tool` spans —
the adapter doesn't emit per-tool completion events for either kind, so
the runner closes them at the next assistant-message boundary. `duration_ms`
is wall-clock from `tool_start` to the next assistant message, which slightly
overcounts the actual tool execution time (it includes the model round-trip
after the tool result returned). Adapter-emitted explicit `tool_end` spans
(Codex, opencode, Claude Managed Agents) won't have this tag.

Sessions with Cost Data [#sessions-with-cost-data]

Use the `agentswarm.cost.usd` OTLP metric counter for cost aggregations and dashboards. For per-span cost attribution in trace queries, filter on the `agentswarm.cost.total_usd` span attribute on `worker.session` spans:

```text
service.name = 'agent-swarm'
AND name = 'worker.session'
AND agentswarm.cost.total_usd > 0
```

Context Pressure [#context-pressure]

```text
service.name = 'agent-swarm'
AND agentswarm.context.percent >= 80
```

Dashboard Ideas [#dashboard-ideas]

Start with these panels:

| Panel                      | Signal                                                                          |
| -------------------------- | ------------------------------------------------------------------------------- |
| Cost by harness            | `sum(agentswarm.cost.usd)` metric, grouped by `harness`                         |
| Cost by model              | `sum(agentswarm.cost.usd)` metric, grouped by `model`                           |
| Token usage by type        | `sum(agentswarm.tokens)` metric, grouped by `token_type`                        |
| Cache efficiency           | `sum(agentswarm.tokens)` where `token_type=cacheRead` ÷ `input`                 |
| API request rate by route  | Trace count over `agentswarm.component = 'api'`, grouped by span `name`         |
| API p95 latency by route   | `p95(durationNano)` over `agentswarm.component = 'api'`, grouped by span `name` |
| Worker sessions by harness | Trace count over `worker.session`, grouped by `agentswarm.harness_provider`     |
| Worker session duration    | `p95(durationNano)` over `worker.session`, grouped by harness                   |
| Tool calls by name         | Trace count over tool spans, grouped by `agentswarm.tool.name`                  |
| Slowest tools              | `p95(durationNano)` over tool spans                                             |
| Poll outcomes              | Trace count over `worker.poll`, grouped by `agentswarm.poll.result`             |
| Errors by span name        | Trace count where `hasError = true`, grouped by `name`                          |

Troubleshooting [#troubleshooting]

No traces appear [#no-traces-appear]

Check that every process has the exporter endpoint:

```bash
docker compose exec api env | grep OTEL
docker compose exec lead env | grep OTEL
docker compose exec worker-1 env | grep OTEL
```

Then confirm the API is logging OTel startup:

```bash
docker compose logs api | grep OTel
```

If `OTEL_EXPORTER_OTLP_ENDPOINT` is empty, tracing is intentionally disabled.

SigNoz returns authentication errors [#signoz-returns-authentication-errors]

For SigNoz Cloud, make sure:

* `OTEL_EXPORTER_OTLP_ENDPOINT` is the base URL (no `/v1/traces` suffix — the SDK appends it)
* `OTEL_EXPORTER_OTLP_HEADERS` is exactly `signoz-ingestion-key=<key>`
* `OTEL_EXPORTER_OTLP_PROTOCOL` is `http/protobuf`
* the key belongs to the same SigNoz region as the ingest endpoint

Local and production traces are mixed [#local-and-production-traces-are-mixed]

Set explicit resource attributes in every environment:

```bash
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=local,env=local,service.namespace=agent-swarm
```

For production:

```bash
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,env=production,service.namespace=agent-swarm
```

Then filter with `env` or `deployment.environment`.

API and workers show as separate services [#api-and-workers-show-as-separate-services]

This is intentional. The API process reports `service.name = agent-swarm-api` and worker/lead processes report `service.name = agent-swarm` so they appear as separate service cards in SigNoz — see [Service names](#service-names-api-vs-worker).

To query across both at once, filter on `service.namespace = 'agent-swarm'` (set by every process) instead of `service.name`. Within a single service, `agentswarm.service.role` further splits `api`, `lead`, and `worker` spans.

Related [#related]

* [Deployment Guide](/docs/guides/deployment) - production Docker Compose setup
* [Environment Variables](/docs/reference/environment-variables) - OpenTelemetry environment variable reference
* [Telemetry](/docs/reference/telemetry) - anonymized product telemetry, separate from your OpenTelemetry traces


# Performance & Resource Sizing (/docs/guides/performance-resource-sizing)





This guide documents practical container sizing numbers for Agent Swarm workers, leads, and light specialist agents. The recommendations come from production-style container metrics observed over a 4-hour SigNoz window, plus the operational lessons from investigating two recurring false alarms: a CPU graph that climbed in a perfect straight line and memory that looked stuck high after a heavy coding session.

Recommended Container Sizes [#recommended-container-sizes]

Use role-specific sizing instead of giving every container the same budget. A heavy coding worker has a different resource profile than a lead agent or an idle content/review worker.

| Container role       | Observed usage                                                            | Recommendation                  | Notes                                                                                                                                                                                                |
| -------------------- | ------------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Heavy worker         | Peaks around 1.4 CPU cores and 2.0 GB RAM during an active coding session | >=2 vCPU burst, 2-3 GB RAM      | Use this for implementation workers running Codex or Claude on real repo work. The extra CPU headroom matters during tool-heavy sessions, TypeScript checks, tests, and provider subprocess startup. |
| Lead / orchestrator  | Peaks around 0.8 CPU cores and 830 MB RAM                                 | About 1 vCPU, 1 GB RAM          | Leads coordinate task intake, Slack updates, delegation, and review loops. They need less memory than implementation workers but should not be starved.                                              |
| Light / idle workers | Idle baseline around 3% CPU and 200-290 MB RAM                            | About 0.5 vCPU, 512 MB-1 GB RAM | Reviewer, tester, content, UX, and similar specialist workers can run smaller when they are not expected to perform heavy local builds.                                                              |

<Callout type="info">
  These are operating recommendations, not hard minimums. If a worker runs large local builds, browser automation, Docker-in-Docker, or repository-wide test suites, size it like a heavy worker even if its role name sounds light.
</Callout>

Kubernetes Sizing [#kubernetes-sizing]

For Kubernetes, set requests to the steady-state budget the scheduler should reserve and limits to the burst ceiling each pod can use during an active session. Heavy workers need the widest gap between request and limit because build tools, language servers, test runners, and provider subprocesses can spike together.

<Callout type="warn">
  Kubernetes is stricter about memory bursts than a generated Docker Compose or single-host deployment. Exceeding `limits.memory` triggers an immediate OOMKill, and pods running above `requests.memory` are the first eviction candidates under node memory pressure. Compose sets no hard memory ceiling by default, so size Kubernetes requests near the real working set and give limits real headroom. A practical heavy-worker limit is `MAX_CONCURRENT_TASKS * per-session peak (~2 GB) + page-cache headroom`; at the default `MAX_CONCURRENT_TASKS=1`, one heavy coding session peaks around 2 GB, and raising the setting multiplies that peak. For critical lead pods, setting memory request equal to memory limit gives Guaranteed QoS, so the pod is not evicted under node memory pressure; the lead's observed peak is modest enough that a small equal request/limit is usually sufficient.
</Callout>

| Pod type             | Suggested replicas | CPU request | CPU limit | Memory request | Memory limit | Notes                                                                                  |
| -------------------- | -----------------: | ----------: | --------: | -------------: | -----------: | -------------------------------------------------------------------------------------- |
| API server           |                1-2 |        500m |     1 CPU |        512 MiB |        1 GiB | Run 2 replicas when the database and storage layer support the deployment topology.    |
| Lead agent           |                  1 |        750m |   1.5 CPU |        768 MiB |      1.5 GiB | Coordination-heavy pods benefit from low latency more than high memory.                |
| Heavy worker         |          1 per pod |       1 CPU |     2 CPU |          2 GiB |        3 GiB | Best default for coding agents, repo-wide checks, and tool-heavy implementation.       |
| Light worker         |          1 per pod |        250m |      750m |        512 MiB |        1 GiB | Suitable for review, content, triage, and low-build inspection tasks.                  |
| Browser / E2E worker |          1 per pod |       1 CPU |   2-3 CPU |          2 GiB |        4 GiB | Browser automation and test fixtures need memory headroom beyond normal worker sizing. |

| Worker class                         | Recommended pod shape | Concurrency per pod (`MAX_CONCURRENT_TASKS`) | Scaling rule                                                                                   |
| ------------------------------------ | --------------------- | -------------------------------------------: | ---------------------------------------------------------------------------------------------- |
| Heavy coding                         | 1 agent per pod       |                      1 active task (default) | Scale by adding pods, not by packing multiple coding sessions into one container.              |
| Light specialist                     | 1 agent per pod       |                      1 active task (default) | Scale horizontally when queue latency matters.                                                 |
| Thin relay / managed-provider worker | 1 agent per pod       |                             1-2 active tasks | Increase only if the provider runtime executes outside the worker and local tool use is light. |

<Callout type="info">
  For Kubernetes autoscaling, use queue depth, task age, and active-session count as the primary signals. CPU alone can under-scale idle-but-backlogged swarms and over-scale during short local build bursts.
</Callout>

Docker Compose on a Single Host [#docker-compose-on-a-single-host]

On a single VPS or bare-metal host, leave reserve capacity for the database, Docker, the kernel page cache, logs, and deploy-time overlap. A practical rule is to allocate only 70-80% of host memory to steady-state containers and keep at least 1-2 vCPU uncommitted on busy boxes.

| Service                                  |   CPU budget |    RAM budget |         Replicas | Notes                                                                            |
| ---------------------------------------- | -----------: | ------------: | ---------------: | -------------------------------------------------------------------------------- |
| API server                               |       1 vCPU |         1 GiB |                1 | Keep close to the database. Increase CPU if API latency rises during task churn. |
| Lead agent                               |       1 vCPU |         1 GiB |                1 | Usually one lead is enough for a small to medium swarm.                          |
| Heavy worker                             | 2 vCPU burst |       2-3 GiB | By host capacity | Count each active coding worker as the main unit of capacity.                    |
| Light worker                             |   0.5-1 vCPU | 512 MiB-1 GiB | By host capacity | Good filler capacity after heavy workers are reserved.                           |
| Observability / proxy / support services |   0.5-1 vCPU | 512 MiB-2 GiB |           1 each | Include these before calculating worker slots.                                   |

Illustrative Hetzner-style VPS tiers:

| Example host class               | Approx. host resources | Recommended swarm shape                                    | Notes                                                                                                                  |
| -------------------------------- | ---------------------: | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Small VPS                        |     4 vCPU / 8 GiB RAM | API + lead + 1 heavy worker + 1-2 light workers            | Good for evaluation, demos, and low-concurrency self-hosting.                                                          |
| Medium VPS                       |    8 vCPU / 16 GiB RAM | API + lead + 3 heavy workers + 2-4 light workers           | Practical baseline for a small production team.                                                                        |
| Large VPS / small dedicated host |   16 vCPU / 32 GiB RAM | API + lead + 6-8 heavy workers + 4-8 light workers         | Keep 6-8 GiB free for OS cache, logs, deploy overlap, and occasional spikes.                                           |
| Dedicated build host             |   32 vCPU / 64 GiB RAM | API + lead + 12-16 heavy workers + light workers as needed | Useful when many workers run tests or builds locally. Split database/storage if API latency or disk I/O becomes noisy. |

Agent Concurrency Settings [#agent-concurrency-settings]

Agent Swarm scales most predictably when each local-runtime worker runs one active task at a time. The per-worker concurrency knob is `MAX_CONCURRENT_TASKS`; the generated Docker Compose default is `MAX_CONCURRENT_TASKS=1`, meaning one active task per worker. Increasing it can be useful for thin relay workers or low-tool tasks, but it multiplies memory peaks and makes local builds contend inside the same container.

| Concurrency profile |                                       Worker count | `MAX_CONCURRENT_TASKS` per worker | Total active tasks | Recommended host or cluster budget            | When to use                                                                                 |
| ------------------- | -------------------------------------------------: | --------------------------------: | -----------------: | --------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Evaluation          |                            1 lead + 1 heavy worker |                                 1 |                  1 | 2-4 vCPU, 4-8 GiB RAM                         | Trial deployments and occasional coding tasks.                                              |
| Small team          |       1 lead + 2-3 heavy workers + 2 light workers |                                 1 |                4-5 | 8 vCPU, 16 GiB RAM                            | Several independent tasks per day with room for reviews and content work.                   |
| Busy team           |       1 lead + 6-8 heavy workers + 4 light workers |                                 1 |              10-12 | 16 vCPU, 32 GiB RAM                           | Regular parallel implementation, review, and QA loops.                                      |
| High throughput     | 1-2 leads + 12-16 heavy workers + 8+ light workers |                                 1 |                20+ | 32+ vCPU, 64+ GiB RAM or Kubernetes node pool | Sustained task queues where horizontal scale matters more than single-host simplicity.      |
| Thin relay workers  |                                Depends on provider |                                 2 |             Varies | Add 512 MiB-1 GiB RAM per extra active task   | Only for providers where execution happens outside the worker and local tooling is minimal. |

| Knob                                               | Resource effect                                          | Recommendation                                                                        |
| -------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Number of worker containers                        | Adds near-linear CPU/RAM capacity and isolation          | Preferred scaling lever for local-runtime agents.                                     |
| `MAX_CONCURRENT_TASKS` (parallel tasks per worker) | Multiplies per-container peak memory and tool contention | Keep the default `1` for coding, builds, browser automation, and repo-wide tests.     |
| Heavy-worker ratio                                 | Determines how many implementation tasks can run at once | Size from expected active coding sessions, not total agent count.                     |
| Light-worker ratio                                 | Adds review, triage, content, and QA capacity cheaply    | Use remaining host capacity after API, lead, and heavy workers are reserved.          |
| Provider credential pools                          | Avoids provider-side rate or session contention          | Match credential slots to the maximum number of concurrent workers for that provider. |

Harness Provider Profiles [#harness-provider-profiles]

Agent Swarm can run several harness providers. Their runtime behavior matters when you assign work to specific worker containers.

| Harness provider | Typical roles                                 | Operational profile                                                                                                                                                                                                           |
| ---------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `claude`         | Lead, research, broad reasoning               | Stable and reliable for broad reasoning, coordination, and work where continuity matters.                                                                                                                                     |
| `codex`          | Implementation, review, structured validation | Stable with deterministic output behavior. Good fit for structured-output tasks, litmus work, code review, and implementation sessions that benefit from precise tool use.                                                    |
| `pi`             | Content, QA, UX inspection                    | Fine for content and QA-style work. Size by the actual task profile: content and inspection can be light, but browser or build-heavy QA can require more headroom.                                                            |
| `opencode`       | Lightweight review and discovery              | Less suitable for determinism-critical work when deployments observe intermittent `opencode session error` crashes near task start. Retarget canonical workflow nodes to `claude` or `codex` when output determinism matters. |

Reading CPU Correctly [#reading-cpu-correctly]

The most common CPU false alarm is reading a cumulative counter as if it were an instantaneous utilization gauge.

`container.cpu.utilization` can look like a perfectly linear post-deploy CPU climb when a dashboard plots it with average time aggregation. That line is not necessarily real load. It is a cumulative-since-boot counter being averaged over time, so the average mechanically rises until the container restarts. The tell is that it resets on each deploy.

For instantaneous CPU, plot a rate query such as:

```text
rate(container.cpu.usage.total)
```

<Callout type="warn">
  Never make CPU sizing decisions from an avg-aggregated cumulative counter. If the graph climbs in a clean straight line and resets on deploy, first check whether the panel should use `rate` instead of `avg`.
</Callout>

A real-world SigNoz dashboard investigation hit this exact issue. The fix was to change the Container CPU Percent panel from average time aggregation to rate aggregation. After that, the real instantaneous CPU line was flat instead of climbing.

Reading Memory Correctly [#reading-memory-correctly]

`container.memory.usage.total` can look stuck high after a heavy worker session. That does not automatically mean the worker is leaking memory.

Two effects stack together:

1. **cgroup page cache.** Coding sessions read and write many files. File-backed pages stay in the container's memory accounting as page cache. That cache is reclaimable, and the kernel usually keeps it until there is pressure because free RAM is wasted RAM.
2. **Long-lived Bun/Node high-water mark.** When the runner, provider adapter, or child process allocates memory during a session, the JavaScript runtime may free heap internally without returning that RSS to the operating system. The process can sit near its session peak until it restarts.

The practical tell is redeploy behavior. In the investigation that produced this guide, a coding worker plateaued around 1,145 MB at idle, peaked at 1,979 MB during a heavy session, and reset to about 470 MB after the next redeploy. That pattern is consistent with page cache plus runtime high-water mark, not a continuously growing leak.

For a better "real memory" panel, plot working set instead of total usage:

```text
container.memory.usage.total - container.memory.inactive_file
```

The exact metric names vary by collector, but the idea is the same: subtract inactive file-backed cache from total container memory so reclaimable page cache does not look like unreclaimable application heap.

<Callout type="info">
  Use `usage.total` for capacity planning and OOM risk. Use working set for leak triage. They answer different questions.
</Callout>

Recent Improvements [#recent-improvements]

Two fixes came out of the same operational thread:

* PR #675 bounded two real accumulators: runner task-keyed VCS/cancel bookkeeping after completed tasks leave `state.activeTasks`, and API-side MCP owner/user session transports that survived unclean disconnects. The PR also added focused unit coverage for MCP idle transport cleanup.
* The SigNoz Container CPU Percent dashboard panel was corrected from average aggregation on a cumulative counter to a rate-based view, removing the fake post-deploy CPU climb.

Practical Sizing Checklist [#practical-sizing-checklist]

Before changing container limits, answer these in order:

1. Is the worker doing heavy local code work, or mostly coordination/content/review?
2. Is the CPU panel using a rate over cumulative CPU usage, not an average of a monotonic counter?
3. Is the memory panel showing total usage, working set, or heap/RSS from inside the process?
4. Does apparent memory growth reset on redeploy?
5. Is the harness provider appropriate for the task's reliability and determinism requirements?

If the metrics pass those checks, size heavy coding workers with real headroom, keep leads around 1 vCPU / 1 GB, and run light specialists smaller until their actual workload says otherwise.


# Personalization & Status (/docs/guides/personalization)



The home page (`/`) and sidebar adapt to your deployment via two layers: **identity envs*&#x2A; (cosmetic — name, logo, brand color, cloud flag) and the **`/status` endpoint** (live setup readiness + activity).

Identity envs [#identity-envs]

All identity envs are read on every `/status` request. Set them in your `.env`, Docker compose, or via `swarm_config` — global-scope writes auto-trigger a reload (debounced \~250ms) so the new value lands in `process.env` and integrations re-init without an explicit `POST /api/config/reload`. Unset envs fall back to neutral defaults.

| Env                      | Default             | Where it shows                                                                                                                                                                                                                                          |
| ------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWARM_ORG_NAME`         | `"Swarm"`           | Sidebar header (next to logo). Also sent as `metadata.organization_name` on every anonymized telemetry event when set.                                                                                                                                  |
| `SWARM_ORG_ID`           | none                | Stable org/tenant identifier exposed on `/status` as `identity.org_id` and sent as `metadata.organization_id` on every anonymized telemetry event when set. Set by the orchestrator on cloud deployments; safe to leave unset on self-host.             |
| `SWARM_ORG_LOGO_URL`     | bundled `/logo.png` | Sidebar header logo. Any HTTPS URL. If the URL fails to load, the sidebar reverts to the bundled logo.                                                                                                                                                  |
| `SWARM_BRAND_COLOR`      | none                | Tints the org name in the sidebar header. Any CSS color (`#a855f7`, `rebeccapurple`, etc.).                                                                                                                                                             |
| `SWARM_CLOUD`            | `false`             | When `true`, marks the deployment as cloud-hosted. Gates the user-menu Docs/Support/Billing items (Phase 2), suppresses the self-host marketing link, and is sent as `metadata.is_cloud` (boolean, always present) on every anonymized telemetry event. |
| `SWARM_MARKETING_URL`    | none                | Footer marketing link target on self-hosted deployments (Phase 2). Suppressed when `SWARM_CLOUD=true` or `SWARM_HIDE_CLOUD_PROMO=true`.                                                                                                                 |
| `SWARM_HIDE_CLOUD_PROMO` | `false`             | Force-hide the marketing footer regardless of `SWARM_CLOUD`. Useful for self-hosted swarms that don't want promotional UI.                                                                                                                              |
| `SWARM_VERIFY_TTL_MS`    | `3_600_000` (1h)    | How long a successful "Test connection" click keeps the harness milestone in `verified` state before re-asking. In-memory; lost on API restart.                                                                                                         |
| `AGENT_FS_API_URL`       | none                | If set, the home "Storage" card shows the agent-fs base URL with an "Open" button. If unset, the card prompts setup with a link to [agent-fs.dev](https://agent-fs.dev).                                                                                |

Examples [#examples]

```bash
# Custom-branded self-hosted swarm
SWARM_ORG_NAME="Acme Engineering"
SWARM_ORG_LOGO_URL="https://acme.example.com/logo.png"
SWARM_BRAND_COLOR="#ff5500"
SWARM_MARKETING_URL="https://swarm.acme.example.com"

# Cloud deployment
SWARM_CLOUD=true
SWARM_ORG_NAME="Acme on swarm.example.com"
SWARM_ORG_LOGO_URL="https://swarm.example.com/logo.png"

# Vanilla self-hosted, no marketing
# (all identity envs unset — defaults apply)
```

`GET /status` [#get-status]

The single source of truth the UI leans on for "what does this swarm look like, what's set up, what's missing." Cheap (env reads + one SQL aggregate), zero side effects, no upstream calls.

Response shape [#response-shape]

```jsonc
{
  "identity": {
    "name": "Acme Engineering",
    "logo_url": "https://acme.example.com/logo.png",
    "brand_color": "#ff5500",
    "is_cloud": false,
    "marketing_url": "https://swarm.acme.example.com",
    "hide_cloud_promo": false,
    "org_id": "org_acme_123"
  },
  "setup": [
    {
      "id": "harness",
      "label": "Harness configured",
      "state": "verified",
      "hint": "Live test passed within the last hour.",
      "action_url": "/integrations",
      "provider": "claude"
    },
    // … 7 more milestones
  ],
  "activity": {
    "agents_online": 3,
    "leads_online": 1,
    "recent_tasks_count": 42
  },
  "agent_fs": {
    "configured": true,
    "base_url": "http://agent-fs:7777"
  }
}
```

The full schema (Zod-validated) is in `src/http/status.ts`. See the auto-generated [API reference](/docs/api-reference/status) for the wire contract.

Setup milestones [#setup-milestones]

Eight milestones in fixed order. Each carries a `state`: `unverified` (not even configured), `configured` (env present but never live-tested), or `verified` (live-tested OR DB-backed proof).

| Milestone    | `configured` rule                                                            | `verified` rule                                                                                                                        |
| ------------ | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `harness`    | `HARNESS_PROVIDER` set + matching cred env present                           | A successful `POST /status/test-connection` within `SWARM_VERIFY_TTL_MS`                                                               |
| `embeddings` | `OPENAI_API_KEY` or `EMBEDDING_API_KEY` set                                  | (No live verification; this optional milestone never degrades system health)                                                           |
| `slack`      | `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` + `!SLACK_DISABLE`                     | Same (Socket Mode connection state not exposed today)                                                                                  |
| `github`     | `GITHUB_WEBHOOK_SECRET` + `GITHUB_APP_ID` + `GITHUB_APP_PRIVATE_KEY`         | Same (App installations validated JIT)                                                                                                 |
| `linear`     | Row in `oauth_tokens(provider='linear')`                                     | Same. Hint mentions the keepalive caveat — refresh-failure tracking is a future migration; check `#swarm-alerts` for keepalive errors. |
| `jira`       | Row in `oauth_tokens(provider='jira')` AND `oauth_apps.metadata.cloudId` set | Same as `linear`                                                                                                                       |
| `workers`    | ≥1 row in `agents`                                                           | ≥1 lead AND ≥1 worker with heartbeat in the last 5 min                                                                                 |
| `first_task` | (never `configured`)                                                         | ≥1 row in `agent_tasks` with `status='completed'`                                                                                      |

The `harness` milestone also carries a typed `provider?: ProviderName` field so the UI knows which provider name to send to `/status/test-connection`.

Test-connection [#test-connection]

`POST /status/test-connection` issues a real upstream call for the configured provider. Credential acceptance mirrors what each adapter accepts at runtime — OAuth users (Claude Pro/Max via `claude` CLI login, Codex ChatGPT OAuth) work without any API-key envs set.

| Harness          | Accepted credentials (in resolution order)                                      | Validation                                                                            |
| ---------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `claude`         | `CLAUDE_CODE_OAUTH_TOKEN` (Pro/Max OAuth) → `ANTHROPIC_API_KEY`                 | OAuth: presence check.<br />API key: live `GET /v1/models` (`x-api-key`).             |
| `claude-managed` | `ANTHROPIC_API_KEY` (managed-agents path is API-key only)                       | Live `GET /v1/models` (`x-api-key`).                                                  |
| `codex`          | `CODEX_OAUTH` (ChatGPT OAuth JSON blob; `.access` non-empty) → `OPENAI_API_KEY` | OAuth: presence check.<br />API key: live `GET /v1/models` (`Authorization: Bearer`). |
| `pi`             | `OPENROUTER_API_KEY` → `ANTHROPIC_API_KEY` → `OPENAI_API_KEY`                   | Live call to matching provider's `/v1/models`.                                        |
| `opencode`       | same as `pi`                                                                    | Live call to matching provider's `/v1/models`.                                        |
| `devin`          | `DEVIN_API_KEY` (+ optional `DEVIN_API_BASE_URL`)                               | Live `GET ${baseUrl}/v1/sessions?limit=1` (`Authorization: Bearer`).                  |

OAuth tokens get a **presence check** rather than a real upstream call. The OAuth-bearer-with-`/v1/models` contract isn't a stable public surface, and OAuth flows have their own refresh logic (handled at adapter boot, not here) — a "real" check that fails on a stale-but-refreshable token would be a worse UX than an optimistic presence check. The runtime adapter remains the source of truth for whether a token actually works.

5-second timeout via `AbortController`. Errors run through `scrubSecrets` before return. On success, the result is cached in-memory keyed by provider, and `/status` reports `harness.state === "verified"` until `SWARM_VERIFY_TTL_MS` elapses or the API restarts.

```bash
curl -s http://localhost:3013/status -H "Authorization: Bearer $API_KEY"

curl -s -X POST http://localhost:3013/status/test-connection \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"provider":"claude"}'
```

Per-agent `harness_provider` [#per-agent-harness_provider]

Workers report their `HARNESS_PROVIDER` env on registration into the `agents.harness_provider` column (migration 054). Operators can re-assign without restarting via:

```bash
curl -X PATCH http://localhost:3013/api/agents/<agent-id>/harness-provider \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"harness_provider":"codex"}'
```

The current behavior is **forecast-only** — the worker keeps using its env-set provider until it restarts, at which point the env wins on re-register. Full dynamic harness switching (worker boots without `HARNESS_PROVIDER`, picks up provider + creds from the API on demand) is tracked in [DES-359](https://linear.app/desplega-labs/issue/DES-359).

Requester profile prompts [#requester-profile-prompts]

When a task has a canonical requester (`requestedByUserId`) and that user's
profile includes a `role` or free-text `notes`, the runner injects a
`## Requester Profile` block into the task prompt before execution.

* `users.role` becomes a concise role suffix such as `Alex (CEO)`
* `users.notes` becomes explicit guidance on tone, depth, and format
* The prompt tells agents to honor that guidance unless it conflicts with
  correctness or operating rules

This makes personalization operational instead of purely cosmetic: the same
swarm can answer an exec with terse outcomes, or give an engineer more detailed
implementation context, without forking agent identities per stakeholder.

The profile prompt is additive. It does not override repository guidelines,
safety rules, or task-specific constraints.

Home page [#home-page]

The home page (`/`) consumes `/status` and renders:

1. **Activity** — leads online, agents online, tasks in last 24h.
2. **Setup checklist** — harness, integrations group (Slack + GitHub with "All integrations →" and "Docs ↗" links), workers, first task.
3. **First steps + Storage** — Phase 3 fills "First steps" with a recommended starter template based on detected integrations; Storage shows the agent-fs card.

If `/status` returns 404 (older API server), the home page redirects to `/dashboard` and the sidebar's "Home" item is hidden — older deployments degrade gracefully.

Related [#related]

* [API reference: `/status`](/docs/api-reference/status)
* [Harness configuration](./harness-configuration)
* [Harness providers](./harness-providers)
* [Deployment](./deployment)
* [MCP tools](/docs/reference/mcp-tools)


# Provider Capability Matrix (/docs/guides/provider-capability-matrix)



The provider you choose during onboarding selects the worker harness and its model credentials. Some server-side features use a separate internal LLM or embedding credential, so a worker can run successfully while those features remain off. Without an embedding key, keyword-based full-text memory search still works, but semantic and hybrid ranking do not.

| Install choice                      | Memory search                                                       | Session summaries + memory rating | Workflow LLM nodes (`raw-llm` / `validate`)                                               | Task steering                     | Session-end profile sync | Skills directory                   | MCP wiring | Structured output | Reasoning effort                                             | Spend tracking                  | Rate-limit handling                                                                        | Model-tier mapping                                          |
| ----------------------------------- | ------------------------------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------- | ------------------------ | ---------------------------------- | ---------- | ----------------- | ------------------------------------------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
| Claude Code                         | Semantic/hybrid search with `OPENAI_API_KEY` or `EMBEDDING_API_KEY` | Yes; LLM rating when enabled      | Needs `OPENAI_API_KEY` or `OPENROUTER_API_KEY`; Claude-only credentials are not supported | Queue at turn boundaries          | Yes                      | Native, `~/.claude/skills`         | Yes        | Yes               | Model-dependent                                              | Yes                             | Structured reset times                                                                     | Yes                                                         |
| OpenAI / Codex                      | Semantic/hybrid search with `OPENAI_API_KEY` or `EMBEDDING_API_KEY` | Yes; LLM rating when enabled      | Yes with `OPENAI_API_KEY`                                                                 | Queue via managed Codex hooks     | Yes                      | Inline resolver, `~/.codex/skills` | Yes        | Yes               | Model-dependent; Codex also supports `max` on capable models | Yes                             | Structured API limits and credits cooldown                                                 | Yes                                                         |
| OpenRouter via pi                   | Semantic/hybrid search with `OPENAI_API_KEY` or `EMBEDDING_API_KEY` | Yes; LLM rating when enabled      | Yes                                                                                       | Live `steer` and queued follow-up | Yes                      | Native, `~/.pi/agent/skills`       | Yes        | Yes               | Model-dependent                                              | Yes                             | Flat five-minute cooldown                                                                  | Yes                                                         |
| AWS Bedrock via pi &#x2A;*(alpha)** | Semantic/hybrid search with `OPENAI_API_KEY` or `EMBEDDING_API_KEY` | No with Bedrock credentials alone | No with Bedrock credentials alone                                                         | Live `steer` and queued follow-up | Yes                      | Native, `~/.pi/agent/skills`       | Yes        | Yes               | Model-dependent via pi                                       | No; Bedrock models are unpriced | pi retries; AWS throttles get actionable retry guidance, but no runner credential cooldown | No built-in mapping; pi defaults point to OpenRouter models |

How to unlock missing features [#how-to-unlock-missing-features]

* **Semantic memory search, every provider:** set `OPENAI_API_KEY` or the dedicated `EMBEDDING_API_KEY` on the API server.
* **Workflow LLM nodes with Claude-only or Bedrock-only credentials:** set `OPENAI_API_KEY` or `OPENROUTER_API_KEY` on the API server.
* **Bedrock session summaries and memory rating:** set `OPENAI_API_KEY` or `OPENROUTER_API_KEY` for the internal AI credential chain. Native Bedrock support is not available yet.
* **Bedrock model tiers:** set `MODEL_TIER_SMOL`, `MODEL_TIER_REGULAR`, `MODEL_TIER_SMART`, and `MODEL_TIER_ULTRA`, or provide the same mappings through `MODEL_TIER_MAP`, using reachable `amazon-bedrock/...` model IDs.
* **Bedrock spend tracking and runner credential cooldowns:** no environment variable unlocks these today; they remain alpha limitations.

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

For provider credentials and model selection, see [Harness Configuration](/docs/guides/harness-configuration). For implementation details behind this matrix, see [Adding a Harness Provider](/docs/guides/harness-providers).


# Published Artifacts (/docs/guides/published-artifacts)



Every merge to `main` (and every version bump) publishes a set of artifacts. This page is the canonical inventory: what exists, how it's tagged, and which one to use.

Docker images [#docker-images]

All images are multi-arch (`linux/amd64` + `linux/arm64`) and published to GitHub Container Registry.

| Image                                         | Built from                                 | Contents                                                                               |
| --------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------- |
| `ghcr.io/desplega-ai/agent-swarm`             | `Dockerfile`                               | API server — compiled binary, migrations, script runtimes. Small by design (\~400 MB). |
| `ghcr.io/desplega-ai/agent-swarm-worker`      | `Dockerfile.worker` (target `worker-full`) | Full worker — all four harnesses plus the complete dev environment (see below).        |
| `ghcr.io/desplega-ai/agent-swarm-worker:slim` | `Dockerfile.worker` (target `worker-slim`) | Slim worker for CI and E2E — all four harnesses, none of the heavy extras.             |

Tag scheme [#tag-scheme]

| Tag                                                             | When pushed                              | Mutability                               |
| --------------------------------------------------------------- | ---------------------------------------- | ---------------------------------------- |
| `latest` / `slim`                                               | Every push to `main`                     | Mutable — tracks main                    |
| `{VERSION}` / `{VERSION}-slim` (e.g. `1.123.0`, `1.123.0-slim`) | Only when `package.json` version changes | Immutable — use for reproducible deploys |
| `sha-{GIT_SHA}` / `sha-{GIT_SHA}-slim`                          | Every push to `main`                     | Immutable — exact commit                 |

Full vs slim worker [#full-vs-slim-worker]

Both variants ship all four harness CLIs (Claude Code, Codex, pi, opencode), the compiled `agent-swarm` binary, Node.js, Bun, Python, git, `gh`, `pm2`, the `agent-fs` and `wts` CLIs, and the full skill trees for every harness.

The **full** image (default, `latest`) additionally includes:

* Build toolchain: `build-essential`, `cmake`, `gcc`/`g++`, Python dev headers
* Playwright Chromium + `agent-browser` (browser automation, screenshots, E2E testing)
* PostgreSQL 16 server + pgvector and Redis server (opt-in local services via `SWARM_DEP_POSTGRES_ENABLED` / `SWARM_DEP_REDIS_ENABLED`)
* GitLab CLI (`glab`), `sentry-cli`, `localtunnel`, `claude-bridge`
* context-mode plugins for Claude and Codex (the `ctx_*` hooks)
* Convenience tools: `vim`, `tmux`, `htop`, `tree`

The **slim** image drops all of the above. On the slim image, `SWARM_DEP_POSTGRES_ENABLED` / `SWARM_DEP_REDIS_ENABLED` and `GITLAB_TOKEN` log a warning at boot instead of activating.

**Use slim when** you're running CI pipelines, cross-provider E2E tests, or local `docker compose` development (docker-compose.local.yml already targets it). **Use full when** agents need to compile arbitrary projects, run browser automation, or use the bundled postgres/redis services — i.e. production swarms.

To build locally:

```bash
bun run docker:build:worker        # full  -> agent-swarm-worker:latest
bun run docker:build:worker:slim   # slim  -> agent-swarm-worker:slim
bun run docker:build:api           # API   -> agent-swarm-api:latest
```

E2B sandbox templates [#e2b-sandbox-templates]

Published on every **version bump** (not every merge), built from the immutable `:{VERSION}` image tags (E2B caches by image reference string, so mutable tags would go stale):

| Template                                                          | Source image                   |
| ----------------------------------------------------------------- | ------------------------------ |
| `agent-swarm-api-{version-slug}` (e.g. `agent-swarm-api-1-123-0`) | `agent-swarm:{VERSION}`        |
| `agent-swarm-api-latest`                                          | `agent-swarm:{VERSION}`        |
| `agent-swarm-worker-{version-slug}`                               | `agent-swarm-worker:{VERSION}` |
| `agent-swarm-worker-latest`                                       | `agent-swarm-worker:{VERSION}` |

Templates are public. See the [E2B provider guide](/docs/guides/e2b-provider-smoke-tests) for usage.

npm package [#npm-package]

[`@desplega.ai/agent-swarm`](https://www.npmjs.com/package/@desplega.ai/agent-swarm) — the CLI (worker/lead runner, hooks, `e2b` commands). Published on version bump.

Helm chart [#helm-chart]

`charts/agent-swarm` — chart `version`/`appVersion` are kept in sync with `package.json` by `bun run sync-chart-version` (CI-enforced). See the [Deployment Guide](/docs/guides/deployment) for Kubernetes usage.

Where publishing happens [#where-publishing-happens]

`.github/workflows/docker-and-deploy.yml` on pushes to `main`: builds and pushes all three image variants per-arch, merges multi-arch manifests, deploys the production compose, notifies Swarm Cloud of new digests, and — on version bumps — publishes E2B templates, the npm package, the git tag, and the GitHub release. PRs only build the API image, the **slim** worker target, and the evals image as a merge-gate check (`.github/workflows/merge-gate.yml`).


# Script connections (/docs/guides/script-connections)



Script connections turn external APIs into first-class, typed clients inside
the [scripts runtime](./scripts-runtime). A lead registers a connection once —
an OpenAPI spec URL, a GraphQL endpoint, or an installed MCP server — and every
script gets a generated client for it:

```ts
export default async function (args, ctx) {
  const pets = await ctx.api.petstore.findPetsByStatus({ query: { status: "available" } });
  const country = await ctx.api.countries.graphql(
    "query($code: ID!) { country(code: $code) { name capital } }",
    { code: "ES" },
  );
  const metrics = await ctx.mcp.swarmmcp.getMetrics({});
  return { pets: pets.length, country, metrics: metrics.structuredContent };
}
```

Credentials attach to connections as **bindings** and are injected at network
egress — script code never holds a raw secret (see
[Security model](#security-model)).

| Kind      | Script surface                        | Registered from                          |
| --------- | ------------------------------------- | ---------------------------------------- |
| `openapi` | `ctx.api.<slug>.<operationId>(args)`  | a spec URL or inline spec (JSON or YAML) |
| `graphql` | `ctx.api.<slug>.graphql(query, vars)` | an endpoint URL                          |
| `mcp`     | `ctx.mcp.<slug>.<toolName>(args)`     | an installed MCP server (tool discovery) |

Registering connections [#registering-connections]

Management is **lead-only** (RBAC verb `script-connection.manage`) through the
`script-connections` MCP tool: `list`, `upsert-openapi`, `upsert-mcp`,
`upsert-graphql`, `refresh`, `disable`. Connections are scoped `global`
(default), `agent`, or `repo`.

OpenAPI, by spec URL [#openapi-by-spec-url]

```jsonc
// script-connections
{
  "action": "upsert-openapi",
  "slug": "notion",
  "displayName": "Notion API",
  "baseUrl": "https://api.notion.com",
  "openapiSpecUrl": "https://api.apis.guru/v2/specs/notion.com/1.0.0/openapi.json",
  "allowedHosts": ["api.notion.com"],
  // optional: create a credential binding in the same call
  "configKey": "NOTION_TOKEN",
  "headerTemplate": "Authorization: Bearer [REDACTED:NOTION_TOKEN]"
}
```

The spec is fetched, parsed (JSON or YAML — YAML is canonicalized to JSON at
ingest), and each operation becomes
a method — `operationId` when present, otherwise derived from the method+path.
The stored spec's ETag is remembered; `{"action": "refresh", "id": "..."}`
re-fetches and regenerates only when the spec actually changed. Inline specs go
in `openapiSpecJson` instead of `openapiSpecUrl` (mutually exclusive).

Call shape — arguments are grouped by parameter location:

```ts
await ctx.api.notion.retrieveAPage({
  path: { page_id: "..." },                    // path params
  query: { filter_properties: "..." },         // query params
  header: { "Notion-Version": "2022-06-28" },  // header params declared in the spec
  body: { ... },                               // request body (JSON)
});
```

Base paths in `baseUrl` (e.g. `https://host/api/v3`) are preserved when spec
paths are resolved against it.

GraphQL [#graphql]

```jsonc
{
  "action": "upsert-graphql",
  "slug": "gh",
  "displayName": "GitHub GraphQL",
  "baseUrl": "https://api.github.com/graphql",
  "allowedHosts": ["api.github.com"],
  "configKey": "GH_GRAPHQL_TOKEN",
  "headerTemplate": "Authorization: Bearer [REDACTED:GH_GRAPHQL_TOKEN]"
}
```

Scripts call it with a positional signature — `graphql(query, variables)`, not
an options object:

```ts
const data = await ctx.api.gh.graphql(
  "query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { stargazerCount } }",
  { owner: "desplega-ai", name: "agent-swarm" },
);
```

GraphQL errors (an `errors` array with no `data`) are thrown with the joined
error messages. `allowedHosts` is required even for credential-less endpoints.

MCP [#mcp]

MCP connections reference an installed MCP server (managed with the
`mcp-server-*` [MCP tools](../reference/mcp-tools)) and discover its tools at
upsert time:

```jsonc
// 1. register the server (any transport the swarm supports)
// mcp-server-create
{ "name": "context7", "transport": "http", "url": "https://mcp.context7.com/mcp", "scope": "swarm" }

// 2. connect it for scripts — tools are discovered and camelCased into methods
// script-connections
{ "action": "upsert-mcp", "slug": "ctx7", "mcpServerId": "<id from step 1>" }
```

```ts
const res = await ctx.mcp.ctx7.resolveLibraryId({ libraryName: "bun", query: "sqlite" });
const text = res.content?.[0]?.text; // raw MCP envelope — unwrap structuredContent/content yourself
```

Tool calls are **proxied server-side** through
`POST /api/script-connections/{id}/mcp-call` (RBAC verb
`script-connection.invoke`) — the MCP server's auth headers and secrets stay in
the API server and never enter the script subprocess. Discovery honors the
connection's scope, so an agent-scoped server's secrets resolve as that agent.

Re-run discovery after a server adds tools with `{"action": "refresh"}`.

Credentials [#credentials]

Bindings are managed with the `credential-bindings` MCP tool (`list`, `upsert`,
`oauth-app-upsert`, `oauth-authorize-url`, `import-legacy`, `disable`) and are
stored relationally (`script_credential_bindings`). A binding is:

* `configKey` — the placeholder identity, e.g. `NOTION_TOKEN`
* `authKind` — `config` (resolve from swarm config / env) or `oauth` (resolve
  from stored OAuth tokens)
* `allowedHosts` — exact hostnames where substitution may happen
* `headerTemplate` / `queryTemplate` — where the credential goes

The template must contain the **exact placeholder** `[REDACTED:<configKey>]`:

```
Authorization: Bearer [REDACTED:NOTION_TOKEN]
```

A template without that placeholder is silently ignored (the request goes out
unauthenticated) — this is the most common misconfiguration; `{{KEY}}`-style
templates do not work.

For `authKind: "config"`, store the secret first:

```jsonc
// set-config
{ "key": "NOTION_TOKEN", "value": "ntn_...", "isSecret": true, "scope": "global" }
```

Secrets are AES-256-GCM encrypted at rest when `SECRETS_ENCRYPTION_KEY` is set
— see [Secrets encryption](./secrets-encryption).

OAuth flow [#oauth-flow]

For APIs where a user authorizes an OAuth app (GitHub, Google, Slack, ...):

```jsonc
// 1. register the OAuth app (client id/secret from the provider's dev console)
// credential-bindings
{
  "action": "oauth-app-upsert",
  "provider": "github",
  "clientId": "...",
  "clientSecret": "...",
  "authorizeUrl": "https://github.com/login/oauth/authorize",
  "tokenUrl": "https://github.com/login/oauth/access_token",
  "scopes": ["repo", "read:org"]
}

// 2. get the authorization URL — the swarm generates state + PKCE (S256)
{ "action": "oauth-authorize-url", "provider": "github" }
```

The user opens the returned URL, authorizes, and the provider redirects to
`GET /api/oauth/{provider}/callback`, where the swarm exchanges the code
(client secret + PKCE verifier), stores access + refresh tokens, and shows a
"you can close this tab" page.

By default client credentials are sent as form-encoded body parameters. For
providers that require HTTP Basic auth and/or a JSON token request (e.g.
Notion), set the token-endpoint knobs on the app:

```jsonc
{ "action": "oauth-app-upsert", "provider": "notion", ..., "tokenAuthStyle": "basic", "tokenBodyFormat": "json" }
```

Then bind the token:

```jsonc
// 3. bind — the token resolves from oauth_tokens at run time
{
  "action": "upsert",
  "configKey": "GITHUB_OAUTH",
  "authKind": "oauth",
  "oauthProvider": "github",
  "allowedHosts": ["api.github.com"],
  "headerTemplate": "Authorization: Bearer [REDACTED:GITHUB_OAUTH]"
}
```

Tokens expiring within 5 minutes are refreshed automatically (refresh is
serialized per provider via a lock). A provider whose refresh fails is skipped
for that run — the placeholder stays unsubstituted and other bindings are
unaffected. Scopes are space-separated in the authorize URL per the OAuth spec.

Three identifiers are involved, deliberately independent:

```
script_connection   slug: "notion"        → what scripts call: ctx.api.notion
  └ credential_binding  configKey: NOTION_TOKEN, oauthProvider: "notion"  → the link
      └ oauth_app       provider: "notion"    → the callback URL segment
```

The callback path segment is the **OAuth app's provider slug**, not the
connection slug. One authorization can back many connections — e.g. a single
`google` OAuth app + token bound into separate `gcal`, `gmail`, and `gdrive`
connections. The redirect URI you register in the provider's dev console stays
stable per app regardless of how many connections reference it.

Worked example: Notion (public integration) [#worked-example-notion-public-integration]

Verified end-to-end against a real Notion workspace:

```jsonc
// credential-bindings — note owner=user (Notion-required authorize param)
// and the Basic+JSON token-endpoint style
{
  "action": "oauth-app-upsert",
  "provider": "notion",
  "clientId": "<integration client id>",
  "clientSecret": "<integration secret>",
  "authorizeUrl": "https://api.notion.com/v1/oauth/authorize",
  "tokenUrl": "https://api.notion.com/v1/oauth/token",
  "scopes": [],
  "extraParams": { "owner": "user" },
  "tokenAuthStyle": "basic",
  "tokenBodyFormat": "json"
}
```

Register `http://<your-host>/api/oauth/notion/callback` as the redirect URI in
Notion's integration settings, open the `oauth-authorize-url` result in a
browser, authorize — then bind with `authKind: "oauth"`, `oauthProvider:
"notion"` and attach to a connection. Google works the same way with
`extraParams: { "access_type": "offline", "prompt": "consent" }` (required to
receive a refresh token) and the default body/form token style.

Security model [#security-model]

* **Placeholder-only in scripts.** Script code, args, and `ctx` only ever see
  `[REDACTED:KEY]`. The real value is substituted inside the runtime's fetch
  layer at egress, and **only** when the destination hostname is in the
  binding's `allowedHosts`. A request carrying the placeholder to any other
  host goes out with the literal placeholder string.
* **MCP credentials never leave the server.** `ctx.mcp` calls are proxied;
  the script subprocess sends tool name + arguments, the API server holds the
  MCP session and its auth.
* **Scrubbed logs.** Resolved values are registered as volatile secrets, so
  stdout/stderr and session logs render them as `[REDACTED:KEY]`.
* **Encrypted at rest.** Config-backed secrets use the swarm secrets cipher.
  OAuth tokens are stored in `oauth_tokens` (plaintext in v1 —
  `TODO(secrets-cipher)`).

Where connections resolve [#where-connections-resolve]

`ctx.api` / `ctx.mcp` are populated everywhere scripts run: `script-run` (MCP
tool and `POST /api/scripts/run`), scheduled script runs,
[workflow `swarm-script` nodes](../concepts/workflows), and
[external script endpoints](./scripts-external-apis) (which resolve under the
endpoint's run-as agent).

Apps sync sources use connections [#apps-sync-sources-use-connections]

A Swarm App model's `sources.<name>` can name a `connection` slug alongside its
`scriptId`. The slug is validated when the definition is written and re-checked
at the start of every sync pass — a connection that was disabled after the write
fails the pass before the source script runs, with zero row churn.

Credentials resolve for the **sync run-as identity**: the source script's owner,
falling back to the lead agent for owner-less scripts (every seeded catalog
script is owner-less). That identity's connections and credential bindings are
what the run gets — the sync engine itself never reads, resolves, or forwards
secret material.

Source scripts follow the same placeholder rule as every other script: send
`[REDACTED:KEY]` in the header and let the egress layer substitute the real
value toward allowlisted hosts only. The seeded `github-issues-pull` script is
the worked example — it sends `Authorization: Bearer [REDACTED:GITHUB_TOKEN]`
and never unwraps a secret. Sources naming a connection are otherwise free to
use the typed `ctx.api.<slug>` client instead of raw `fetch`.

Current limitations (v1) [#current-limitations-v1]

* OpenAPI specs may be JSON or YAML (YAML is converted to JSON at ingest and
  stored canonically).
* `ctx.mcp` returns the raw MCP result envelope (`content` +
  `structuredContent`); unwrap it in the script.
* GraphQL results are untyped (`any`); OpenAPI response types are generated
  from the spec on a best-effort basis.
* OAuth tokens are not yet encrypted at rest.

Trying it out [#trying-it-out]

A self-contained HTML playground lives at
[`examples/script-connections-playground.html`](https://github.com/desplega-ai/agent-swarm/blob/main/examples/script-connections-playground.html)
in the repo — open it in a browser against a local server to run scripts
against your registered connections interactively, with presets for each
connection kind and a live leak-probe demonstrating the egress substitution
rules.


# One-off Script Workflow Runs (/docs/guides/script-workflow-runs)



One-off Script Workflow runs give agents a workflow-shaped execution surface for ad-hoc jobs. Use them when a task needs durable multi-step execution, but the work is not yet worth turning into a named workflow definition.

They run TypeScript source, persist a `script_runs` row, execute in the background through the script-workflow supervisor, and journal each durable step in `script_run_journal`. The journal is the contract: if the process restarts or the source is re-executed from the top, completed steps are replayed by label instead of run again.

The user module runs in a credential-free guest process. Calls through `ctx.step.*` and the allowlisted `ctx.swarm.*` surface cross a bounded, authenticated capability bridge to the trusted host, which keeps the swarm bearer and performs the HTTP requests. Replacing `globalThis.fetch` inside user code therefore cannot observe authenticated host traffic. Capability results keep the same 64 MiB response guard as the script SDK.

When to use this [#when-to-use-this]

Use a one-off Script Workflow run when the job has more shape than a single `script-run`, for example:

* call a catalog script to gather context
* summarize or classify the result with a raw LLM call
* spawn an agent task for review or follow-up work
* inspect the run later from the dashboard, MCP tools, SDK, or API

If the job should recur on a schedule, be versioned as a product workflow, or be edited by operators, use a normal workflow definition instead.

Launch from MCP [#launch-from-mcp]

Load the tools with `ToolSearch` if they are not visible:

```text
launch-script-run
get-script-run
list-script-runs
```

Then launch TypeScript source:

```ts
export default async function main(args, ctx) {
  const recalled = await ctx.step.swarmScript("recall-task-context", {
    name: "task-context-gathering",
    scope: "global",
    args: {
      taskId: args.taskId,
      queries: [
        "script workflows durable runs",
        "DES-541 QA journal replay",
        "swarm scripts catalog",
      ],
    },
    intent: "script-workflow-guide-context",
  });

  const summary = await ctx.step.rawLlm("summarize-context", {
    prompt: `Summarize this task context for an operator:\n${JSON.stringify(recalled)}`,
  });

  return { recalled, summary };
}
```

Call `launch-script-run` with:

```json
{
  "scriptName": "task-context-summary",
  "idempotencyKey": "task-context-summary:<task-short-id>",
  "args": { "taskId": "<task-id>" },
  "source": "export default async function main(args, ctx) { /* ... */ }"
}
```

The tool calls `POST /api/script-runs` with `background: true`, preserves the invoking agent identity, and returns the run ID plus dashboard URL.

Inspect from MCP [#inspect-from-mcp]

Use `get-script-run` for a single run:

```json
{ "id": "<script-run-id>" }
```

The response includes the `run` object and `journal` entries. Each journal entry has:

* `stepKey` - the durable label
* `stepType` - `swarm-script`, `raw-llm`, or `agent-task`
* `config` - the step config recorded for audit/debugging
* `status` - `completed` or `failed`
* `result` or `error`
* timestamps

Use `list-script-runs` to find recent runs:

```json
{ "status": "completed", "agentId": "<agent-id>", "limit": 25 }
```

`status`, `agentId`, `limit`, and `offset` map directly to the list API.

Copy-paste workflow patterns [#copy-paste-workflow-patterns]

Thariq Shihipar's dynamic workflows post, ["A harness for every task"](https://x.com/trq212/status/2061907337154367865), is the motivating pattern: use a workflow-shaped harness when a single context window is likely to drift, stop early, or verify its own work too generously. These examples adapt the post's patterns to one-off Script Workflow runs.

For each example, paste the TypeScript into `launch-script-run.source`, set the shown `args`, and give the run an `idempotencyKey` if you might launch it twice.

Classify-and-act triage [#classify-and-act-triage]

Use this when an inbound item needs a cheap classifier before it spends agent time.

```ts
export default async function main(args, ctx) {
  const classification = await ctx.step.rawLlm("classify-item", {
    schema: {
      type: "object",
      properties: {
        kind: { type: "string", enum: ["bug", "docs", "question", "ops"] },
        urgency: { type: "string", enum: ["low", "medium", "high"] },
        nextAction: { type: "string" },
      },
      required: ["kind", "urgency", "nextAction"],
      additionalProperties: false,
    },
    prompt: `Classify this item and choose the next action:\n${args.item}`,
  });

  const result = classification.result;
  if (result.urgency !== "high") {
    return { classification: result, createdTask: false };
  }

  const followUp = await ctx.step.agentTask("high-urgency-follow-up", {
    task: `Handle this ${result.kind} item.\n\nItem:\n${args.item}\n\nRecommended action:\n${result.nextAction}`,
    priority: 80,
    tags: ["script-workflow", "triage"],
  });

  return { classification: result, createdTask: true, followUp };
}
```

Launch args:

```json
{
  "scriptName": "classify-and-act-triage",
  "idempotencyKey": "classify-and-act-triage:support-1842",
  "args": {
    "item": "Customer reports that script runs disappear from the dashboard after refresh."
  }
}
```

Fan-out-and-synthesize verification [#fan-out-and-synthesize-verification]

Use this when a post, report, or PR description has multiple claims that should be checked independently before one synthesis step.

```ts
export default async function main(args, ctx) {
  const checks = [];

  for (const [index, claim] of args.claims.entries()) {
    checks.push(
      await ctx.step.agentTask(`verify-claim-${index + 1}`, {
        task: `Verify this claim against the repo or linked source. Return pass/fail and concise evidence.\n\nClaim: ${claim}`,
        tags: ["script-workflow", "claim-check"],
        outputSchema: {
          type: "object",
          properties: {
            pass: { type: "boolean" },
            evidence: { type: "string" },
          },
          required: ["pass", "evidence"],
          additionalProperties: false,
        },
      }),
    );
  }

  const synthesis = await ctx.step.rawLlm("synthesize-verification", {
    prompt: `Summarize these independent claim checks. Call out any failed or weak claims.\n${JSON.stringify(checks, null, 2)}`,
  });

  return { checks, synthesis };
}
```

Launch args:

```json
{
  "scriptName": "fan-out-claim-verification",
  "idempotencyKey": "fan-out-claim-verification:script-workflows-post-v1",
  "args": {
    "claims": [
      "Script Workflow runs journal every ctx.step.* call by label.",
      "A repeated durable label replays the first journaled result.",
      "SCRIPT_RUN_MAX_AGENT_TASKS defaults to 50."
    ]
  }
}
```

Loop-until-done refinement [#loop-until-done-refinement]

Use this when the stop condition is qualitative and you want the run to keep a durable audit trail of each pass.

```ts
export default async function main(args, ctx) {
  let draft = args.startingDraft;
  const maxPasses = args.maxPasses ?? 3;

  for (let pass = 1; pass <= maxPasses; pass++) {
    const revision = await ctx.step.rawLlm(`revise-pass-${pass}`, {
      prompt: `Revise this draft against the rubric.\n\nRubric:\n${args.rubric}\n\nDraft:\n${draft}`,
    });
    draft = revision.result;

    const review = await ctx.step.rawLlm(`review-pass-${pass}`, {
      schema: {
        type: "object",
        properties: {
          done: { type: "boolean" },
          feedback: { type: "string" },
        },
        required: ["done", "feedback"],
        additionalProperties: false,
      },
      prompt: `Decide whether this draft satisfies the rubric.\n\nRubric:\n${args.rubric}\n\nDraft:\n${draft}`,
    });

    if (review.result.done) {
      return { done: true, pass, draft, feedback: review.result.feedback };
    }
  }

  return { done: false, passes: maxPasses, draft };
}
```

Launch args:

```json
{
  "scriptName": "loop-until-done-copy-review",
  "idempotencyKey": "loop-until-done-copy-review:homepage-hero-v1",
  "args": {
    "maxPasses": 3,
    "rubric": "Clear, specific, no invented claims, under 120 words.",
    "startingDraft": "Agent Swarm lets teams run durable agent workflows and inspect each step."
  }
}
```

SDK launch wrapper [#sdk-launch-wrapper]

From a normal swarm script, launch the same source through the SDK and inspect the durable journal later:

```ts
export default async function main(args, ctx) {
  const source = `export default async function main(args, ctx) {
    const recalled = await ctx.step.swarmScript("recall", {
      name: "smart-recall",
      scope: "global",
      args: { queries: args.queries },
      intent: "sdk-launch-wrapper"
    });

    const summary = await ctx.step.rawLlm("summarize", {
      prompt: "Summarize these recalled memories for the operator:\\n" + JSON.stringify(recalled)
    });

    return { recalled, summary };
  }`;

  const launched = await ctx.swarm.script_launchRun({
    scriptName: "sdk-launched-memory-summary",
    idempotencyKey: `sdk-launched-memory-summary:${args.topic}`,
    args: { queries: [`${args.topic} gotchas`, `${args.topic} previous fixes`] },
    source,
  });

  return launched;
}
```

SDK methods [#sdk-methods]

Inside a swarm script, the same tools are exposed as SDK methods:

```ts
await ctx.swarm.script_launchRun({
  scriptName: "memory-search-as-code",
  source,
  args: { query: "DES-541 script workflows" },
  idempotencyKey: "memory-search-as-code:DES-541",
});

await ctx.swarm.script_getRun({ id });

await ctx.swarm.script_listRuns({
  status: "aborted_limit",
  limit: 10,
});
```

The SDK names use underscores/camel case because they are TypeScript method names. They proxy to the MCP tools:

| SDK method                   | MCP tool            |
| ---------------------------- | ------------------- |
| `ctx.swarm.script_launchRun` | `launch-script-run` |
| `ctx.swarm.script_getRun`    | `get-script-run`    |
| `ctx.swarm.script_listRuns`  | `list-script-runs`  |

API flow [#api-flow]

The public launch/inspect API is:

* `POST /api/script-runs` - create a run and, with `background: true`, start the supervisor subprocess
* `GET /api/script-runs` - list runs, optionally filtered by `status` and `agentId`
* `GET /api/script-runs/{id}` - return the run and journal
* `DELETE /api/script-runs/{id}` - cancel a running run; terminal runs are left unchanged

The internal journal flow is:

1. The launch endpoint creates a `script_runs` row with status `running`.
2. The supervisor starts the harness subprocess and records `pid` plus `lastHeartbeatAt`.
3. `ctx.step.*` checks `GET /api/internal/script-runs/{runId}/steps/{stepKey}` before executing.
4. If a journal entry exists, the step replays it: a `completed` entry returns the stored result, a `failed` entry rethrows the stored error. A recorded failure must not replay as a success — otherwise a harness that died after journaling the failure but before reporting it would let the resumed run continue past a failed step and finish `completed`.
5. If no journal entry exists, the step executes and writes through `POST /api/internal/script-runs/{runId}/steps`.
6. When the subprocess exits, the supervisor marks the run `completed` or `failed`.

This is why step labels matter. A label is not display text. It is the durability key for that logical step.

Step types [#step-types]

`ctx.step.rawLlm(label, config)` runs one raw LLM call and journals the response.

`ctx.step.swarmScript(label, config)` calls the reusable scripts runtime. It can run a named catalog script:

```ts
await ctx.step.swarmScript("daily-ops-snapshot", {
  name: "compound-insights",
  scope: "global",
  args: { days: 1, includeScriptUsage: true, includeCostAndTokens: true },
});
```

`compound-insights` now reports actual `script_runs` usage separately from MCP-call log signals and can surface a cost-and-token headline in the same snapshot.

or an inline script:

```ts
await ctx.step.swarmScript("normalize-records", {
  source: "export default async function main(args) { return args.rows.map((row) => row.id); }",
  args: { rows },
  intent: "normalize-records",
});
```

`ctx.step.agentTask(label, config)` dispatches a swarm task and **blocks until it reaches a terminal status**, journaling its real output as the step result. This is the natural semantic for a durable step — a sequential `plan → review → implement` chain will not fan out or pass a placeholder downstream.

Dispatch and wait are journal-separable: the server looks up an existing `script-run-step` task by a `(runId, label)` context key before creating one. Once selected, the wait stays pinned to that task ID, so an unrelated task that later reuses the same context key cannot hijack the result. If the harness process restarts mid-wait (crash, supervisor reconciliation, `SCRIPT_RUN_MAX_WALL_MS` eviction), replay resumes polling the same task instead of dispatching a duplicate. The step is only journaled once the task reaches a terminal status.

Config options beyond the task-dispatch fields (`template`/`task`/`agentId`/`tags`/…):

| Option              | Default | Behavior                                                                                                                                                                                     |
| ------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `waitForCompletion` | `true`  | `false` reverts to the legacy fire-and-forget shape: a single dispatch call that journals `{taskId, status}` immediately (usually `status: "pending"`).                                      |
| `timeoutMs`         | 2 hours | Max time to wait for a terminal status before throwing. Checked with \~30s granularity (each poll round-trip already long-polls server-side). Only applies when `waitForCompletion` is true. |
| `failOnTaskFailure` | `true`  | When the task ends `failed`/`cancelled`/`superseded`: `true` throws so the failure surfaces to the workflow author; `false` resolves with `{taskId, status: "failed", error}` instead.       |

```ts
const review = await ctx.step.agentTask("implement-fix", {
  task: "Implement the fix described in the plan output.",
  timeoutMs: 4 * 60 * 60 * 1000, // this step commonly runs long — give it more room
  failOnTaskFailure: false, // let the workflow decide how to handle a rejected/failed implementation
});
if (review.status === "failed") {
  // review.taskId / review.error are available here
}
```

`timeoutMs` is a per-step request; the *effective* deadline is always clamped to the run's own shared, absolute wall-clock cap (`SCRIPT_RUN_MAX_WALL_MS`, computed from the run's persisted start time — not per-process, not divided across concurrent steps). If the run-level cap arrives first, every step waiting at that moment times out together, the supervisor may kill the harness process, and — per the replay-safety guarantee above — resumed steps poll the same taskId on the next reconciliation rather than duplicating work.

Fan-out with Promise.all [#fan-out-with-promiseall]

`ctx.step.agentTask` calls made inside `Promise.all` dispatch and wait **concurrently** — there is no shared mutex or serializing queue, so wall time is bounded by the slowest step, not the sum. Give each parallel step a label derived from its loop index (not a literal), same rule as any other loop:

```ts
export default async function main(args, ctx) {
  const reviews = await Promise.all(
    args.items.map((item, i) =>
      ctx.step.agentTask(`review-${i}`, { task: `Review ${item.name}` }),
    ),
  );
  return { reviews };
}
```

This is durable and replay-safe exactly like the sequential case: each label gets its own `(runId, label)` context key, so a crash mid-fan-out resumes every still-pending label against its already-dispatched task, and results come back in the same order as `args.items`.

If one step in a `Promise.all` throws (e.g. `failOnTaskFailure` on a sibling), the *other* in-flight steps are not silently discarded — the harness gives them a bounded grace period to finish their own journal write before it finalizes the run, so a task that was about to complete on the server is not orphaned by the sibling's failure. "In flight" covers the whole step lifecycle from the moment `ctx.step.*` is called, including a sibling still waiting on its very first journal lookup — not just one that has already dispatched.

`ctx.step.humanInTheLoop()` exists in the type surface but is stubbed in Script Workflows v1.

Lifecycle [#lifecycle]

The persisted run lifecycle is a small state machine, while each `ctx.step.*` call follows the journal lookup path shown inside `running`.

<Mermaid
  chart="flowchart TD
    queued[&#x22;queued\nPOST /api/script-runs accepted&#x22;] --> running[&#x22;running\nsupervisor owns subprocess&#x22;]
    running --> lookup[&#x22;step label lookup\n(runId, label)&#x22;]
    lookup -->|journal hit: completed| replay[&#x22;durable replay\nreturn stored result&#x22;]
    lookup -->|journal hit: failed| replayFail[&#x22;durable replay\nrethrow stored error&#x22;]
    replayFail --> failed
    lookup -->|journal miss| execute[&#x22;execute step\nswarm-script / raw-llm / agent-task&#x22;]
    execute --> write[&#x22;write journal row\ncompleted or failed&#x22;]
    replay --> next[&#x22;next source line&#x22;]
    write --> next
    next --> running
    running --> completed[&#x22;completed\nterminal&#x22;]
    running --> failed[&#x22;failed\nterminal&#x22;]
    running --> aborted[&#x22;aborted_limit\nterminal guardrail&#x22;]
    running --> cancelled[&#x22;cancelled\nterminal&#x22;]
    running --> paused[&#x22;paused\nsupervisor stopped&#x22;]
    paused --> reconcile[&#x22;supervisor reconciliation\nrestart harness&#x22;]
    reconcile --> lookup"
/>

Persisted statuses:

| Status          | Meaning                                                              |
| --------------- | -------------------------------------------------------------------- |
| `running`       | The run is active or waiting for the supervisor to resume it.        |
| `paused`        | The run was stopped and can be resumed by supervisor reconciliation. |
| `completed`     | The script finished and `output` may be present.                     |
| `failed`        | The script ended with an error.                                      |
| `cancelled`     | The run was cancelled before completion.                             |
| `aborted_limit` | A runtime guardrail stopped the run.                                 |

Terminal statuses are `completed`, `failed`, `cancelled`, and `aborted_limit`.

`label_lint_violation` is not a persisted run status. It is a launch-time rejection from `POST /api/script-runs` when the TypeScript syntax tree places a repeated literal step label inside a loop or an iteration callback such as `map`, `forEach`, or `reduce`. Similar-looking text in comments, strings, or unrelated code does not trigger the lint.

Durability and replay [#durability-and-replay]

Every `ctx.step.*` call follows the same durable pattern:

```text
look up journal row by (runId, label)
if found and completed: return stored result
if found and failed:    rethrow stored error
if missing:             execute step, write result or error, return/throw
```

In live QA, a run called `ctx.step.swarmScript("durable-double", ...)` twice with different source. The second call returned the first journaled result, and the run kept exactly one `durable-double` journal row. That behavior is intentional: label reuse means "this is the same logical step", not "run another step with the same name".

For loops, include an item identifier in the label:

```ts
for (const item of args.items) {
  await ctx.step.agentTask(`review-${item.id}`, {
    task: `Review ${item.name}`,
  });
}
```

Do not do this:

```ts
for (const item of args.items) {
  await ctx.step.agentTask("review", {
    task: `Review ${item.name}`,
  });
}
```

That pattern is rejected at launch with `label_lint_violation` when the lint can detect the repeated literal label.

Guardrails [#guardrails]

Script Workflow runs are intended for bounded one-off work. The runtime enforces limits:

| Guardrail                    |    Default | Effect                                              |
| ---------------------------- | ---------: | --------------------------------------------------- |
| `SCRIPT_RUN_MAX_STEPS`       |     `1000` | Maximum total journal entries for one run.          |
| `SCRIPT_RUN_MAX_AGENT_TASKS` |       `50` | Maximum `agent-task` journal entries for one run.   |
| `SCRIPT_RUN_MAX_WALL_MS`     | `86400000` | Maximum wall-clock runtime before supervisor abort. |

When a run exceeds a limit, the supervisor records `aborted_limit` and stores the limit message in `run.error`. The deployed QA path confirmed the `SCRIPT_RUN_MAX_AGENT_TASKS` default by writing the 51st `agent-task` journal row and ending the run with `SCRIPT_RUN_MAX_AGENT_TASKS exceeded (51/50)`.

Dashboard [#dashboard]

The dashboard has a Script Runs section at `/script-runs`.

Use the list view to scan recent runs by status, script name, agent, start time, and journal count. Open a run to inspect the source, args, output/error, heartbeat, and each journaled step.

Related [#related]

* [Scripts runtime](/docs/guides/scripts-runtime)
* [Workflows](/docs/concepts/workflows)
* [Receipts](/docs/receipts)
* [Script Runs API reference](/docs/api-reference/script-runs)
* [MCP tools reference](/docs/reference/mcp-tools)


# Scripts credential broker (/docs/guides/scripts-credential-broker)



The scripts credential broker lets swarm scripts authenticate outbound `fetch`
calls without receiving the raw secret in script source or arguments. A script
sends a `[REDACTED:<configKey>]` placeholder in a request header or query
parameter, and the runtime swaps that placeholder for the real config value only
when the destination host is allowlisted.

This broker is part of the `scripts-runtime` fetch layer. It is not a dynamic
MCP tool generator, and it does not expose a new SDK method to unwrap secrets.

Binding model [#binding-model]

Bindings are stored relationally in `script_credential_bindings` and managed
with the `credential-bindings` MCP tool — including OAuth-backed bindings and
attaching bindings to [script connections](./script-connections), which is the
recommended way to authenticate `ctx.api` / `ctx.mcp` calls.

The legacy JSON document in `swarm_config` under `SCRIPT_CREDENTIAL_BINDINGS`
is still read when no relational bindings exist, and can be imported once via
`{"action": "import-legacy"}`. The document can be either an array of bindings
or an object with a `bindings` array:

```json
{
  "bindings": [
    {
      "configKey": "GITHUB_TOKEN",
      "allowedHosts": ["api.github.com"],
      "headerTemplate": "Authorization: Bearer [REDACTED:GITHUB_TOKEN]",
      "scope": "global",
      "scopeId": null,
      "active": true
    }
  ]
}
```

Each binding has:

* `configKey` — the config key to resolve with the normal swarm config
  resolution path.
* `allowedHosts` — exact hostnames where this credential may be substituted.
* `headerTemplate` — optional header shape containing the
  `[REDACTED:<configKey>]` placeholder.
* `queryTemplate` — optional query shape like
  `api_key=[REDACTED:VENDOR_API_KEY]`.
* `scope` / `scopeId` — `global`, `agent`, or `repo` scoping for where the
  binding applies.
* `active` — set to `false` to keep a binding in config but stop using it.

At least one of `headerTemplate` or `queryTemplate` is required.

Default GitHub binding [#default-github-binding]

Fresh runtimes include a backward-compatible default binding:

```json
{
  "configKey": "GITHUB_TOKEN",
  "allowedHosts": ["api.github.com"],
  "headerTemplate": "Authorization: Bearer [REDACTED:GITHUB_TOKEN]",
  "scope": "global",
  "scopeId": null,
  "active": true
}
```

If `GITHUB_TOKEN` resolves from swarm config or the process environment, scripts
can call GitHub by sending the placeholder header:

```ts
await fetch("https://api.github.com/repos/desplega-ai/agent-swarm", {
  headers: {
    Authorization: "Bearer [REDACTED:GITHUB_TOKEN]",
  },
});
```

The script never sees the raw token. The fetch patch substitutes the header only
when the request hostname is `api.github.com`.

Query-string credentials [#query-string-credentials]

Some APIs require credentials in the query string. Configure a `queryTemplate`
for those APIs:

```json
{
  "configKey": "VENDOR_API_KEY",
  "allowedHosts": ["api.vendor.example"],
  "queryTemplate": "api_key=[REDACTED:VENDOR_API_KEY]",
  "scope": "global",
  "active": true
}
```

Then the script sends the placeholder query value:

```ts
const url = new URL("https://api.vendor.example/v1/items");
url.searchParams.set("api_key", "[REDACTED:VENDOR_API_KEY]");

await fetch(url);
```

The broker does not add missing query parameters. It only replaces placeholders
that are already present on allowlisted hosts.

Operator workflow [#operator-workflow]

Lead agents manage bindings through the static `credential-bindings` MCP tool.
The tool writes `SCRIPT_CREDENTIAL_BINDINGS` to `swarm_config`; the runtime reads
that config when a script starts.

If you want scripts to call a typed external API instead of hand-written
`fetch()` calls, pair the broker with the lead-only `script-connections` tool.
That registry stores an OpenAPI spec, allowed hosts, and the optional backing
credential binding, then generates a typed `ctx.api.<slug>` client for swarm
scripts.

Typical flow:

1. Create or update the credential binding with `credential-bindings`.
2. Register the OpenAPI-backed API connection with `script-connections`.
3. Call the generated `ctx.api.<slug>` helpers from the script instead of
   embedding raw URLs and auth logic in source.

Global-scope config mutations now live-reload the runtime, so updated bindings
and script connections take effect without a manual `/api/config/reload`.

For manual config management, keep the binding document non-secret. The secret
value belongs in the separate swarm config key named by `configKey`, such as
`VENDOR_API_KEY`, with `isSecret` enabled.

Security boundary [#security-boundary]

The placeholder flow is:

1. The script subprocess receives resolved binding metadata and secret values
   through the internal `egressSecrets` payload.
2. `eval-harness.ts` installs the credential-broker fetch patch before user code
   runs.
3. On each `fetch`, the patch checks the request hostname.
4. If the hostname is in `allowedHosts`, placeholders in configured headers or
   query parameters are replaced with the real secret value.
5. If the hostname is not allowlisted, the placeholder remains redacted.

This means a script can opt in to using a credential for an approved API without
being able to redirect the same placeholder to another host and leak the secret.

Request-body and path substitution are intentionally not supported today. Add
those only with a separate host-allowlisted substitution mode and tests that
preserve the same placeholder discipline.


# Scripts as external APIs (/docs/guides/scripts-external-apis)



Any saved script can be opted in to a public HTTP endpoint:
`POST /api/x/script/<endpointId>`. This is the first asset type under the
`/api/x/*` namespace — a general prefix reserved for swarm-created assets the
swarm exposes to the outside world. Calls run the script synchronously and
return a JSON envelope; no swarm API key is required to call the endpoint
itself.

Creating an endpoint [#creating-an-endpoint]

Either:

* Open the script in the dashboard and use its **API** tab — create, reveal
  the bearer token, copy a ready-made curl command, enable/disable, rotate,
  or delete.
* Use the `script-apis` MCP tool (`list` / `create` / `update` / `rotate` /
  `delete`) to manage endpoints programmatically. It's deferred — load it
  with `ToolSearch("select:script-apis")` first.

Creating an endpoint requires a **run-as agent** — external calls execute the
script under that agent's identity, so its egress secrets and API connections
resolve normally. This defaults to the script's owning agent; scripts with no
owner (global scripts created by no one in particular) must specify one
explicitly.

Auth modes [#auth-modes]

* `none` — anyone with the URL can call the endpoint.
* `bearer` — an auto-generated token (`xsk_...`) is required in the
  `Authorization: Bearer <token>` header. The token is stored
  **AES-256-GCM-encrypted** at rest (same cipher and key as the swarm secrets
  store) in a dedicated column — not as a `swarm_config` row, so it never
  shows up in the Secrets UI.

The token is shown in full only right after `create` or `rotate`. Listing
endpoints afterward masks it (`********`) unless you explicitly ask to reveal
it — see [Listing and revealing tokens](#listing-and-revealing-tokens) below.

Calling the endpoint [#calling-the-endpoint]

```bash
curl -X POST https://your-swarm-host/api/x/script/kRxMfQaBnTwL \
  -H 'Authorization: Bearer xsk_...' \
  -H 'Content-Type: application/json' \
  -H 'X-Swarm-Timeout-Ms: 90000' \
  -d '{"...args..."}'
```

* **Input**: a JSON body, validated against the script's stored
  `argsJsonSchema` when one exists (scripts predating that column skip this
  step — the in-sandbox Zod check still applies if the script declares
  `argsSchema`).
* **Timeout**: `X-Swarm-Timeout-Ms`, default 60s, clamped to 1–300s.
* **CORS**: any origin is allowed by default, inherited from the global CORS
  handler — no per-endpoint configuration in v1.

Response envelope [#response-envelope]

Every request that reaches execution returns HTTP 200 with a wrapped
envelope:

```json
{ "ok": true, "result": { "...": "..." }, "error": null, "durationMs": 842 }
```

```json
{
  "ok": false,
  "result": null,
  "error": { "type": "args_validation", "message": "..." },
  "durationMs": 4
}
```

`error.type` is one of `args_validation`, `invalid_json`, `runtime_error`,
`timeout`, or `import_violation`. `stdout`/`stderr` are never exposed to
external callers.

Auth and routing failures short-circuit before execution and use plain HTTP
status codes instead of the envelope: `401` (missing/invalid bearer token),
`404` (unknown or disabled endpoint — the same response either way, so a
disabled endpoint's existence isn't leaked), `501` (`workspace-rw` scripts
aren't supported here, same as `/api/scripts/run`).

Listing and revealing tokens [#listing-and-revealing-tokens]

`script-apis` with `action: "list"` returns endpoints with bearer tokens
masked as `********`, mirroring how `get-config` masks secret config values.
Pass `includeSecrets: true` to reveal the real tokens (each reveal is
registered with the log scrubber so it never leaks into telemetry):

```jsonc
// action: "list", scriptId: "...", includeSecrets: false (default)
{ "id": "kRxMfQaBnTwL", "authMode": "bearer", "token": "********", "enabled": true, ... }

// action: "list", scriptId: "...", includeSecrets: true
{ "id": "kRxMfQaBnTwL", "authMode": "bearer", "token": "xsk_...", "enabled": true, ... }
```

Usage tracking [#usage-tracking]

Each call increments the endpoint's `callCount` and updates `lastUsedAt`. The
underlying script run is also recorded with `apiEndpointId` set, so external
invocations show up alongside agent-triggered runs in the
[Script Runs](/docs/api-reference/script-runs) dashboard.

Known limitations [#known-limitations]

* No rate limiting in v1 — each call spawns a sandboxed subprocess held open
  up to the configured timeout.
* `argsJsonSchema` is only populated for scripts saved after the schema
  extraction was added; older scripts skip request-time validation.

Related docs [#related-docs]

* [Scripts runtime](/docs/guides/scripts-runtime) — what the script sandbox
  exposes and how the typecheck stays aligned with it.
* [Script connections](/docs/guides/script-connections) — the preferred path
  for typed outbound `ctx.api` / `ctx.mcp` calls, with credential bindings and
  OAuth-backed auth handled server-side.
* [Scripts credential broker](/docs/guides/scripts-credential-broker) — how a
  script authenticates its own outbound `fetch` calls when a typed connection
  is not the right fit (separate from the bearer token an external caller uses
  to reach the script).
* [External APIs reference](/docs/api-reference/external-apis) — the
  generated OpenAPI reference for `POST /api/x/script/{endpointId}`.
* [Scripts API reference](/docs/api-reference/scripts) — the authenticated
  dashboard routes for creating, listing, and rotating endpoints.


# Scripts-only mode (code-mode) (/docs/guides/scripts-only-mode)





Scripts-only mode ("code-mode") shrinks the externally exposed MCP surface from
the full tool catalog (\~118 tools, \~80K tokens of schema) down to the **8 script
tools**: `script-search`, `script-run`, `script-upsert`, `script-delete`,
`script-query-types`, `launch-script-run`, `get-script-run`,
`list-script-runs`. Agents perform every other swarm operation — delegation,
progress, completion, messaging, memory — by executing TypeScript through
`script-run`, where the full SDK is available as `ctx.swarm.*`.

Enabling it [#enabling-it]

Set on the **API server** and agent containers to make a global environment
override (it controls both tool registration and the system-prompt note):

```bash
SCRIPTS_ONLY_MCP=true
```

Nothing else changes: the worker runner's own dispatch machinery talks plain
HTTP and is unaffected, and task completion via `ctx.swarm.task_storeProgress`
hits the same handlers as the `store-progress` tool.

Per-agent enablement [#per-agent-enablement]

Use a config row when only one agent should use code-mode. For example:

```bash
curl -X PUT "$MCP_BASE_URL/api/config" \
  -H "Authorization: Bearer $AGENT_SWARM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"scope":"agent","scopeId":"<agentId>","key":"SCRIPTS_ONLY_MCP","value":"true"}'
```

Resolution is deterministic:

| Priority | Source                                            | Result                                         |
| -------- | ------------------------------------------------- | ---------------------------------------------- |
| 1        | Non-empty `SCRIPTS_ONLY_MCP` environment variable | Global override                                |
| 2        | Repository config                                 | Applies to agents working in that repository   |
| 3        | Agent config                                      | Applies to that agent                          |
| 4        | Global config                                     | Applies swarm-wide when no narrower row exists |
| 5        | No value                                          | Off: the default full tool surface             |

The tool surface changes on the agent's next MCP session initialization. The
worker prompt changes on its next harness reconciliation (normally within ten
seconds) or worker boot; in-flight sessions retain their existing prompt.

For a mixed fleet, use code-mode for strong models and keep the full tool
surface for small models. PR #969 found that small models can lose delegation
fidelity under code-mode even when parent tasks complete.

<Callout type="info">
  The scripts SDK bridge (`/api/mcp-bridge`) always builds a **full-surface**
  server instance internally — the flag only trims what agent harnesses see over
  `/mcp`. Scripts keep access to the whole SDK allowlist.
</Callout>

What agents get instead of named tools [#what-agents-get-instead-of-named-tools]

The prompt template `system.agent.scripts_only_mode` is appended to every
session when the flag is set. It documents the script entry signature
(`export default async function (args, ctx)`), the response envelope rule
(`res?.data ?? res`), the sandbox limits (\~30s kill, 1 MB stdout), and the
built-in coordination scripts that ship in the seed catalog:

| Script              | Args                               | Replaces                                       |
| ------------------- | ---------------------------------- | ---------------------------------------------- |
| `delegate`          | `{agentName, task, parentTaskId?}` | get-swarm + send-task (resolves agent by name) |
| `wait-for-task`     | `{taskId, budgetSec?}`             | sleep + get-task-details polling loops         |
| `get-child-outputs` | `{parentTaskId}`                   | per-child get-task-details fan-in              |
| `complete-task`     | `{taskId, output, status?}`        | store-progress (terminal)                      |
| `report-progress`   | `{taskId, note}`                   | store-progress (in-progress)                   |
| `swarm-overview`    | `{}`                               | get-swarm + get-tasks stats                    |

These are ordinary [seed scripts](../guides/scripts-runtime) — version-aware
re-seeding applies, and they are useful in the default (full-surface) mode too
for bulk fan-out work.

When to use it — measured guidance [#when-to-use-it--measured-guidance]

From a 21-run comparison matrix (same collaborative task, 3 runs per cell;
see `thoughts/shared/research/2026-07-11-scripts-only-mcp-experiment.md` in the
repo for the full data):

* **Claude harness: viable default.** With the seed scripts, code-mode reached
  cost parity with the full surface ($1.85 vs $1.83/run), completed faster
  (3.3 vs 3.9 min), delegated correctly 3/3, and cut lead-session peak context
  by \~37% (25K vs 39K tokens). Without seeds it cost +71% — the seeds are what
  make the mode work.
* **Harnesses without tool-search (pi, opencode) on small models: keep the
  full surface.** The context win is largest there (opencode full-surface
  sessions peak \~80K vs \~38K scripts-only), but deepseek-flash-class models
  lost delegation fidelity in code-mode — they completed parents while skipping
  actual delegation, ignored the seed catalog, and dropped `parentTaskId`
  lineage. Correct coordination-as-code is a capability threshold; verify with
  your model before enabling.

<Callout type="warn">
  Grade **delegation fidelity**, not just parent completion, when evaluating this
  mode — "parent completed" was a misleading success signal in small-model runs.
</Callout>

Operational notes [#operational-notes]

* Leads need `MAX_CONCURRENT_TASKS >= 2` (auto review follow-up tasks otherwise
  deadlock behind the in-progress parent — true in any mode, but code-mode
  leads hold parents open while waiting on children).
* `docker-compose.scripts-only.yml` in the repo root runs a ready-made
  experiment stack (API + lead + 2 workers) parametrized by `SCRIPTS_ONLY_MCP`,
  `MATRIX_PROVIDER`, and `MATRIX_MODEL`.


# Scripts runtime (/docs/guides/scripts-runtime)



Swarm scripts are TypeScript modules persisted in the catalog via
[`/api/scripts/upsert`](../api-reference/scripts) and executed by the
`scripts-runtime`. The save-time typecheck and the runtime are deliberately
aligned — what passes the typecheck is what actually runs. This page documents
the full surface so script authors don't have to bisect.

Authoring contract [#authoring-contract]

The default export always receives `args` first and `ctx` second. A
one-parameter `function (ctx)` treats the script arguments as `ctx` at runtime,
so every `ctx.*` access fails. For named scripts, export an `argsSchema` so
callers, schedules, and workflows can discover the JSON input contract:

```ts
import type { ScriptContext } from "swarm-sdk";
import * as z from "zod";

export const argsSchema = z.object({
  taskId: z.string(),
  limit: z.number().optional(),
});

export default async function (args: z.infer<typeof argsSchema>, ctx: ScriptContext) {
  const response = await ctx.swarm.task_get({ taskId: args.taskId });
  const task = ((response as { data?: unknown }).data ?? response) as {
    title?: string;
  };
  return { title: task.title };
}
```

Inline source passed to `script-run` skips the compile-time typecheck.
`script-upsert` typechecks before saving, so importing `ScriptContext` also
makes inline code promotion-safe. Use `script-query-types` for the authoritative
SDK and stdlib declarations. There is no ambient task context: values such as
`taskId` must arrive through `args`; the agent identity is propagated
automatically.

What the runtime provides [#what-the-runtime-provides]

Scripts run inside a `bun run` subprocess (`src/scripts-runtime/eval-harness.ts`)
with the environment stripped to a small allowlist. Everything below is
available as a plain global in user code AND typechecks cleanly.

ES2022 standard library [#es2022-standard-library]

The typecheck loads `lib.es2022.d.ts`. All standard ES2022 built-ins resolve:

* Primitives + boxed types: `Number`, `String`, `Boolean`, `Symbol`, `BigInt`
* Collections: `Array`, `Map`, `Set`, `WeakMap`, `WeakSet`
* Errors: `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`
* Async: `Promise`, `Promise.all`, `Promise.allSettled`, `Promise.race`, `Promise.any`
* Iteration: `Iterator`, `IterableIterator`, `Generator`
* Reflection: `Object`, `Reflect`, `Proxy`
* JSON / Math / Date / RegExp
* Free functions: `isFinite`, `isNaN`, `parseInt`, `parseFloat`,
  `encodeURIComponent`, `decodeURIComponent`, `encodeURI`, `decodeURI`

`Array<T>`, `Promise<T>`, `Record<K, V>`, and structural object types all work
as you'd expect. No `any`-everywhere required.

Web platform — fetch, URL, encoding, timers, abort [#web-platform--fetch-url-encoding-timers-abort]

These are exposed by Bun's runtime and explicitly declared in the typecheck
runtime-globals shim:

* `fetch`, `Request`, `Response`, `Headers`, `RequestInit`, `ResponseInit`, `Blob`, `FormData`
* `URL`, `URLSearchParams`
* `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`, `setImmediate`,
  `clearImmediate`, `queueMicrotask`
* `AbortController`, `AbortSignal`
* `TextEncoder`, `TextDecoder`, `atob`, `btoa`, `structuredClone`
* `crypto.randomUUID()`, `crypto.getRandomValues(...)`, `crypto.subtle`

The `fetch` available in the runtime IS the one Bun provides — same shape as
Node 18+'s undici fetch. Prefer `ctx.stdlib.fetch` / `ctx.stdlib.fetchJson` for
built-in retries; bare `fetch` works too.

For external APIs that need secrets, prefer
[script connections](/docs/guides/script-connections) for typed `ctx.api` /
`ctx.mcp` access, or the
[scripts credential broker](/docs/guides/scripts-credential-broker) when you
truly need a hand-written `fetch()`. In both cases, avoid putting raw tokens in
source or args.

Console + logger [#console--logger]

`console.log` / `.warn` / `.error` / `.info` / `.debug` are global. They also
hang off `ctx.logger`. Both write to the subprocess `stderr` (captured by the
executor and returned in the HTTP response's `stderr` field).

Node-compat surface [#node-compat-surface]

Bun's Node compatibility layer makes these available:

* `Buffer` (Uint8Array-compatible, with `Buffer.from(...)`, `Buffer.concat(...)`, etc.)
* `process.env` — typed as `Record<string, string | undefined>`. The environment
  is **stripped to a small allowlist** before the user script runs. You should
  NOT assume any specific env keys exist. Today's surviving keys: `HOME`,
  `LANG`, `LC_ALL`, `PATH`, `TMPDIR`, `SWARM_SCRIPT_*` (internal).
* `process.platform`, `process.arch`, `process.version`, `process.cwd()`, `process.hrtime()`

Swarm-specific imports [#swarm-specific-imports]

```ts
import { fetch, fetchJson, grep, glob, table, Redacted } from "stdlib";
// stdlib also flows through ctx.stdlib at runtime
```

```ts
import type { ScriptContext, ScriptMain, SwarmConfig, Redacted } from "swarm-sdk";
```

The `ScriptContext` passed to your `default` export carries:

* `ctx.swarm` — typed SDK for in-swarm operations (memory, kv, tasks, scripts,
  repos, schedules). Method allowlist: see
  `src/scripts-runtime/sdk-allowlist.ts`.
* `ctx.swarm.config` — `apiKey`, `agentId`, `mcpBaseUrl` (all wrapped in
  `Redacted<string>`), plus `ctx.swarm.config.get("KEY")` for user config.
  Redacted values stringify and serialize as `<redacted>`; never unwrap one
  into a return value, log line, or request body assembled by hand.
* `ctx.api.<slug>` / `ctx.mcp.<slug>` — typed clients for registered script
  connections. They exist only for configured connections; inspect
  `Object.keys(ctx.api ?? {})` or `Object.keys(ctx.mcp ?? {})` before use.
* App-generated namespaces — each registered Swarm App contributes declarations
  for its named queries and actions, including typed `app_query` overloads, to
  the save-time script type environment. Inspect the effective declarations with
  `script-query-types`; the surface follows the app definition available when
  the script is authored or updated.
* `ctx.stdlib` — the runtime helpers (`fetch`, `fetchJson`, `grep`, `glob`,
  `table`, `Redacted`).
* `ctx.logger` — Console-compatible logger; same destination as the global
  `console`.

Most SDK results are wrapper objects; read `response.data ?? response` before
narrowing the payload. Prefer registered connections for authenticated calls:
credentials are attached server-side and do not enter script source or args.

Stored scripts are not automatically re-typechecked when an app definition or
registered connection changes. After a schema change, run `script-query-types`
and upsert affected scripts again so stale assumptions fail during validation
instead of at runtime.

`ctx.swarm.*` receives the complete scrubbed SDK response instead of the
10,000-byte model-facing MCP replacement. Both inline/named scripts and durable
workflow scripts stream these responses through a separate 64 MiB hard limit.
Crossing that limit cancels the response and throws an error asking you to
narrow or paginate the query; the runtime never silently truncates a field.

The wider boundary applies only inside the script sandbox. When a script
returns its result through `script-run`, that result crosses the agent-facing
MCP boundary again: responses over 10,000 serialized UTF-8 bytes are replaced
with a bounded preview or omission plus the per-agent KV spill pointer. Process
large collections inside the script and return only the compact derived value
the model needs.

Durable workflow context [#durable-workflow-context]

Scripts launched through `launch-script-run` receive a different context:

* `ctx.run` — the durable run's `id`, `agentId`, and `args`
* `ctx.step.rawLlm(label, config)`, `ctx.step.agentTask(label, config)`, and
  `ctx.step.swarmScript(label, config)` — replay-safe, journaled steps
* `ctx.swarm`, `ctx.stdlib`, and `ctx.logger`

Durable runs do not receive `ctx.api`, `ctx.mcp`, or `ctx.swarm.config`. When a
durable workflow needs a registered connection, call a named script through
`ctx.step.swarmScript`.

Allowed bare imports [#allowed-bare-imports]

The TypeScript import allowlist (`src/scripts-runtime/import-allowlist.ts`):

* `stdlib`
* `swarm-sdk`
* `zod` — for declaring `export const argsSchema = z.object({...})`

`import "fs"` / `"node:fs"` / `"path"` / any unlisted bare specifier is
rejected at upsert and at run time.

What the runtime does NOT provide [#what-the-runtime-does-not-provide]

These are **rejected** by the typecheck and would also fail at runtime — they
do NOT exist in the script sandbox:

* DOM APIs — `window`, `document`, `localStorage`, `sessionStorage`,
  `HTMLElement`, `Event`, etc. The typecheck does NOT include
  `lib.dom.d.ts`. We did this on purpose; the runtime is not a browser.
* Filesystem — `fs`, `node:fs`, `node:fs/promises`. Use `ctx.stdlib.glob` and
  `ctx.stdlib.grep` for read-only file inspection in `workspace-rw` mode
  (v2 only).
* Subprocess — `node:child_process`, `Bun.spawn`. Scripts cannot shell out.
* Net — `node:net`, raw TCP/UDP. Outbound HTTP via `fetch` only.
* `Bun.*` globals — even though Bun is the runtime, scripts cannot access the
  `Bun` object directly. Use `ctx.stdlib` instead.

Typecheck diagnostics [#typecheck-diagnostics]

When `script-upsert` rejects code, the response is structured:

```json
{
  "error": "typecheck_failed",
  "diagnostics": ["...colorized full diagnostic..."],
  "structured": [
    {
      "severity": "error",
      "code": 2552,
      "message": "Cannot find name 'Mat'. Did you mean 'Math'?",
      "file": "/virtual/user-script.ts",
      "line": 1,
      "column": 28,
      "endLine": 1,
      "endColumn": 31,
      "identifier": "Mat",
      "suggestion": "Math"
    }
  ]
}
```

Each entry carries the TypeScript diagnostic `code` (`TS2552`, `TS2304`, …),
the precise location, the offending identifier when it's a name lookup, and
the compiler's "did you mean…" hint when one is offered.

Runtime errors [#runtime-errors]

When a script throws at runtime, the HTTP response includes a
`runtimeError` field beside `stderr`:

```json
{
  "exitCode": 1,
  "error": "eval_error",
  "stderr": "Error: kaboom from line 4\n    at user-script.ts:4:13",
  "runtimeError": {
    "name": "Error",
    "message": "kaboom from line 4",
    "stack": "Error: kaboom from line 4\n    at .../user-script.ts:4:13\n    ...",
    "userFrames": [{ "file": "user-script.ts", "line": 4, "column": 13, "raw": "at user-script.ts:4:13" }],
    "userScriptLine": 4,
    "userScriptColumn": 13
  }
}
```

Stack frames inside the harness or `node_modules` are stripped from the
`stderr` text shown to clients (they remain in `runtimeError.stack` for
debugging). The user-script path is normalized to the basename
`user-script.ts` — absolute tmpdir paths never leak.

Exposing a script externally [#exposing-a-script-externally]

A saved script can be opted in to a public HTTP endpoint —
[`POST /api/x/script/<id>`](/docs/guides/scripts-external-apis) — for callers
outside the swarm, with optional bearer auth and typed input validation
against the same `argsJsonSchema` this page describes.

When to escalate [#when-to-escalate]

If you find code that runs fine in the runtime but fails the typecheck, treat
it as a swarm bug, not a script bug. Add a probe to `src/tests/scripts-typecheck.test.ts`
and either expand the runtime-globals shim
(`SCRIPT_RUNTIME_GLOBALS` in `src/be/scripts/typecheck.ts`) or open an issue
with the failing snippet.


# Secrets Encryption (/docs/guides/secrets-encryption)



Overview [#overview]

`swarm_config` rows with `isSecret=1` are encrypted at rest with AES-256-GCM. This covers API tokens, OAuth credentials, credential pools, and anything else stored as a secret in the config store.

The server resolves the encryption key on boot in this order:

1. `SECRETS_ENCRYPTION_KEY` env var — base64-encoded 32 bytes
2. `SECRETS_ENCRYPTION_KEY_FILE` env var — path to a file containing the base64 key
3. `<data-dir>/.encryption-key` on disk
4. Auto-generated on first boot and written to `<data-dir>/.encryption-key`, with a `[secrets] generated new encryption key at <path> — BACK THIS UP` warning

Auto-generation only happens **when the DB does not yet contain encrypted secret rows** (fresh DB or first upgrade from plaintext-only secrets). If the DB already has encrypted rows and the key is missing, boot fails closed instead of silently minting a different key that would render existing secrets unreadable.

What to back up [#what-to-back-up]

**Back up and preserve the encryption key material alongside `agent-swarm-db.sqlite`** — whether it comes from `SECRETS_ENCRYPTION_KEY`, `SECRETS_ENCRYPTION_KEY_FILE`, or an auto-generated `.encryption-key`. Losing the key means losing all encrypted secrets with no recovery path.

Do not switch between env/file/auto-generated sources unless the underlying base64 key value is identical.

Key rotation is not yet supported (follow-up work).

Reserved keys [#reserved-keys]

`API_KEY` and `SECRETS_ENCRYPTION_KEY` are rejected by the `swarm_config` API/MCP/DB layers (case-insensitive), skipped during env injection, and must never be stored in the DB. Legacy rows can still be deleted for cleanup.

First-time migration from plaintext [#first-time-migration-from-plaintext]

If you're upgrading from plaintext secrets and did **not** set `SECRETS_ENCRYPTION_KEY` beforehand, a **one-time plaintext backup** is written to `<db-path>.backup.secrets-YYYY-MM-DD.env` before encryption runs.

This file contains your secrets in plaintext and is a safety net — **delete it after verifying your encryption key is safely backed up**. The backup is not created if you provided `SECRETS_ENCRYPTION_KEY` or `SECRETS_ENCRYPTION_KEY_FILE`.

Legacy plaintext secrets are auto-migrated to encrypted storage on first boot after upgrade.


# Self-Hosted SSO (/docs/guides/self-hosted-sso)





Agent Swarm ships without a built-in login page by design — early self-hosters authenticate with a pre-shared `API_KEY`. As deployments grow to multiple teams, you need verified identity and access control. This guide covers two SSO deployment modes that compose cleanly together.

***

Decision table [#decision-table]

| Mode                  | Implementation                   | What it provides                                                        | Complexity | When to use                        |
| --------------------- | -------------------------------- | ----------------------------------------------------------------------- | ---------- | ---------------------------------- |
| **1. oauth2-proxy**   | docker-compose add-on            | IdP authentication gate; all authenticated users share the operator key | Low        | Lock the dashboard fast.           |
| **2. Trusted-header** | oauth2-proxy + forwarded headers | Same gate, plus identity headers forwarded to the upstream              | Medium     | Recommended for most self-hosters. |

<Callout type="info">
  Role-based access control (RBAC) is on the roadmap — track and upvote it at [feedback.agent-swarm.dev/p/user-rbac](https://feedback.agent-swarm.dev/p/user-rbac).
</Callout>

***

Mode 1 — oauth2-proxy quickstart [#mode-1--oauth2-proxy-quickstart]

**What it does:** A reverse proxy / forward-auth gate sits in front of the Agent Swarm dashboard. Users log in through your IdP (Okta, Google, Azure, Keycloak, …). On success, oauth2-proxy forwards the request to the swarm. The swarm still sees one shared `API_KEY` — no per-user attribution at the API layer.

**Best for:** Teams that need to lock the dashboard right now.

**Limitation:** Every authenticated user has full operator-key access. There is no per-user identity at the API or MCP layer.

Add oauth2-proxy to your docker-compose.yml [#add-oauth2-proxy-to-your-docker-composeyml]

Add the following service to your existing `docker-compose.yml` (the one based on `docker-compose.example.yml`):

```yaml title="docker-compose.yml (add to existing file)"
services:
  # ... your existing api / lead / worker services ...

  oauth2-proxy:
    image: "quay.io/oauth2-proxy/oauth2-proxy:v7.6.0"
    restart: unless-stopped
    depends_on:
      api:
        condition: service_healthy
    ports:
      - "4180:4180"
    environment:
      - OAUTH2_PROXY_CONFIG=/etc/oauth2-proxy/oauth2-proxy.cfg
    volumes:
      - ./oauth2-proxy.cfg:/etc/oauth2-proxy/oauth2-proxy.cfg:ro
    command: ["--config=/etc/oauth2-proxy/oauth2-proxy.cfg"]
```

Then create `oauth2-proxy.cfg` alongside your `docker-compose.yml` — see the [example config](#example-oauth2-proxycfg) below.

With this setup, `http://your-host:4180` is the SSO-protected entry point to the dashboard. Browsers hitting port `4180` are challenged by oauth2-proxy; authenticated sessions are proxied to the Agent Swarm API/UI on port `3013`.

Example oauth2-proxy.cfg [#example-oauth2-proxycfg]

```ini title="oauth2-proxy.cfg"
## oauth2-proxy configuration for Agent Swarm
## Full reference: https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview/

# ── Provider ───────────────────────────────────────────────────────────────────
# Replace with your IdP's OIDC discovery endpoint.
provider = "oidc"
oidc_issuer_url = "https://YOUR_IDP.example.com"          # Okta: https://your-org.okta.com
                                                           # Azure: https://login.microsoftonline.com/TENANT_ID/v2.0
                                                           # Google: https://accounts.google.com
                                                           # Keycloak: http://keycloak:8080/realms/YOUR_REALM
client_id     = "YOUR_OIDC_CLIENT_ID"
client_secret = "YOUR_OIDC_CLIENT_SECRET"

# ── Callback ───────────────────────────────────────────────────────────────────
redirect_url = "http://YOUR_SWARM_HOST:4180/oauth2/callback"

# ── Upstream (the swarm API/UI) ────────────────────────────────────────────────
upstreams = ["http://api:3013"]    # docker-compose service name + port

# ── Cookie ────────────────────────────────────────────────────────────────────
cookie_secret = "YOUR_32_BYTE_BASE64_SECRET"  # openssl rand -base64 32
cookie_secure = false    # set to true if serving over HTTPS
cookie_name   = "_oauth2_proxy"

# ── Network ───────────────────────────────────────────────────────────────────
http_address = "0.0.0.0:4180"

# ── Email allow-list (optional) ───────────────────────────────────────────────
# Restrict access to specific email domains:
# email_domains = ["yourcompany.com"]

# ── Access logging ────────────────────────────────────────────────────────────
request_logging = true
```

A ready-to-use version of this file lives at [`examples/sso/oauth2-proxy.cfg`](https://github.com/desplega-ai/agent-swarm/blob/main/examples/sso/oauth2-proxy.cfg) in the repository.

***

Mode 2 — Trusted-header mode (recommended) [#mode-2--trusted-header-mode-recommended]

**What it does:** oauth2-proxy handles the IdP interaction (Mode 1). After authentication, it forwards identity headers (`X-Forwarded-User`, `X-Forwarded-Email`, `X-Forwarded-Groups`) to the swarm API. This gives you a clear identity signal at the proxy layer, ready to be consumed when the app adds header-based attribution.

**Best for:** Self-hosters who want to establish the full trusted-header pipeline today and benefit from per-user attribution as the app layer evolves.

**Security note:** The swarm API must only accept forwarded-header requests from the proxy, not from the public internet. Enforce this by keeping the API port (`3013`) off the public network, and routing all external traffic through the proxy.

Headers forwarded by oauth2-proxy [#headers-forwarded-by-oauth2-proxy]

When using `--pass-user-headers` (or equivalent config), oauth2-proxy adds:

| Header                | Value                           |
| --------------------- | ------------------------------- |
| `X-Forwarded-User`    | Username / email from IdP       |
| `X-Forwarded-Email`   | Email address                   |
| `X-Forwarded-Groups`  | Comma-separated IdP groups      |
| `X-Auth-Request-User` | Alternative header — same value |

docker-compose.yml additions for trusted-header mode [#docker-composeyml-additions-for-trusted-header-mode]

Add the following to the `oauth2-proxy` service from Mode 1:

```yaml title="docker-compose.yml additions"
services:
  oauth2-proxy:
    # ... base config from Mode 1 ...
    command:
      - "--config=/etc/oauth2-proxy/oauth2-proxy.cfg"
      - "--pass-user-headers=true"        # forward X-Forwarded-User / X-Forwarded-Email
      - "--pass-host-header=true"
```

Updated oauth2-proxy.cfg for trusted-header mode [#updated-oauth2-proxycfg-for-trusted-header-mode]

Add the following to your `oauth2-proxy.cfg`:

```ini title="oauth2-proxy.cfg additions for trusted-header mode"
# Pass identity headers to the upstream swarm API
pass_user_headers    = true
pass_access_token    = false    # keep IdP access token off the wire
set_xauthrequest     = true     # enable X-Auth-Request-* headers
```

A complete docker-compose snippet for Mode 2 is at [`examples/sso/docker-compose.sso.yml`](https://github.com/desplega-ai/agent-swarm/blob/main/examples/sso/docker-compose.sso.yml).

***

Mode compatibility summary [#mode-compatibility-summary]

The two modes build on each other — you can start with Mode 1 and add Mode 2 without disruption:

```
Step 1: oauth2-proxy gate                → Mode 1
Step 2: add --pass-user-headers          → Mode 2 (trusted-header)
```

In Mode 2, the proxy handles:

* OIDC authorization code flow
* Token validation and refresh
* Session cookie management
* Identity header forwarding

***

Security notes [#security-notes]

* **Never expose the swarm API port (`3013`) directly to the internet when using trusted-header mode.** The API cannot distinguish a legitimate proxy header from a forged one without additional origin checks. Route all external traffic through the proxy.
* **Rotate `cookie_secret` if compromised.** All existing oauth2-proxy sessions are invalidated — users will need to re-authenticate.
* **Back up your `SECRETS_ENCRYPTION_KEY`.** SSO config (client secrets, cookie secrets) is stored encrypted in the swarm config store. Losing the key means losing access to those secrets. See [Secrets Encryption](/docs/guides/secrets-encryption).
* **OIDC client secrets should be stored as swarm config secrets**, not plain env vars, for production deployments. The swarm config store encrypts at rest with AES-256-GCM.

***

Related [#related]

* [Deployment Guide](/docs/guides/deployment) — base Docker Compose setup this guide extends
* [Secrets Encryption](/docs/guides/secrets-encryption) — encrypting OIDC client secrets at rest
* [RBAC / user roles](https://feedback.agent-swarm.dev/p/user-rbac) — track and upvote role-based access control on the roadmap
* [Design doc: SSO Integration for Agent Swarm](https://live.agent-fs.dev/file/~/648a5f3c-35c8-4f11-8673-b89de52cd6bd/2faf73ba-4eee-4472-8b3b-359c4ed6bfbb/thoughts/16990304-76e4-4017-b991-f3e37b34cf73/research/2026-06-05-sso-integration-design.md) — internal design doc with full architecture rationale


# Steer a Running Task (/docs/guides/task-steering)



Task steering sends new instructions to work that has already started. Use it to correct an assumption, add missing context, or redirect an agent without cancelling the task and starting over.

Choose a delivery mode [#choose-a-delivery-mode]

| Mode    | Meaning                                                         | Use when                                                                                |
| ------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `queue` | Deliver the message at the next turn boundary.                  | The current turn can finish safely and you want to add context without discarding work. |
| `steer` | Interrupt the current turn and deliver the message immediately. | Letting the current turn continue would be wrong or unsafe.                             |

Not every harness can interrupt. Task responses include `supportedSteerModes`, and the dashboard disables modes the assigned harness does not advertise.

| Provider       | Supported modes  | A `steer` request normally becomes                                           |
| -------------- | ---------------- | ---------------------------------------------------------------------------- |
| pi-mono        | `steer`, `queue` | `steered`                                                                    |
| Claude Managed | `steer`, `queue` | `steered`                                                                    |
| opencode       | `queue`          | `queued`                                                                     |
| Devin          | `queue`          | `queued`                                                                     |
| Claude Code    | `queue`          | `queued`                                                                     |
| Codex          | `queue`          | `queued` (delivered by Codex lifecycle hooks at the next tool-call boundary) |

Claude Code queue delivery additionally depends on a supported stock CLI invocation. See [Harness Providers: Claude queue-steering gate](/docs/guides/harness-providers#claude-queue-steering-gate). Codex delivery is harness-side: the worker image registers managed Codex hooks that inject queued messages into the running session; there is no mid-turn interrupt. See [Harness Providers: Codex harness-side delivery](/docs/guides/harness-providers#codex-harness-side-delivery-codex-hook).

Enable steering [#enable-steering]

Steering is **disabled by default**. Set `STEERING_ENABLED=true` (or `1`) on the API server and worker containers to turn it on. While disabled, new steering requests are rejected across HTTP, MCP, scripts, and Slack; the dashboard hides its steering controls and the server does not register `steer-task` or `accept-steer`. Existing message history stays readable, and delivery callbacks plus terminal-status promotion remain enabled so messages already in flight can finish safely if the flag is turned off again.

The flag can also be set as a global `swarm_config` entry — global config rows are injected into the server environment at startup and on config reload.

Degradation and `onUnsupported` [#degradation-and-onunsupported]

The server uses a durable fallback ladder:

```text
requested steer
  -> deliver as steer when supported
  -> otherwise queue at a turn boundary
  -> otherwise create a follow-up task
```

`onUnsupported` controls whether the fallback is allowed:

* `degrade` (default) preserves the message by moving down the ladder. Read `outcome`, `effectiveMode`, `degradedFrom`, and `promotedTaskId` to learn what happened.
* `fail` rejects an unsupported mode. HTTP callers receive `422`, MCP/script callers receive an error, and no steering-message row is created.

Choose `fail` when true interrupt semantics are required. For example, an instruction to stop a destructive operation should not silently wait until the operation finishes.

Tasks that haven't started yet (`unassigned`, `offered`, `pending`) also accept steering when the provider supports live delivery: the message queues as `pending` and the worker delivers it once the session is live. A `steer` request on a not-yet-started task degrades to `queue` — there is no turn to interrupt yet. If the task reaches a terminal state without ever starting, pending messages are promoted to follow-up tasks as usual.

Send steering [#send-steering]

Dashboard [#dashboard]

Open an in-progress task and use the steering composer. The mode selector follows `supportedSteerModes`, and the result reports whether the message interrupted, queued, or became a follow-up. The task page's Steering section refreshes the message lifecycle from the API.

MCP [#mcp]

`steer-task` is available on both the agent and user MCP surfaces:

```json
{
  "taskId": "00000000-0000-0000-0000-000000000000",
  "message": "Stop changing the schema; keep the fix application-only.",
  "mode": "steer",
  "onUnsupported": "fail"
}
```

Agents may steer tasks they created, and the lead may steer any task. User calls are limited to tasks allowed by `task.steer.own`.

After incorporating a delivered message, the assigned agent calls `accept-steer`:

```json
{
  "steeringMessageId": "00000000-0000-0000-0000-000000000000",
  "note": "Kept the existing schema and moved validation into the service."
}
```

Script SDK [#script-sdk]

Scripts use the same MCP handler through `task_steer`:

```ts
const result = await swarm.task_steer({
  taskId,
  message: "Use the cached result; do not repeat the external request.",
  mode: "queue",
  onUnsupported: "degrade",
});
```

HTTP [#http]

```bash
curl -X POST "http://localhost:3013/api/tasks/$TASK_ID/steer" \
  -H "Authorization: Bearer $AGENT_SWARM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Stop and verify the migration against an existing database.",
    "mode": "steer",
    "onUnsupported": "degrade"
  }'
```

Inspect the audit trail with `GET /api/tasks/{id}/steering-messages`. Worker delivery callbacks live under `/api/steering-messages/{id}/{delivered,undeliverable,handled}` and require the assigned worker's `X-Agent-ID`; normal clients should not call them.

Slack [#slack]

Slack thread steering is opt-in:

```bash
SLACK_THREAD_STEERING=lead       # or all
SLACK_THREAD_STEERING_MODE=queue # or steer
```

`lead` targets the latest in-progress lead task in the thread. `all` targets the latest active task regardless of role. When the flag is unset, existing thread follow-up routing is unchanged. See [Slack Integration](/docs/integrations/slack#live-steering-instead-of-follow-up-tasks).

Message lifecycle [#message-lifecycle]

Every accepted message has its own audit record:

```text
pending -> delivered -> handled
   |
   +-> promoted
   |
   +-> cancelled
```

* `pending`: stored and waiting for the worker to deliver it.
* `delivered`: the provider accepted it. `deliveredMode` records what the adapter actually used.
* `handled`: the agent acknowledged the message with `accept-steer` after incorporating it.
* `promoted`: live delivery was unavailable, the task stopped before delivery, or the provider rejected the message. `promotedTaskId` links to the durable follow-up task.
* `cancelled`: pending delivery was cancelled before a provider accepted it.

If the parent task reaches a terminal state while messages are still pending, the server promotes each one exactly once rather than dropping it.


# Worker Credential Recovery (/docs/guides/worker-credential-recovery)





A worker container always boots and registers, even when the harness credentials it needs (e.g. `CLAUDE_CODE_OAUTH_TOKEN`) are missing. It parks in a `waiting_for_credentials` state, the dispatcher routes around it, and the dashboard shows what it's blocked on. The worker self-heals as soon as the credential lands in `swarm_config` — no container restart.

This guide documents that lifecycle, the endpoints that drive it, and the configuration knobs that govern its timing.

<Callout type="info">
  This pattern replaces the previous bash-level fail-fast in `docker-entrypoint.sh`, which exited the container on missing creds and forced operators to restart workers after every credential change.
</Callout>

***

1\. Boot model [#1-boot-model]

The Docker entrypoint is now best-effort. It still does file-prep side effects (codex login, claude-managed config restore from `swarm_config`, etc.) but does **not** exit the process on missing harness credentials. The single hard exit it keeps is `API_KEY` — without that, the worker can't talk to the API at all.

After the entrypoint hands off, the worker process:

1. Calls `join-swarm` so the agent row exists in the DB and is visible on the dashboard.
2. Calls `awaitCredentials(...)` (`src/commands/credential-wait.ts`) which loops:
   * `checkProviderCredentials(provider, env)` against the per-adapter predicate.
   * If not ready, calls `fetchResolvedEnv(...)` to merge the latest `swarm_config` values into `process.env`.
   * Re-checks. If still not ready, sleeps with exponential backoff and reports state via `PUT /api/agents/{id}/credential-status`.
3. Once ready, transitions the agent's row to `status: idle` and starts the task-claim loop.

The agent never crashes. Operators set creds whenever, and the worker picks them up on the next tick.

***

2\. Agent lifecycle [#2-agent-lifecycle]

The `agents.status` enum is `idle | busy | offline | waiting_for_credentials`. Adding the fourth value keeps the dispatcher predicate trivial — `getIdleWorkersWithCapacity` filters on `status === 'idle'`, so blocked workers are excluded from routing without any extra condition.

The shape of the agent row while waiting:

```json
{
  "id": "worker-1",
  "status": "waiting_for_credentials",
  "credentialMissing": ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]
}
```

`credentialMissing` is `null` (or absent) in any other state.

| From → To                          | Trigger                                                                                    |
| ---------------------------------- | ------------------------------------------------------------------------------------------ |
| `idle` → `waiting_for_credentials` | Worker `awaitCredentials` finds creds gone (rare — usually only happens at boot)           |
| `waiting_for_credentials` → `idle` | Worker's next tick finds creds present in `process.env` after a `fetchResolvedEnv` refresh |
| any → `offline`                    | Heartbeat stops landing                                                                    |

***

3\. Per-provider predicates [#3-per-provider-predicates]

Each adapter exports a `checkCredentials(env, opts?): CredStatus` (`src/providers/credentials.ts` dispatches to the right one). The shape:

```ts
interface CredStatus {
  ready: boolean;
  missing: string[];
  satisfiedBy?: 'env' | 'file' | 'side-effect-pending';
  hint?: string;
}
```

Provider-by-provider:

| Provider         | Ready when                                                                                                                                                                                    |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `claude`         | `CLAUDE_CODE_OAUTH_TOKEN` **or** `ANTHROPIC_API_KEY` is set                                                                                                                                   |
| `claude-managed` | All of `ANTHROPIC_API_KEY`, `MANAGED_AGENT_ID`, `MANAGED_ENVIRONMENT_ID`, `MCP_BASE_URL` are set                                                                                              |
| `devin`          | Both `DEVIN_API_KEY` and `DEVIN_ORG_ID` are set                                                                                                                                               |
| `codex`          | `~/.codex/auth.json` exists **or** `OPENAI_API_KEY` is set (login dance still needs to run; reported as `satisfiedBy: 'side-effect-pending'`)                                                 |
| `pi`             | `~/.pi/agent/auth.json` exists, otherwise model-conditional: `MODEL_OVERRIDE` resolves to anthropic/openrouter/openai → that provider's key required. Unset → any one of the three is enough. |
| `opencode`       | Same shape as `pi`, file at `~/.local/share/opencode/auth.json`, model-conditional env keys                                                                                                   |

***

4\. Endpoints [#4-endpoints]

Two read endpoints (for dashboard / orchestrators) and one write endpoint (for the worker itself).

```http
GET /api/agents/{id}/credential-status
```

Single-agent snapshot. Response:

```json
{
  "agentId": "worker-1",
  "name": "worker-1",
  "status": "waiting_for_credentials",
  "missing": ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
  "provider": "claude",
  "lastCheckedAt": "2026-05-06T21:35:27.791Z"
}
```

```http
GET /api/agents/credential-status[?status=waiting_for_credentials]
```

Bulk snapshot across all agents. The optional `?status=` filter narrows to one enum value. Powers the dashboard's at-a-glance fleet view without N round-trips.

```http
PUT /api/agents/{id}/credential-status
{ "ready": false, "missing": ["CLAUDE_CODE_OAUTH_TOKEN"] }
```

Worker self-report. `ready: true` flips the agent back to `idle` and clears `credentialMissing`. `ready: false` sets `waiting_for_credentials` with the listed missing keys.

<Callout type="warn">
  There is no `/ready` endpoint on the worker process today. Workers don't expose an HTTP server; orchestrators that want strict gating should poll `GET /api/agents/{id}/credential-status` against the API. Tracked as a follow-up if a Kubernetes-style readiness split is needed.
</Callout>

***

5\. Recovering a parked worker [#5-recovering-a-parked-worker]

Set the missing credential via the config API and the worker picks it up on its next tick (default ≤30s):

```bash
curl -X PUT https://api.example.com/api/config \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "scope": "agent",
    "scopeId": "worker-1",
    "key": "CLAUDE_CODE_OAUTH_TOKEN",
    "value": "sk-ant-oat01-...",
    "isSecret": true
  }'
```

Scope choices:

* `scope: "agent"` — only this worker sees it.
* `scope: "global"` — all workers inherit it.

The worker's next `fetchResolvedEnv` call merges the new value into `process.env`, the predicate flips green, and the agent row transitions to `idle`.

Caveat: codex side-effect on first credential [#caveat-codex-side-effect-on-first-credential]

`codex login --with-api-key` runs in the entrypoint as a one-time side effect. If `OPENAI_API_KEY` arrives **after** the entrypoint has finished, the worker's predicate goes ready (since the env var is set) but `~/.codex/auth.json` may not be written until the next container restart. Plan to restart the codex worker once after first-credential — tracked as a follow-up to move the side effect into TS.

***

6\. Dashboard surface [#6-dashboard-surface]

The agents list (`/agents`) shows a `WAITING FOR CREDS` pill in the Status column for blocked workers. The detail view (`/agents/{id}`) renders a panel listing each missing variable as a chip plus a remediation hint with the exact `PUT /api/config` form.

The dashboard polls these endpoints; once you set the credential, the badge clears within the polling interval.

***

7\. Configuration knobs [#7-configuration-knobs]

All read from `process.env` at function entry — overridable per worker.

| Variable                  | Default | Effect                                                                      |
| ------------------------- | ------- | --------------------------------------------------------------------------- |
| `BOOT_INITIAL_BACKOFF_MS` | `2000`  | Initial sleep between credential checks                                     |
| `BOOT_MAX_BACKOFF_MS`     | `30000` | Cap on the exponential backoff                                              |
| `BOOT_MAX_WAIT_SECONDS`   | `0`     | If > 0, exit with code `78` (`EX_CONFIG`) once exceeded. `0` = wait forever |

`78` is distinct from generic failures so monitoring can tell "credentials never arrived" apart from a crash.

***

8\. What this is **not** [#8-what-this-is-not]

* Not a push-based notification system. Workers poll their own state on backoff; there is no SSE / WebSocket subscription to `/api/config/reload`. Latency: ≤30s by default.
* Not a way to bootstrap `API_KEY`, `SECRETS_ENCRYPTION_KEY`, or `MCP_BASE_URL` — those are required for the worker to talk to the API at all and are validated at the bash layer.
* Not a replacement for the per-task `fetchResolvedEnv` refresh — that still runs on every task spawn (`src/commands/runner.ts`) and is what keeps long-running agents in sync with mid-flight credential rotations.

***

Related [#related]

* [Adding a Harness Provider](/docs/guides/harness-providers) — implement a new provider; includes the `checkCredentials` predicate contract
* [Harness Configuration](/docs/guides/harness-configuration) — provider-by-provider credential setup
* [Secrets Encryption](/docs/guides/secrets-encryption) — how `swarm_config` secrets are protected at rest


# x402 Payments (/docs/guides/x402-payments)



Agent Swarm includes built-in support for [x402](https://x402.org/) payments, allowing agents to automatically pay for x402-gated API endpoints using USDC on Base.

How It Works [#how-it-works]

x402 is an open payment protocol that uses the HTTP 402 "Payment Required" status code for native micropayments over HTTP. When an agent calls an x402-gated API:

1. The initial request returns a `402 Payment Required` response
2. The x402 client automatically signs a USDC payment authorization (EIP-3009)
3. The request is retried with the payment signature
4. The API returns the response — payment is settled on-chain asynchronously

The entire flow is **gasless** for the payer and completes in \~1.5–2 seconds.

Setup [#setup]

Choose a Signer [#choose-a-signer]

Two signer backends are supported:

| Backend                    | Env Vars                                      | Best For                              |
| -------------------------- | --------------------------------------------- | ------------------------------------- |
| **Openfort** (recommended) | `OPENFORT_API_KEY` + `OPENFORT_WALLET_SECRET` | Production — keys managed in TEE      |
| **Viem**                   | `EVM_PRIVATE_KEY`                             | Development/testing — raw private key |

The signer is auto-detected based on which credentials are set. Use `X402_SIGNER_TYPE` to override.

Openfort Setup [#openfort-setup]

1. Create an account at [openfort.xyz](https://www.openfort.xyz/)
2. Get your API key and wallet secret
3. Set environment variables:

```bash
OPENFORT_API_KEY=sk_test_...
OPENFORT_WALLET_SECRET=<base64-encoded-p256-key>
```

Viem Setup [#viem-setup]

1. Generate a wallet (e.g., `cast wallet new`)
2. Get test USDC from the [CDP Faucet](https://portal.cdp.coinbase.com/products/faucet) on Base Sepolia
3. Set environment variable:

```bash
EVM_PRIVATE_KEY=0x...
```

No ETH needed — x402 payments are gasless for the payer.

Spending Limits [#spending-limits]

Configure via environment variables or programmatically:

| Variable                | Default  | Description     |
| ----------------------- | -------- | --------------- |
| `X402_MAX_AUTO_APPROVE` | `$1.00`  | Max per-request |
| `X402_DAILY_LIMIT`      | `$10.00` | Max per-day     |

See the full [environment variables reference](/docs/reference/environment-variables#x402-payments).

Usage [#usage]

Drop-in Fetch Replacement [#drop-in-fetch-replacement]

```typescript
import { createX402Fetch } from "@/x402";

const paidFetch = createX402Fetch();

// Use like normal fetch — 402 payments are automatic
const response = await paidFetch("https://api.example.com/paid-endpoint");
const data = await response.json();
```

Full Client with Spending Tracking [#full-client-with-spending-tracking]

```typescript
import { createX402Client } from "@/x402";

const client = await createX402Client();

// Make paid requests
const response = await client.fetch("https://api.example.com/paid-endpoint");

// Check spending
const summary = client.getSpendingSummary();
console.log(`Spent today: $${summary.todaySpent.toFixed(2)}`);
console.log(`Remaining: $${summary.dailyRemaining.toFixed(2)}`);
```

Custom Configuration [#custom-configuration]

```typescript
import { createX402Client } from "@/x402";

const client = await createX402Client({
  maxAutoApprove: 0.50,   // Max $0.50 per request
  dailyLimit: 5.00,       // Max $5.00 per day
  network: "eip155:8453", // Base mainnet
});
```

CLI [#cli]

Test x402 payments from the command line:

```bash
# Check configuration
bun src/x402/cli.ts check

# Make a paid request
bun src/x402/cli.ts fetch https://api.example.com/paid-endpoint

# View spending summary
bun src/x402/cli.ts status
```

Module Structure [#module-structure]

```
src/x402/
├── index.ts              # Public API re-exports
├── client.ts             # Payment client with spending limits
├── config.ts             # Environment variable loading
├── openfort-signer.ts    # Openfort backend wallet signer
├── spending-tracker.ts   # In-memory spending tracking
└── cli.ts                # CLI for testing payments
```

Security [#security]

* **Use burner wallets** — load only small amounts of working capital
* **Set spending limits** — configure per-request and daily limits
* **Never commit private keys** — always use environment variables
* **Testnet first** — use Base Sepolia (`eip155:84532`) during development
* **Openfort for production** — keys managed in a TEE, not on disk

Further Reading [#further-reading]

* [x402 Protocol Specification](https://github.com/coinbase/x402/blob/main/specs/x402-specification.md)
* [x402 Buyer Quickstart](https://docs.cdp.coinbase.com/x402/quickstart-for-buyers)
* [x402.org](https://www.x402.org/)

Related [#related]

* [Environment Variables](/docs/reference/environment-variables#x402-payments) — x402 configuration variables
* [Architecture Overview](/docs/architecture/overview) — How the x402 module fits into the system


# AgentMail Integration (/docs/integrations/agentmail)



Give your agents email addresses via [AgentMail](https://agentmail.to). Emails are routed to agents as tasks or inbox messages.

<Callout type="warn">
  The `register-agentmail-inbox` MCP tool requires the `agentmail` capability, which is **disabled by default**. Enable it by setting `CAPABILITIES` on the API server to the full default list plus `agentmail` — the variable replaces the defaults, it is not additive. See the [environment variables reference](/docs/reference/environment-variables). The webhook endpoint itself is not gated by this flag.
</Callout>

Setup [#setup]

1. Create an account at [AgentMail](https://agentmail.to)
2. Set up a webhook pointing to your server

**Webhook URL:** `https://your-server.com/api/agentmail/webhook`

Configuration [#configuration]

```bash
AGENTMAIL_WEBHOOK_SECRET=your-svix-secret

# Optional: filter incoming webhooks by domain
AGENTMAIL_INBOX_DOMAIN_FILTER=yourdomain.com
AGENTMAIL_SENDER_DOMAIN_FILTER=gmail.com,company.com
```

Domain Filtering [#domain-filtering]

You can restrict which emails are processed using domain filters:

* **`AGENTMAIL_INBOX_DOMAIN_FILTER`** — Only process webhooks for inboxes on the specified domains. Emails to inboxes on other domains are silently dropped.
* **`AGENTMAIL_SENDER_DOMAIN_FILTER`** — Only process emails from senders on the specified domains. Emails from other sender domains are silently dropped.

Both accept comma-separated domain lists and are optional. When not set, all domains are allowed.

How It Works [#how-it-works]

Email Routing [#email-routing]

Agents self-register which inboxes they receive mail from using the `register-agentmail-inbox` MCP tool.

* **Emails to a worker's inbox** become tasks
* **Emails to a lead's inbox** become inbox messages for triage
* **Follow-up emails in the same thread** are automatically routed to the same agent

Registering an Inbox [#registering-an-inbox]

```
register-agentmail-inbox(
  action: "register",
  inboxId: "inb_xxx",
  inboxEmail: "worker@yourdomain.com"
)
```

Managing Inboxes [#managing-inboxes]

```
# List your registered inboxes
register-agentmail-inbox(action: "list")

# Unregister an inbox
register-agentmail-inbox(action: "unregister", inboxId: "inb_xxx")
```

Sending Email [#sending-email]

Agents can send emails using AgentMail MCP tools:

* `send_message` — Send a new email
* `reply_to_message` — Reply to an existing thread
* `forward_message` — Forward an email
* `list_threads` — List email threads in an inbox

Related [#related]

* [Environment Variables](/docs/reference/environment-variables) — AgentMail configuration (`AGENTMAIL_WEBHOOK_SECRET`, domain filters)
* [Task Lifecycle](/docs/concepts/task-lifecycle) — How emails become tasks
* [Slack Integration](/docs/integrations/slack) — Another external task source


# Composio (/docs/integrations/composio)



Composio gives Agent Swarm a managed connection layer for third-party apps such
as Gmail, GitHub, Slack, Notion, HubSpot, and similar tools. The current Agent
Swarm integration is intentionally small: use the [`x` command](/docs/reference/x-command)
or the `swarm_x` MCP tool to route requests to Composio's Tool Router API.

<Callout type="info">
  This is a prototype/admin surface today. The long-term product shape is an
  API-mediated integration where the swarm server owns Composio auth, creates
  sessions, exposes Connect Links, and gives workers only task-scoped tool access.
</Callout>

What it does [#what-it-does]

* **Creates Tool Router sessions.** A session is Composio's runtime context for
  one user and a set of available toolkits.
* **Uses connected accounts.** Gmail, GitHub, and other app credentials live in
  Composio under the provided `user_id`, not in Agent Swarm.
* **Generates Connect Links.** If a user has not connected a toolkit yet,
  `COMPOSIO_MANAGE_CONNECTIONS` returns a hosted link at `connect.composio.dev`.
* **Executes app tools.** After a connection is active, the session can execute
  app tools such as `GMAIL_FETCH_EMAILS`.
* **Works from CLI and MCP.** Humans can run `agent-swarm x composio ...`;
  agents can call `swarm_x` with `target: "composio"`.

Setup [#setup]

Add a Composio project API key to the process running the CLI or swarm API:

```bash
COMPOSIO_API_KEY=your-project-api-key
```

Optional:

```bash
COMPOSIO_BASE_URL=https://backend.composio.dev/api/v3.1
COMPOSIO_ORG_API_KEY=your-org-api-key
```

The CLI sends `COMPOSIO_API_KEY` as `x-api-key`. With `--org`, it sends
`COMPOSIO_ORG_API_KEY` as `x-org-api-key`.

How it works [#how-it-works]

1\. Create a user-scoped session [#1-create-a-user-scoped-session]

```bash
agent-swarm x composio POST /tool_router/session \
  --body '{"user_id":"swarm-user-id","toolkits":{"enable":["gmail"]},"workbench":{"enable":false}}'
```

Store the returned `session_id`. Sessions persist on Composio's server and do
not expire, but they should still be treated as task or conversation context in
Agent Swarm.

2\. Search for tools before executing [#2-search-for-tools-before-executing]

```bash
agent-swarm x composio POST /tool_router/session/$SESSION_ID/search \
  --body '{"queries":[{"use_case":"Check recent emails in Gmail and return metadata only."}]}'
```

Search returns the current tool slugs, schemas, recommended plan, pitfalls, and
connection status. Do not invent tool slugs.

3\. Connect the toolkit if needed [#3-connect-the-toolkit-if-needed]

If search reports no active Gmail connection:

```bash
agent-swarm x composio POST /tool_router/session/$SESSION_ID/execute \
  --body '{"tool_slug":"COMPOSIO_MANAGE_CONNECTIONS","arguments":{"toolkits":["gmail"]}}'
```

Composio returns a Connect Link for the user. Incomplete connection attempts
expire quickly, so generate a fresh link if the user misses the window.

4\. Execute the app tool [#4-execute-the-app-tool]

```bash
agent-swarm x composio POST /tool_router/session/$SESSION_ID/execute \
  --body '{"tool_slug":"GMAIL_FETCH_EMAILS","arguments":{"user_id":"me","max_results":5,"include_payload":false,"verbose":false}}'
```

Prefer metadata-first reads unless the task explicitly needs message bodies,
attachments, or destructive operations.

Session and account scoping [#session-and-account-scoping]

| Scope      | Owner                | Notes                                                                                                       |
| ---------- | -------------------- | ----------------------------------------------------------------------------------------------------------- |
| Deployment | Agent Swarm server   | Holds `COMPOSIO_API_KEY`; workers should not receive it directly in the productized path.                   |
| User       | Composio `user_id`   | Connected accounts persist under this ID and can be reused by future sessions.                              |
| Session    | Composio Tool Router | Runtime context for the task: toolkits, auth config, connected account selection, MCP URL, workbench state. |
| Task       | Agent Swarm          | Store the Composio `session_id` with the task or conversation so follow-ups reuse context.                  |

If a user has multiple accounts for the same toolkit, pin the desired account
when creating the session or pass the `account` identifier when executing a
tool. Without an explicit account, Composio uses its default account-selection
behavior.

MCP tool [#mcp-tool]

Agents can call `swarm_x` instead of shelling out:

```jsonc
{
  "target": "composio",
  "method": "POST",
  "path": "/tool_router/session/$SESSION_ID/execute",
  "body": {
    "tool_slug": "GMAIL_FETCH_EMAILS",
    "arguments": {
      "user_id": "me",
      "max_results": 5,
      "include_payload": false,
      "verbose": false
    }
  }
}
```

`swarm_x` injects Composio auth server-side and rejects absolute Composio paths
so the API key cannot be routed to arbitrary hosts.

Skill [#skill]

The `composio` skill is a system-default seeded skill from
`templates/skills/composio`. Use it when an agent needs Composio Tool Router
sessions, connected accounts, Connect Links, or Composio app tool execution.

References [#references]

* [Composio users and sessions](https://docs.composio.dev/tool-router/users-and-sessions)
* [Create a Tool Router session](https://docs.composio.dev/reference/api-reference/tool-router/postToolRouterSession)
* [Execute a Tool Router tool](https://docs.composio.dev/reference/api-reference/tool-router/postToolRouterSessionBySessionIdExecute)
* [Connected accounts](https://docs.composio.dev/docs/auth-configuration/connected-accounts)
* [Managing multiple connected accounts](https://docs.composio.dev/tool-router/managing-multiple-accounts)


# GitHub App Integration (/docs/integrations/github)



Enable GitHub webhooks for automated task creation from @mentions, issue assignments, and label-based triggers.

Setup [#setup]

1. Create a [GitHub App](https://github.com/settings/apps/new)
2. Set webhook URL: `https://your-server.com/api/github/webhook`
3. Generate a webhook secret

Required Permissions [#required-permissions]

* **Issues**: Read & Write
* **Pull requests**: Read & Write

Subscribe to Events [#subscribe-to-events]

* Issues
* Issue comments
* Pull requests
* Pull request reviews
* Pull request review comments
* Check runs
* Check suites
* Workflow runs

Configuration [#configuration]

```bash
# Required for GitHub webhooks
GITHUB_WEBHOOK_SECRET=your-webhook-secret

# Optional: Bot name for @mentions (default: agent-swarm-bot)
GITHUB_BOT_NAME=your-bot-name

# Optional: Additional @mention aliases (comma-separated)
GITHUB_BOT_ALIASES=heysidekick,sidekick

# Optional: Labels that trigger agent action on PR/issue label events (comma-separated, default: swarm-review)
GITHUB_EVENT_LABELS=swarm-review

# Optional: Enable bot reactions (requires GitHub App)
GITHUB_APP_ID=123456
GITHUB_APP_PRIVATE_KEY=base64-encoded-key

# Optional: Disable GitHub integration
GITHUB_DISABLE=true
```

Runtime cancellation flags [#runtime-cancellation-flags]

GitHub unassign and review-request-removal events cancel the linked swarm task by
default. You can disable either behavior independently through `swarm_config`
without redeploying workers:

* `github.cancelOnUnassign` — controls PR/issue `unassigned` events
* `github.cancelOnReviewRequestRemoved` — controls PR `review_request_removed`
  events

Absent key, or any value other than `"false"`, keeps the current cancel-on-event
behavior. Setting the value to `"false"` leaves the task untouched and the
handler returns `{ created: false }`.

```bash
# Keep tasks alive when the bot is unassigned
agent-swarm set-config global github.cancelOnUnassign false

# Keep tasks alive when a review request is removed
agent-swarm set-config global github.cancelOnReviewRequestRemoved false
```

Supported Events [#supported-events]

| Event                                       | What happens                                                                                                                                        |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Bot assigned to PR/issue                    | Creates a task for the lead agent                                                                                                                   |
| Review requested from bot                   | Creates a review task                                                                                                                               |
| PR review submitted                         | Creates a review task for submitted human reviews, including inline comments bundled into the task description when present                         |
| `@bot-name` or `@alias` in comment/issue/PR | Creates a task with the mention context                                                                                                             |
| `swarm-review` label added to PR/issue      | Creates a task for review (configurable via `GITHUB_EVENT_LABELS`)                                                                                  |
| Bot unassigned / review request removed     | Cancels the linked task by default; both behaviors are runtime-configurable via `github.cancelOnUnassign` and `github.cancelOnReviewRequestRemoved` |

Submitted PR reviews with no top-level body are still processed when they contain inline comments. Agent Swarm fetches the full review-comment bundle, appends an `Inline review comments` section with `path:line` context to the spawned task, and avoids spawning duplicate follow-up tasks from the corresponding standalone `pull_request_review_comment` events.

Suppressed Events (Safety Defaults) [#suppressed-events-safety-defaults]

The following events are **suppressed by default** to prevent cascade behavior where agents auto-merge PRs without meaningful human review:

* **PR closed/merged** — No automatic task creation on PR close
* **PR synchronize** (new commits pushed) — No automatic task on push
* **CI check failures** (check\_run, check\_suite, workflow\_run) — No automatic task from CI events

These events still emit to the internal `workflowEventBus` for workflow triggers, but do not create agent tasks.

Bot Reactions [#bot-reactions]

If GitHub App credentials are provided (`GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`), the bot can react to comments and issues with emoji to acknowledge receipt.

Automated replies to inline PR review threads must use the bot identity configured by `GITHUB_TOKEN`, not a user's OAuth connection. Agents verify the authenticated `gh` login before posting and append `<!-- agent-swarm:review-ack -->` so swarm-authored acknowledgements have explicit machine-readable provenance.

Eyes Reaction on Task Pickup [#eyes-reaction-on-task-pickup]

When an agent picks up a GitHub-sourced task (transitions to `in_progress`), a 👀 reaction is automatically added to the originating GitHub entity — the comment, issue, PR, or review that triggered the task. This gives immediate visual feedback that work has begun, without requiring the agent to post a comment.

Supported event types:

* **Issue/PR comments** — 👀 added to the comment
* **PR review comments** (inline) — 👀 added to the review comment
* **PR reviews** (body) — 👀 added via GraphQL (REST does not support review reactions)
* **Issues/PRs** (assigned or labeled) — 👀 added to the issue or PR itself

Worker Git Configuration [#worker-git-configuration]

Workers need git credentials to create PRs and push code:

```bash
GITHUB_TOKEN=your-github-token    # For git operations
GITHUB_EMAIL=worker@example.com    # Git commit email
GITHUB_NAME=Worker Agent           # Git commit name
```

Related [#related]

* [GitLab Integration](/docs/integrations/gitlab) — GitLab webhook support (same adapter pattern)
* [Linear Integration](/docs/integrations/linear) — Bidirectional ticket tracking with Linear
* [Environment Variables](/docs/reference/environment-variables) — GitHub configuration variables
* [Task Lifecycle](/docs/concepts/task-lifecycle) — How GitHub events become tasks
* [Slack Integration](/docs/integrations/slack) — Another external task source
* [Sentry Integration](/docs/integrations/sentry) — Automated error triage from Sentry
* [AgentMail Integration](/docs/integrations/agentmail) — Give agents email addresses


# GitLab Integration (/docs/integrations/gitlab)



Agent Swarm supports GitLab alongside GitHub through a provider adapter pattern. GitLab webhooks create tasks from MR reviews, issue assignments, and @mentions — just like the GitHub integration.

Setup [#setup]

1. Go to your GitLab project or group **Settings > Webhooks**
2. Set the webhook URL: `https://your-server.com/api/gitlab/webhook`
3. Set a secret token for verification
4. Select the events you want to receive

Events to Enable [#events-to-enable]

* **Merge request events**
* **Issue events**
* **Note (comment) events**
* **Pipeline events**

Configuration [#configuration]

```bash
# Required for GitLab webhooks
GITLAB_WEBHOOK_SECRET=your-webhook-secret

# GitLab API access (PAT or Group Access Token)
GITLAB_TOKEN=your-gitlab-token

# Optional: GitLab instance URL (default: https://gitlab.com)
GITLAB_URL=https://gitlab.com

# Optional: Bot name for @mentions (default: agent-swarm-bot)
GITLAB_BOT_NAME=your-bot-name

# Optional: Disable GitLab integration
GITLAB_DISABLE=true
```

Supported Events [#supported-events]

| Event                                         | What happens                            |
| --------------------------------------------- | --------------------------------------- |
| Bot assigned to MR/issue                      | Creates a task for the lead agent       |
| `@bot-name` in comment/issue/MR               | Creates a task with the mention context |
| Pipeline failure (on MRs with existing tasks) | Creates a CI notification task          |

Worker Git Configuration [#worker-git-configuration]

Workers have `glab` CLI pre-installed for GitLab operations. Configure git credentials for GitLab repos:

```bash
GITLAB_TOKEN=your-gitlab-token    # For git operations and glab CLI
GITLAB_EMAIL=worker@example.com   # Git commit email for GitLab repos
GITLAB_NAME=Worker Agent          # Git commit name for GitLab repos
```

The runner automatically detects whether a task targets a GitHub or GitLab repository and uses the appropriate CLI tool (`gh` or `glab`).

VCS Provider Detection [#vcs-provider-detection]

Agent Swarm uses a provider adapter pattern to support both GitHub and GitLab. When a task is created from a webhook event, the `vcsProvider` field is set automatically. Workers use this to select the right CLI:

| Operation        | GitHub (`gh`)      | GitLab (`glab`)                    |
| ---------------- | ------------------ | ---------------------------------- |
| Create PR/MR     | `gh pr create`     | `glab mr create`                   |
| View PR/MR       | `gh pr view`       | `glab mr view`                     |
| Review           | `gh pr review`     | `glab mr approve` / `glab mr note` |
| Comment on issue | `gh issue comment` | `glab issue note`                  |
| Clone            | `gh repo clone`    | `glab repo clone`                  |

Workflow Triggers [#workflow-triggers]

GitLab events can trigger [workflows](/docs/concepts/workflows):

* `gitlab.merge_request.opened`
* `gitlab.merge_request.merged`
* `gitlab.merge_request.closed`
* `gitlab.issue.opened`
* `gitlab.issue.closed`
* `gitlab.note.created`
* `gitlab.pipeline.failed`
* `gitlab.pipeline.success`

Related [#related]

* [GitHub Integration](/docs/integrations/github) — GitHub webhook setup
* [Environment Variables](/docs/reference/environment-variables) — GitLab configuration variables
* [Workflows](/docs/concepts/workflows) — Automate actions on GitLab events
* [Task Lifecycle](/docs/concepts/task-lifecycle) — How webhook events become tasks


# Integrations (/docs/integrations)



Agent Swarm is built to live inside an existing toolchain. Tasks can come in from issue trackers and chat, agent activity flows back as comments and replies, and incidents from observability tools become triage work automatically.

This section is the canonical home for each integration's setup, behavior, and operational notes. As we add more, each one gets its own page with the same shape:

* **What it does** — direction of sync, trigger surfaces, what becomes a swarm task.
* **Setup** — OAuth/app config, env vars, webhooks.
* **How it works** — gates, filters, lifecycle mapping, outbound updates.
* **Outbound updates** — how to push state back to the external system (skill, MCP tool, or API call).
* **Reference** — link to the canonical API docs / SDK.

Supported integrations [#supported-integrations]

Issue trackers [#issue-trackers]

* [**Linear**](/docs/integrations/linear) — inbound webhook → swarm tasks. Workflow-state gate plus `swarm-ready` label override. Outbound updates via the `linear-interaction` skill.
* [**Jira Cloud**](/docs/integrations/jira) — OAuth 3LO + dynamic webhooks. Assignee or @-mention triggers a task; lifecycle events post back as Jira comments.
* [**GitHub**](/docs/integrations/github) — App + webhooks. Issues, PRs, and review threads create or update swarm tasks; the agent comments back inline.
* [**GitLab**](/docs/integrations/gitlab) — token-based webhooks. Merge requests and issues map to swarm tasks the same way GitHub does.

Chat & email [#chat--email]

* [**Slack**](/docs/integrations/slack) — bot mentions, slash commands, and thread replies. The swarm uses Slack as its primary human-in-the-loop surface.
* [**AgentMail**](/docs/integrations/agentmail) — per-agent inboxes for email-driven workflows.
* [**Composio**](/docs/integrations/composio) — managed connected accounts and Tool Router sessions for third-party app tools such as Gmail and GitHub. Prototype access via `agent-swarm x composio` and `swarm_x`.
* [**Kapso (WhatsApp)**](/docs/integrations/kapso) — inbound WhatsApp messages become swarm tasks; agents reply in-thread. Native webhook + outbound send/reply tools, with the `kapso-whatsapp` skill for media/templates/reactions.

CRM & data [#crm--data]

* [**Salesforce**](/docs/integrations/salesforce) — Salesforce MCP connector authenticated via OAuth. Gives agents access to Salesforce's hosted MCP endpoints.
* [**Microsoft Graph**](/docs/integrations/microsoft-graph) — blessed Microsoft 365 OpenAPI connection with delegated OAuth for Teams, Outlook, OneDrive, and user profiles.

Observability [#observability]

* [**Sentry**](/docs/integrations/sentry) — error issues become triage tasks.

Adding a new integration [#adding-a-new-integration]

The page structure under `integrations/` is intentionally generic. To add a new one:

1. Drop a new `<provider>.mdx` here with the same section layout (What it does → Setup → How it works → Outbound updates → Reference).
2. Add the page slug to `integrations/meta.json`.
3. Cross-link from the supported list above.


# Jira Integration (/docs/integrations/jira)



Agent Swarm integrates with [Atlassian Jira Cloud](https://www.atlassian.com/software/jira) as the second provider in its generic ticket-tracker framework. Issues assigned to (or @-mentioning) the bot become swarm tasks; task lifecycle events post back as Jira comments.

Features [#features]

* **OAuth 3LO authentication** — PKCE flow via the same generic `oauth4webapi` module that powers the Linear integration.
* **Inbound on assignee or @-mention** — `jira:issue_updated` (assignee → bot) and `comment_created` / `comment_updated` (bot @-mention) both create or follow-up tasks.
* **Outbound lifecycle comments** — `task.created`, `task.completed`, `task.failed`, `task.cancelled` each post a plaintext comment to the linked issue via Jira REST v2.
* **Auto-webhook registration + 25-day refresh** — admins call `POST /webhook-register` with a JQL filter; a 12-hour timer refreshes any webhook expiring within 7 days.
* **ADF text extraction** — issue descriptions and comment bodies stored as Atlassian Document Format are walked into plaintext for prompts.

Setup [#setup]

1\. Create the Atlassian OAuth 2.0 (3LO) app [#1-create-the-atlassian-oauth-20-3lo-app]

1. Go to [https://developer.atlassian.com/console](https://developer.atlassian.com/console/myapps/) → "Create" → "OAuth 2.0 integration".
2. On the Permissions tab, add the **Jira API** product and grant the following scopes:
   * `read:jira-work`
   * `write:jira-work`
   * `manage:jira-webhook`
   * `offline_access`
   * `read:me`
3. On the Authorization tab, set the **callback URL** to `<MCP_BASE_URL>/api/trackers/jira/callback` — for example `https://your-swarm.example.com/api/trackers/jira/callback`. With `bun run dev:http` portless mode this is `https://api.swarm.localhost:1355/api/trackers/jira/callback`. The callback and webhook URLs are built from `PUBLIC_MCP_BASE_URL` when it is set, falling back to `MCP_BASE_URL` otherwise — in split deploys where `MCP_BASE_URL` is an internal/cluster address, set `PUBLIC_MCP_BASE_URL` to the public ingress origin.
4. Copy the **Client ID** and **Client Secret** from the Settings tab.
5. Generate a high-entropy webhook token: `openssl rand -hex 32`. This becomes `JIRA_WEBHOOK_TOKEN`.

<Callout type="warn">
  **Atlassian does not HMAC-sign OAuth 3LO dynamic webhooks** (plan errata I8). Instead we authenticate inbound deliveries by embedding `JIRA_WEBHOOK_TOKEN` in the path segment of the registered URL and using a constant-time compare. Treat the registered webhook URL like a Slack incoming-webhook URL — keep it out of public repos, screenshots, and logs.
</Callout>

2\. Configure environment [#2-configure-environment]

Add to your `.env`:

```bash
JIRA_CLIENT_ID=your-client-id
JIRA_CLIENT_SECRET=your-client-secret
JIRA_WEBHOOK_TOKEN=hex-token-from-openssl-rand
# Optional — defaults to <MCP_BASE_URL>/api/trackers/jira/callback
JIRA_REDIRECT_URI=https://your-swarm.example.com/api/trackers/jira/callback
```

Atlassian rejects `localhost` and plaintext `http://` in webhook registration. For local development, expose your API server through an HTTPS tunnel and point `MCP_BASE_URL` at it:

```bash
# .env
MCP_BASE_URL=https://your-tunnel.example.com
```

To temporarily disable Jira without removing the env vars: `JIRA_DISABLE=true` (or set `JIRA_ENABLED=false`).

3\. Connect [#3-connect]

1. Restart the API server so `initJira()` picks up the new env vars.
2. Visit `<MCP_BASE_URL>/api/trackers/jira/authorize` in a browser — you'll be redirected through Atlassian's consent screen.
3. After consent, the swarm stores the access/refresh tokens, fetches `accessible-resources`, and persists `cloudId` + `siteUrl` in the `oauth_apps.metadata` blob.

Verify with:

```bash
curl -H "Authorization: Bearer <API_KEY>" $MCP_BASE_URL/api/trackers/jira/status | jq
```

4\. Register a webhook [#4-register-a-webhook]

If your OAuth grant includes the `manage:jira-webhook` scope (it should, given the setup above), you can auto-register:

```bash
curl -X POST -H "Authorization: Bearer <API_KEY>" -H "Content-Type: application/json" \
  -d '{"jqlFilter":"project = KAN"}' \
  $MCP_BASE_URL/api/trackers/jira/webhook-register | jq
```

The response includes `webhookId` and `expiresAt` (defaulted to 30 days; the first refresh tick replaces this with Atlassian's authoritative value).

If you don't have the `manage:jira-webhook` scope (e.g. consenting via a restricted user), register the webhook manually via Atlassian REST or the developer console — the registered URL is exactly the one returned by `/status` under `webhookUrl`.

How it works [#how-it-works]

OAuth token lifecycle [#oauth-token-lifecycle]

Atlassian uses rotating refresh tokens. Every successful refresh-token exchange consumes the stored refresh token and returns a replacement. Agent Swarm treats `oauth_tokens` as the authoritative token store:

1. The initial OAuth callback writes the Jira access token, refresh token, expiry, and metadata to SQLite.
2. Any server-side refresh path serializes refreshes per provider, exchanges the current refresh token once, and writes the returned access token, refresh token, expiry, and `updatedAt` back to `oauth_tokens` before reporting success.
3. The update is compare-and-swap guarded by the refresh token observed before the exchange. If another process already rotated the row, the loser refuses to use its freshly returned token.
4. Near-expiry reads, including `tracker-status` and `get-oauth-access-token`, call the same refresh helper so both tools see a current DB row.

The old `JIRA_REFRESH_TOKEN_OVERRIDE` workaround should not be used for normal operation. Keeping the database authoritative avoids stale-token replay, which can invalidate a rotating-token chain.

Use the MCP tool `get-oauth-access-token` when an agent needs a bearer token for direct provider API calls. The tool accepts a provider slug, ensures the token has the requested minimum validity, and returns only the access token plus expiry. It never returns the refresh token.

Inbound [#inbound]

1. Atlassian POSTs to `<MCP_BASE_URL>/api/trackers/jira/webhook/<JIRA_WEBHOOK_TOKEN>`. The path token is verified in constant time against `JIRA_WEBHOOK_TOKEN`; mismatches return 401 with an empty body (no info leak).
2. The dispatcher branches on `webhookEvent`:
   * `jira:issue_updated` → `handleIssueEvent` (only assignee transitions **to** the bot account create/follow-up tasks; transitions away are ignored).
   * `comment_created` / `comment_updated` → `handleCommentEvent` (only when the bot is @-mentioned).
   * `jira:issue_deleted` → `handleIssueDeleteEvent` (cancels any active swarm task linked to the issue).
3. The `tracker_sync` row is inserted FIRST via `createTrackerSyncIfAbsent` (UNIQUE-gated). Only when the insert is fresh does the swarm task get created — making concurrent webhook deliveries idempotent.
4. ADF (Atlassian Document Format) issue descriptions and comment bodies are walked to plaintext via `src/jira/adf.ts` before being injected into the prompt template.
5. Two short-circuits prevent loops:
   * **Self-authored skip**: comments where `author.accountId === botAccountId` are ignored.
   * **5s outbound-echo skip**: if the linked `tracker_sync` row has `lastSyncOrigin === "swarm"` and `lastSyncedAt` within the last 5 seconds, the inbound is dropped (it's almost certainly an echo of a comment we just posted).

Outbound [#outbound]

1. The swarm's task lifecycle event bus emits `task.created`, `task.completed`, `task.failed`, `task.cancelled`.
2. `src/jira/outbound.ts` listens, looks up the `tracker_sync` row, skips if `lastSyncOrigin === "external"` within the 5s window, and otherwise calls `jiraFetch("/rest/api/2/issue/<KEY>/comment")` with a plaintext body.
3. On success, the row's `lastSyncOrigin` flips to `swarm` and `lastSyncedAt` updates — closing the loop with the inbound short-circuit.
4. We use REST v2 (plaintext body), not v3 (ADF). Plaintext-only outbound is intentional for v1.

Webhook lifecycle [#webhook-lifecycle]

Atlassian dynamic webhooks expire 30 days after registration / refresh. A 12-hour interval timer (`startJiraWebhookKeepalive` in `src/jira/webhook-lifecycle.ts`) calls `PUT /rest/api/3/webhook/refresh` whenever any registered webhook's `expiresAt` is within 7 days. On 200 + `{ expirationDate }`, the new expiry is fanned out to every locally-tracked webhook. On 204 No Content, local expiries are left untouched and a warning is logged.

MCP tools [#mcp-tools]

| Tool                     | Description                                                                                  |
| ------------------------ | -------------------------------------------------------------------------------------------- |
| `tracker-status`         | Pass `provider: "jira"` to inspect connection state.                                         |
| `get-oauth-access-token` | Pass `provider: "jira"` to receive a currently valid access token for direct Jira API calls. |
| `tracker-link-task`      | Manually link a swarm task to a Jira issue id/key.                                           |
| `tracker-sync-status`    | View sync rows for the Jira provider.                                                        |
| `tracker-map-agent`      | Map a swarm agent to a Jira `accountId` for assignment routing.                              |

These tools are provider-generic — see [MCP Tools](/docs/reference/mcp-tools) for full schemas.

Architecture [#architecture]

```
Atlassian → POST /api/trackers/jira/webhook/<token>
            (URL-token authenticated; no HMAC)
              │
              ▼
       handleJiraWebhook
       ├─ verifyJiraWebhookToken (constant-time)
       ├─ synthesizeDeliveryId  (event + ts + entity + sha256(body))
       ├─ hasTrackerDelivery    (DB-persisted dedup)
       └─ dispatchAndRecord
            ├─ jira:issue_updated  → handleIssueEvent
            ├─ comment_created     → handleCommentEvent
            ├─ comment_updated     → handleCommentEvent
            └─ jira:issue_deleted  → handleIssueDeleteEvent

Swarm task lifecycle  ──► event-bus  ──► src/jira/outbound.ts
                                          │
                                          ▼
                                 jiraFetch /rest/api/2/issue/<KEY>/comment
                                 (plaintext body)
```

The integration shares the same `oauth_apps`/`oauth_tokens`/`tracker_sync`/`tracker_agent_mapping` tables as Linear — only the provider-specific webhook receiver, sync handlers, and outbound poster live under `src/jira/`.

Known limitations (v1) [#known-limitations-v1]

* **Single workspace per install.** `cloudId` is fixed at the first OAuth connect. Reconnecting picks the **first** entry from `accessible-resources` — multi-workspace support is a v2 concern.
* **`JIRA_WEBHOOK_TOKEN` rotation requires re-registering every webhook.** There's no automated drift detection between the env value and Atlassian's stored URL. Rotation flow: set new `JIRA_WEBHOOK_TOKEN` → restart → `DELETE /api/trackers/jira/webhook/:id` for each existing webhook → `POST /api/trackers/jira/webhook-register` to re-register with the new token.
* **No status transitions on task completion.** Outbound is comments only (mirrors Linear v1).
* **Plaintext-only outbound.** No ADF formatting — the comment body posts via REST v2 as raw plaintext including emoji.
* **No per-issue debounce / outbound queue.** Rate-limit handling is `jiraFetch`'s single 429 retry. A queued outbound poster is a v2 concern.
* **Bot identity = consenting Atlassian user.** There's no separate `@swarm-bot` handle without a dedicated Atlassian account; mentions resolve to whichever user completed the OAuth consent.
* **Access tokens are short-lived.** Agents should call `get-oauth-access-token` instead of copying tokens from SQLite or config. Refresh tokens must never be logged, returned from tools, or stored in swarm config overrides.

Related [#related]

* [Linear Integration](/docs/integrations/linear) — sibling tracker provider
* [Harness Providers](/docs/guides/harness-providers) — how the swarm dispatches different LLM harnesses
* [MCP Tools](/docs/reference/mcp-tools) — full schemas for the `tracker-*` MCP tools


# Kapso (WhatsApp) (/docs/integrations/kapso)



Agent Swarm integrates with [Kapso](https://kapso.ai) — a WhatsApp platform vendor that fronts the Meta Cloud API — as an inbound chat source. A provisioned WhatsApp number's inbound messages become swarm tasks, and agents reply in-thread over WhatsApp.

<Callout type="warn">
  The Kapso MCP tools (`register-kapso-number`, `unregister-kapso-number`, `send-whatsapp-message`, `reply-whatsapp-message`) require the `kapso` capability, which is **disabled by default**. Enable it by setting `CAPABILITIES` on the API server to the full default list plus `kapso` — the variable replaces the defaults, it is not additive. See the [environment variables reference](/docs/reference/environment-variables). The inbound webhook handler is not gated by this flag.
</Callout>

<Callout type="info">
  The thin `send-whatsapp-message` / `reply-whatsapp-message&#x60; MCP tools cover the common text path. Everything else — templates, media, reactions, typing indicator, mark-as-read, signature verification, contact resolution, conversation history — lives in the **`kapso-whatsapp` skill** (shipped as a template skill in this repo). Drop to the skill's REST recipes for those.
</Callout>

What it does [#what-it-does]

* **Inbound on message.** A provisioned number's webhook is pointed at the swarm's native handler. An inbound `whatsapp.message.received` event becomes a `kapso-inbound` task (or is dispatched to a workflow, if the number maps to one).
* **KV-backed routing, no table.** The phone-number → routing mapping lives in the swarm KV store (`integrations:kapso:numbers`), written by the lead-only `register-kapso-number` tool. No migration required.
* **Webhook signing + dedup.** Inbound deliveries are HMAC-SHA256 verified against `KAPSO_WEBHOOK_HMAC_SECRET` (`X-Webhook-Signature` header) and deduped by message id (`integrations:kapso:dedupe`, 24h TTL).
* **Default routing to the lead.** When a number is registered without an explicit agent or workflow, inbound tasks route to the lead agent.
* **Outbound tools.** `send-whatsapp-message` and `reply-whatsapp-message` wrap the Kapso Meta-proxy text send (the latter quote-replies an inbound WAMID).

Setup [#setup]

1\. Configure environment [#1-configure-environment]

Add these as swarm config values in the integrations dashboard, or set them in your `.env` for a self-hosted deployment:

```bash
# Required — Kapso API key (X-API-Key header)
KAPSO_API_KEY=your-kapso-api-key

# Required — the WhatsApp Business phone-number ID the swarm sends from
KAPSO_PHONE_NUMBER_ID=123456789012345

# Recommended — shared secret Kapso signs inbound webhooks with (X-Webhook-Signature, raw hex)
KAPSO_WEBHOOK_HMAC_SECRET=your-webhook-secret

# Optional — override the Kapso API base URL (defaults to https://api.kapso.ai)
# KAPSO_API_BASE_URL=https://api.kapso.ai
```

2\. Register the number [#2-register-the-number]

Provisioning is a **lead-only** operation. The lead calls the `register-kapso-number` MCP tool, which:

1. Points the number's Kapso webhook at `<MCP_BASE_URL>/api/integrations/kapso/webhook` (signed with `KAPSO_WEBHOOK_HMAC_SECRET`). This step is idempotent — an identical existing webhook is detected and not re-created.
2. Writes the routing mapping to KV so inbound messages route to an agent (defaults to the lead) or a workflow.

The tool also accepts optional `agentId`, `workflowId`, and `name` fields so you can route a number to a specific agent target, dispatch it through a workflow trigger, and label the mapping for operators.

To stop routing, the lead calls `unregister-kapso-number` (also lead-only). The Kapso-side webhook is not deleted automatically — remove it in the Kapso dashboard if you want deliveries to stop.

3\. Install the skill [#3-install-the-skill]

The `kapso-whatsapp` skill is the canonical reference for any WhatsApp interaction beyond plain text. It ships as a template skill at `templates/skills/kapso-whatsapp` and is recommended (auto-installable) on the integration's dashboard card.

How it works [#how-it-works]

Two inbound paths exist:

* **Native handler** (`/api/integrations/kapso/webhook`) — fires for any phone number registered via `register-kapso-number`. Verifies HMAC, dedupes by message id, reads the routing mapping from KV, and either dispatches a `kapso-inbound` task or delegates to a workflow trigger. Also emits a `kapso.message.received` event on the workflow event bus (additive — event-subscribed workflows can observe inbound regardless of mapping).
* **Workflow path** — a number whose mapping points at a `workflowId` dispatches inbound via that workflow's webhook trigger instead of creating a task directly.

The `kapso-inbound` task description is rendered from the `kapso.message.received` prompt template, so operators can customize the triage instructions without code changes.

Inbound events are filtered to `message.kapso.direction == "inbound"` — the swarm's own outbound sends, deliveries, reads, and failures do not create tasks.

Outbound updates [#outbound-updates]

* **Text (common case):** `send-whatsapp-message` / `reply-whatsapp-message` MCP tools. Free-form text is only allowed within the 24h WhatsApp session window; outside it the tool returns a structured `sessionWindowExpired` error pointing at the template path.
* **Provisioning / teardown:** `register-kapso-number` and `unregister-kapso-number` are the lead-only MCP tools for turning native routing on or off for a specific phone-number ID.
* **Everything else:** the `kapso-whatsapp` skill's REST recipes — templates (outside the 24h window), media (image / document / audio / video), reactions, typing indicator, mark-as-read, signature verification, contact resolution, and conversation history.

Reference [#reference]

* [Kapso](https://kapso.ai) — platform vendor.
* `kapso-whatsapp` skill — `templates/skills/kapso-whatsapp/SKILL.md` in this repo.


# Linear (/docs/integrations/linear)



Agent Swarm integrates with [Linear](https://linear.app) as an inbound issue-tracker source: assigning the agent to a Linear issue creates a swarm task and a Linear AgentSession. Activity flows back into the AgentSession as `thought` / `action` / `response` / `error` events.

<Callout type="info">
  **Sync direction is INBOUND-ONLY** (Linear → swarm). Inbound webhooks create and update swarm tasks. Outbound state changes (e.g. moving an issue to "Done", adding labels, posting plain comments outside an AgentSession) are **not** automatic — see [Outbound updates](#outbound-updates) below.
</Callout>

What it does [#what-it-does]

* **Inbound on assignment.** When a user assigns the Linear agent integration to an issue, Linear fires an `AgentSession` webhook. The handler creates a swarm task linked to the session.
* **State-gated task creation.** Only issues whose `WorkflowState.type` is in the configured allowlist trigger a task. By default that's `unstarted, started, completed, canceled` — i.e. everything except `triage` and `backlog`. Skipped assignments leave a comment on the AgentSession explaining how to retry.
* **Label override.** A label on the issue (default `swarm-ready`, configurable) bypasses the state gate so users can pre-stage backlog issues to auto-trigger when assigned.
* **AgentSession activity stream.** The agent posts thoughts, actions, responses, and errors back to the AgentSession in real time. A `response` activity auto-completes the session.
* **Webhook signing + dedup.** Inbound webhooks are HMAC-SHA256 verified and deduped by the `Linear-Delivery` header (5-minute TTL).
* **Dashboard linking.** `externalUrls` on each session link to the swarm dashboard's task page.

Setup [#setup]

1\. Create a Linear OAuth App [#1-create-a-linear-oauth-app]

1. Go to **Linear → Settings → API → Applications** and create a new application:
   * **Actor**: Application
   * **Callback URL**: `<MCP_BASE_URL>/api/trackers/linear/callback`
   * **Webhook URL**: `<MCP_BASE_URL>/api/trackers/linear/webhook`
2. Enable **Agent session events** in the webhook settings.

<Callout type="info">
  These URLs are built from `PUBLIC_MCP_BASE_URL` when it is set, falling back to `MCP_BASE_URL` otherwise. In split deploys where `MCP_BASE_URL` is an internal/cluster address, set `PUBLIC_MCP_BASE_URL` to the public ingress origin so Linear can reach the callback and webhook.
</Callout>

3. Copy the Client ID, Client Secret, and Webhook Signing Secret.

2\. Configure environment [#2-configure-environment]

Add to your `.env`:

```bash
# Required — OAuth + webhook auth
LINEAR_CLIENT_ID=your-client-id
LINEAR_CLIENT_SECRET=your-client-secret
LINEAR_REDIRECT_URI=http://localhost:3013/api/trackers/linear/callback
LINEAR_SIGNING_SECRET=your-webhook-signing-secret

# Optional — gate config (defaults shown)
LINEAR_ALLOWED_STATES=unstarted,started,completed,canceled
LINEAR_SWARM_READY_LABEL=swarm-ready

# Optional — disable the integration entirely
# LINEAR_DISABLE=true
```

With portless dev mode (`bun run dev:http`):

```bash
LINEAR_REDIRECT_URI=https://api.swarm.localhost:1355/api/trackers/linear/callback
```

3\. Complete OAuth [#3-complete-oauth]

Start the server and visit `<MCP_BASE_URL>/api/trackers/linear/authorize` in a browser to complete the OAuth flow. The token is encrypted at rest in `swarm_config`.

How it works [#how-it-works]

Inbound flow [#inbound-flow]

<Mermaid
  chart="sequenceDiagram
  participant U as User
  participant L as Linear
  participant S as Swarm API
  participant A as Agent

  U->>L: Assign agent to issue
  L->>S: AgentSessionEvent webhook
  S->>S: Verify HMAC, dedup, gate
  alt State allowed OR has swarm-ready label
    S->>S: Create swarm task + tracker_sync
    S->>L: Acknowledge AgentSession (thought)
    L->>A: Task delivered to lead/pool
    loop While working
      A->>L: Post thought / action activities
    end
    A->>L: Post response → session completes
  else State gated
    S->>L: Post skip response → session completes
  end"
/>

State gate [#state-gate]

When an issue is assigned, the handler resolves the issue's `WorkflowState.type` and label list. The decision is:

| Condition                                                                    | Result                                                                 |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Issue has the `swarm-ready` label (or your `LINEAR_SWARM_READY_LABEL` value) | **Create** — label override                                            |
| `state.type` ∈ `LINEAR_ALLOWED_STATES`                                       | **Create** — ready                                                     |
| `state.type` is unset / null                                                 | **Create** — fail-open                                                 |
| `state.type` ∉ allowlist                                                     | **Skip** — post a response on the AgentSession explaining how to retry |

`LINEAR_ALLOWED_STATES` is a comma-separated list of Linear `WorkflowState.type` values. The full enum is `triage, backlog, unstarted, started, completed, canceled`. Whitespace and case are normalized.

Examples:

```bash
# Default: skip Backlog and Triage
LINEAR_ALLOWED_STATES=unstarted,started,completed,canceled

# Only trigger when actively in progress
LINEAR_ALLOWED_STATES=started

# Open the gate completely (skip everything → only label override works)
LINEAR_ALLOWED_STATES=
```

The Linear `AgentSessionEvent` payload doesn't include state or labels, so the handler issues a small GraphQL query (`issue.state.type` + `labels.nodes.name`) to resolve them. If the OAuth token is missing, the gate fails open.

Skip message [#skip-message]

When the gate skips an assignment, the agent posts a `response` activity to the AgentSession (which auto-completes it):

> Agent Swarm received the assignment but skipped — this issue is in Backlog.
>
> To trigger work, move it to an allowed workflow state (e.g. **Todo** or **In Progress**), or add the `swarm-ready` label and re-assign the agent.

This is a deliberate design choice: silent no-ops on assignments are confusing. Always leave a trace.

Issue updates [#issue-updates]

Linear `Issue` updates that target a tracked issue refresh the swarm task's tracker metadata. State transitions:

| Linear state             | Swarm action                               |
| ------------------------ | ------------------------------------------ |
| `Backlog`                | log only, no status change                 |
| `Todo`                   | log only                                   |
| `In Progress`            | log only                                   |
| `Done`                   | log only — agent decides when work is done |
| `Canceled` / `Cancelled` | cancel the swarm task                      |
| Issue deleted            | cancel the swarm task                      |

Follow-up messages [#follow-up-messages]

When a user sends a message in the Linear agent chat after the original task is done:

1. Linear fires a `prompted` AgentSessionEvent.
2. The handler creates a new swarm task with the follow-up context, repointing the existing `tracker_sync` to it.
3. The agent processes the follow-up via the normal task lifecycle.

If the original task is still in flight, the handler posts a `thought` activity acknowledging the message but does **not** create a new task — the message is folded into the existing run.

Stop signal [#stop-signal]

If the user clicks the stop button in Linear, the agent activity carries `signal: "stop"`. The handler cancels the active swarm task and ends the session with a "Task cancelled by user." response.

Outbound updates [#outbound-updates]

The webhook integration is one-way. To **push** updates back to Linear from the swarm — creating issues, transitioning states, posting comments outside an AgentSession — use the [`linear-interaction` skill](#related-skills-references). The skill wraps the Linear GraphQL API:

```graphql
mutation IssueCreate($input: IssueCreateInput!) {
  issueCreate(input: $input) { issue { id identifier } }
}

mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
  issueUpdate(id: $id, input: $input) { success }
}

mutation CommentCreate($input: CommentCreateInput!) {
  commentCreate(input: $input) { comment { id } }
}
```

Authentication uses the swarm's OAuth token from `swarm_config` (stored encrypted under `LINEAR_OAUTH_TOKEN`). The lead agent fetches it via `db-query`; worker agents request it from the lead.

For activity within an existing AgentSession (the agent's running task), use the helpers in `src/linear/sync.ts` — `postAgentSessionResponse`, `postAgentSessionThought`, `postAgentSessionAction`, `postAgentSessionError`. These don't need the skill; they're called directly from the worker session.

MCP tools [#mcp-tools]

| Tool                  | Description                                               |
| --------------------- | --------------------------------------------------------- |
| `tracker-status`      | Check tracker connection status                           |
| `tracker-link-task`   | Link a swarm task to a Linear issue                       |
| `tracker-unlink`      | Remove a tracker link                                     |
| `tracker-sync-status` | View sync status for linked items                         |
| `tracker-map-agent`   | Map a swarm agent to a Linear user for assignment routing |

Architecture [#architecture]

The integration sits on top of a generic tracker abstraction shared with Jira and (eventually) others:

* `src/oauth/` — Reusable OAuth module with `oauth_apps` / `oauth_tokens` tables and PKCE support.
* `src/be/db-queries/tracker.ts` — Generic `tracker_sync` and `tracker_agent_mapping` tables.
* `src/linear/` — Linear-specific webhook handler (`webhook.ts`), AgentSession sync (`sync.ts`), state gate (`gate.ts`), and prompt templates (`templates.ts`).

The state gate is exposed as a pure function (`shouldCreateTaskFromLinearEvent` in `src/linear/gate.ts`) so it can be unit-tested without spinning up the API.

Related skills & references [#related-skills--references]

* **`linear-interaction` skill** — canonical procedure for pushing outbound updates (create issues, change status, comment) via the Linear GraphQL API. Invoke with the `Skill` tool.
* **[Linear API docs](https://developers.linear.app/docs)** — GraphQL schema and authentication reference.
* **[`@linear/sdk`](https://github.com/linear/linear/tree/master/packages/sdk)** — official TypeScript SDK; the swarm uses raw `fetch` against the GraphQL endpoint, but the SDK's type definitions are the canonical reference for payload shapes.

Related docs [#related-docs]

* [Environment Variables](/docs/reference/environment-variables) — full env reference
* [Task Lifecycle](/docs/concepts/task-lifecycle) — how tasks created from Linear flow through the swarm
* [Architecture Overview](/docs/architecture/overview) — system architecture including integrations


# Microsoft Graph (/docs/integrations/microsoft-graph)



Agent Swarm includes a narrow, reviewed [Microsoft Graph v1.0](https://learn.microsoft.com/en-us/graph/overview) connection for Microsoft 365. It exposes typed script methods for user profiles, Teams channels and messages, Outlook mail, and OneDrive folder listings.

<Callout type="warn">
  This connection supports **delegated access only**: a signed-in work or school user authorizes the swarm to act on their behalf through the [OAuth 2.0 authorization code flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow). App-only access is not available until Agent Swarm supports the `client_credentials` grant.
</Callout>

What it does [#what-it-does]

* **Microsoft Teams:** list [team channels](https://learn.microsoft.com/en-us/graph/api/channel-list?view=graph-rest-1.0), read [channel messages](https://learn.microsoft.com/en-us/graph/api/channel-list-messages?view=graph-rest-1.0) and [chat messages](https://learn.microsoft.com/en-us/graph/api/chat-list-messages?view=graph-rest-1.0), and [send channel messages, replies, and chat messages](https://learn.microsoft.com/en-us/graph/api/chatmessage-post?view=graph-rest-1.0).
* **Outlook:** list the signed-in user's [mail messages](https://learn.microsoft.com/en-us/graph/api/user-list-messages?view=graph-rest-1.0) and [send mail](https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0).
* **OneDrive:** [list items in the signed-in user's root folder](https://learn.microsoft.com/en-us/graph/api/driveitem-list-children?view=graph-rest-1.0).
* **Users:** [read the signed-in profile or another directory user's basic profile](https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0).

The reviewed spec intentionally exposes only these operations. Scripts call them through `ctx.api.microsoftGraph` after a lead registers the blessed connection.

Setup [#setup]

1\. Register a Microsoft Entra application [#1-register-a-microsoft-entra-application]

In the Microsoft Entra admin center, open **Entra ID → App registrations → New registration**. Choose the account type that matches your organization and record the **Application (client) ID**. Microsoft recommends single-tenant registration for most internal applications; see [Register an application in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app).

2\. Add the OAuth redirect URI [#2-add-the-oauth-redirect-uri]

Under **Authentication**, add a **Web** redirect URI that exactly matches the static callback shown in Agent Swarm's OAuth app dialog:

```
https://<your-swarm-api-host>/api/oauth/callback
```

Microsoft Entra only redirects to registered reply URLs, and production web redirect URIs must use HTTPS. See [Redirect URI restrictions](https://learn.microsoft.com/en-us/entra/identity-platform/reply-url).

3\. Add delegated Microsoft Graph permissions [#3-add-delegated-microsoft-graph-permissions]

Under **API permissions → Add a permission → Microsoft Graph → Delegated permissions**, add the scopes used by the blessed operations:

* `User.Read`
* `User.ReadBasic.All`
* `Channel.ReadBasic.All`
* `ChannelMessage.Read.All`
* `ChannelMessage.Send`
* `Chat.ReadWrite`
* `Mail.Read`
* `Mail.Send`
* `Files.Read`

The OAuth preset also requests `openid`, `profile`, and `offline_access`. Microsoft Entra only returns a refresh token when `offline_access` is requested. Some Teams permissions require an administrator to grant consent; verify each permission in the [Microsoft Graph permissions reference](https://learn.microsoft.com/en-us/graph/permissions-reference) and use **Grant admin consent** when your tenant requires it.

4\. Create a client secret [#4-create-a-client-secret]

Under **Certificates & secrets → Client secrets**, create a secret and copy its **Value** immediately. Microsoft Entra does not display the value again after you leave the page. See [Add and manage application credentials](https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-credentials).

Store the value only in Agent Swarm's OAuth app form. Do not place it in a script, a connection spec, or source control.

5\. Create and authorize the OAuth app [#5-create-and-authorize-the-oauth-app]

In the Agent Swarm dashboard, open **Connections → OAuth Apps**, add an app, and select the &#x2A;*Microsoft 365 (Graph)** preset. Paste the client ID and client secret, then save and authorize it.

The preset uses these Microsoft identity platform endpoints:

```
https://login.microsoftonline.com/common/oauth2/v2.0/authorize
https://login.microsoftonline.com/common/oauth2/v2.0/token
```

For a single-tenant registration, override both URLs and replace `common` with the tenant ID or verified tenant domain. Microsoft documents `common`, `organizations`, `consumers`, and tenant identifiers as supported issuer segments in [OAuth 2.0 and OpenID Connect protocols](https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols).

6\. Register the blessed connection [#6-register-the-blessed-connection]

In **Connections**, add a connection from the catalog and choose **Microsoft Graph**. Keep the generated `microsoftGraph` slug, select the Microsoft OAuth authorization, and save. The blessed entry supplies the reviewed spec and the `https://graph.microsoft.com/v1.0` base URL; the OAuth binding is restricted to `graph.microsoft.com`.

A lead can register the same connection with the `script-connections` tool. Use the authorization ID returned by the OAuth flow:

```jsonc
{
  "action": "upsert-openapi",
  "slug": "microsoftGraph",
  "displayName": "Microsoft Graph",
  "specSource": {
    "kind": "vendored",
    "slug": "microsoft-graph"
  },
  "auth": {
    "type": "oauth",
    "authorizationId": "<microsoft-authorization-id>",
    "hosts": ["graph.microsoft.com"]
  }
}
```

The catalog slug contains a hyphen, but script connection slugs are normalized to camel case. Use `ctx.api.microsoftGraph`, not `ctx.api.microsoft-graph`.

7\. Verify from a script [#7-verify-from-a-script]

This example posts a delegated-user message to a Teams channel:

```ts
export default async function (args, ctx) {
  return ctx.api.microsoftGraph.sendChannelMessage({
    path: {
      "team-id": args.teamId,
      "channel-id": args.channelId,
    },
    body: {
      body: {
        contentType: "text",
        content: args.message,
      },
    },
  });
}
```

The generated client also includes `getCurrentUser`, `getUser`, `listTeamChannels`, `listChannelMessages`, `replyToChannelMessage`, `listChatMessages`, `sendChatMessage`, `listMailMessages`, `sendMail`, and `listDriveRootChildren`.

How it works [#how-it-works]

The `microsoft-graph` catalog entry points at a narrow, operator-reviewed OpenAPI façade. The connection generator turns each operation ID into a typed method under `ctx.api.microsoftGraph`. The credential broker resolves the selected OAuth authorization only at network egress and inserts it as an `Authorization: Bearer ...` header for `graph.microsoft.com`.

Microsoft Entra issues the access token after the user completes the authorization code flow. The included `offline_access` scope allows Agent Swarm to refresh the delegated authorization when it expires. See the [Microsoft identity platform authorization code flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow).

Limitations [#limitations]

* **Delegated authorization only.** Microsoft documents client credentials for background services and daemons, but Agent Swarm's generic OAuth wrapper does not implement that grant yet. App-only Microsoft Graph access remains out of scope; see [Microsoft identity platform client credentials flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow).
* **Teams application posting is migration-only.** Microsoft lists `ChannelMessage.Send` for delegated channel posting. The application permission is `Teamwork.Migrate.All`, which is limited to importing messages into teams and channels in migration mode. See [Send a channel message](https://learn.microsoft.com/en-us/graph/api/channel-post-messages?view=graph-rest-1.0) and [send a chat message](https://learn.microsoft.com/en-us/graph/api/chat-post-messages?view=graph-rest-1.0).
* **No Teams bot identity or inbound mentions.** This is a Microsoft Graph data connection, not a native Teams bot or chat surface.
* **No binary file transfer.** The first reviewed spec exposes OneDrive folder listings, but not upload/download content operations, because the current typed script client is JSON/text-oriented.

Related docs [#related-docs]

* [Script connections](/docs/guides/script-connections) — connection registration, generated clients, and OAuth bindings
* [Scripts credential broker](/docs/guides/scripts-credential-broker) — egress allowlists and secret substitution
* [OAuth callback migration](/docs/guides/oauth-callback-migration) — the static callback URL used by generic OAuth apps


# Salesforce (/docs/integrations/salesforce)



Agent Swarm can connect to Salesforce as an MCP server over HTTP, authenticated via OAuth. Salesforce exposes several native hosted MCP endpoints — no proxy, no middleware required.

<Callout type="info">
  The OAuth setup (External Client App, redirect URI, global secrets) is the same regardless of which Salesforce MCP endpoint you use. Register whichever endpoint fits your use case and use the OAuth scopes below.
</Callout>

What it does [#what-it-does]

* **Registers Salesforce as an MCP server** on the HTTP transport pointing at one of Salesforce's hosted MCP endpoints (for example, `platform/mcp/v1/platform/sobject-reads` for sObject reads — see Step 2).
* **Authenticates with OAuth** using a Salesforce External Client App. The swarm handles the OAuth round-trip; agents call MCP tools without managing tokens directly.
* **Gives agents access to Salesforce data.** Once connected, any agent with the Salesforce server in scope can use the MCP tools exposed by the registered endpoint.

Setup [#setup]

1\. Activate the Salesforce Hosted MCP server [#1-activate-the-salesforce-hosted-mcp-server]

In Salesforce Setup, enable Hosted MCP Servers and activate the specific server you want agents to use before adding it to Agent Swarm.

The Agent Swarm connector URL points directly at Salesforce's hosted MCP endpoint. If that hosted server is not activated in Salesforce first, the URL cannot complete the MCP/OAuth flow.

2\. Register the MCP server [#2-register-the-mcp-server]

In the Agent Swarm dashboard (or via the CLI), register a new MCP server with HTTP transport:

* **Name:** `salesforce` (or any name you choose)
* **Transport:** HTTP
* **URL:** the Salesforce MCP endpoint you want to expose. For example, `https://api.salesforce.com/platform/mcp/v1/platform/sobject-reads` gives agents sObject read access. Salesforce hosts additional MCP endpoints — use the path that matches your use case.

Leave authentication unconfigured for now — you'll wire up OAuth in the next steps.

3\. Create a Salesforce External Client App [#3-create-a-salesforce-external-client-app]

In Salesforce Setup, go to **Setup → External Client App Manager → New External Client App** and enable OAuth settings:

**Callback URL** — must exactly match what the swarm sends:

```
https://<your-swarm-api-host>/api/mcp-oauth/callback
```

For example: `https://your-swarm.example.com/api/mcp-oauth/callback`

<Callout type="warn">
  **Exact match required.** No trailing slash, no port, no `www`. Salesforce's OAuth server does a literal string comparison. See [Gotchas](#gotchas) for the most common failure mode.
</Callout>

**OAuth scopes** — add these two:

* `Access Salesforce Hosted MCP Servers (mcp_api)`
* `Perform requests at any time (refresh_token, offline_access)`

**Supported Authorization Flows** — require the &#x2A;*Proof Key for Code Exchange (PKCE)** extension.

**Client secret** — optional. Agent Swarm can store and send a client secret, but Salesforce Hosted MCP clients do not require one unless you explicitly enable a secret requirement in the External Client App's OAuth settings. If you leave the Salesforce secret requirement disabled, copy only the **Consumer Key** (client ID).

<Callout type="info">
  OAuth changes in Salesforce can take up to 30 minutes to propagate before the External Client App is usable. If you get `invalid_client` immediately after saving, wait and retry.
</Callout>

4\. Paste the manual OAuth client into the swarm [#4-paste-the-manual-oauth-client-into-the-swarm]

Salesforce's authorization server &#x2A;*does not advertise a `registration_endpoint`**, which means OAuth Dynamic Client Registration (DCR) is not available. Register the pre-created Salesforce OAuth client manually in Agent Swarm (Settings → MCP Servers → your Salesforce server → OAuth config).

Agent Swarm stores these manual-client fields:

| Field                       | Value                                                                                               |
| --------------------------- | --------------------------------------------------------------------------------------------------- |
| `clientId`                  | Salesforce **Consumer Key**                                                                         |
| `clientSecret`              | Salesforce **Consumer Secret**, only if your External Client App requires one                       |
| `authorizationServerIssuer` | Salesforce OAuth issuer, for example `https://login.salesforce.com`                                 |
| `authorizeUrl`              | Salesforce authorize endpoint, for example `https://login.salesforce.com/services/oauth2/authorize` |
| `tokenUrl`                  | Salesforce token endpoint, for example `https://login.salesforce.com/services/oauth2/token`         |
| `scopes`                    | `mcp_api` and `refresh_token`                                                                       |
| `tokenEndpointAuthMethod`   | Optional client authentication method: `client_secret_basic`, `client_secret_post`, or `none`       |

If you use the API instead of the dashboard, the same fields are accepted by:

```bash
curl -X POST "$MCP_BASE_URL/api/mcp-oauth/<mcp-server-id>/manual-client" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "clientId": "<salesforce-consumer-key>",
    "authorizationServerIssuer": "https://login.salesforce.com",
    "authorizeUrl": "https://login.salesforce.com/services/oauth2/authorize",
    "tokenUrl": "https://login.salesforce.com/services/oauth2/token",
    "tokenEndpointAuthMethod": "client_secret_post",
    "scopes": ["mcp_api", "refresh_token"]
  }'
```

Add `"clientSecret": "<salesforce-consumer-secret>"` only when your Salesforce External Client App requires it. Set `tokenEndpointAuthMethod` to match how the pre-registered client authenticates. If omitted, manual clients preserve the legacy `client_secret_post` behavior unless discovery proves that method unsupported.

5\. Set global secrets [#5-set-global-secrets]

Two global secrets govern how the swarm builds the OAuth redirect URI and where it sends the browser after a successful authorization. Set them in **Settings → Configuration → Global Secrets** (they hot-reload into the API server without a restart):

| Secret         | Value                           | Why                                                                                                                                                    |
| -------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `MCP_BASE_URL` | `https://<your-swarm-api-host>` | Builds the `redirect_uri` sent to Salesforce                                                                                                           |
| `APP_URL`      | `https://<your-dashboard-host>` | Where the browser lands after OAuth succeeds. If you are not running your own deployment, leave it as the hosted default `https://app.agent-swarm.dev` |

Example:

```bash
MCP_BASE_URL=https://your-swarm-api.example.com
APP_URL=https://your-swarm-dashboard.example.com
```

<Callout type="info">
  If your `MCP_BASE_URL` must stay an internal/cluster address (split deploy), set `PUBLIC_MCP_BASE_URL` to the public origin instead. The OAuth redirect URI is built from `PUBLIC_MCP_BASE_URL` when set, falling back to `MCP_BASE_URL`. See [Environment Variables](/docs/reference/environment-variables) for the full fallback chain.
</Callout>

6\. Complete the OAuth flow [#6-complete-the-oauth-flow]

In the swarm dashboard, go to Settings → MCP Servers → your Salesforce server and click **Connect**. You'll be redirected to Salesforce's consent screen. After approving, the browser returns to your dashboard with `?oauth=success`.

7\. Verify [#7-verify]

Trigger a tool call to confirm the connection is working. The tools available depend on the endpoint you registered. For example, if you registered the sObject reads endpoint:

```sql
SELECT Id, Name, Industry FROM Account ORDER BY CreatedDate DESC LIMIT 3
```

Any agent with the Salesforce MCP server in scope can now use it via the registered MCP tools.

How it works [#how-it-works]

The OAuth redirect URI is built by `callbackRedirectUri()` in `src/http/mcp-oauth.ts`:

```
${getPublicMcpBaseUrl()}/api/mcp-oauth/callback
```

`getPublicMcpBaseUrl()` (in `src/utils/constants.ts`) resolves in this order:

1. `PUBLIC_MCP_BASE_URL`
2. `MCP_BASE_URL`
3. `http://localhost:${PORT||3013}`

It does **not** fall back to `APP_URL`. The `APP_URL` / `DASHBOARD_URL` chain is only used for the post-authorization browser redirect — the page the user lands on after consent, not the OAuth callback itself.

Both the `/authorize` route and the `/callback` route run on the **API server** process. Global secrets set in the dashboard are injected into that process's environment (and hot-reloaded), which is why updating them in the UI immediately takes effect.

Agent Swarm appends the [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707) `resource` parameter to the Salesforce authorization and token requests. The value is the MCP server URL you registered in Agent Swarm, for example:

```
resource=https://api.salesforce.com/platform/mcp/v1/platform/sobject-reads
```

That `resource` value is expected. It tells Salesforce which hosted MCP resource the OAuth token is being requested for.

Gotchas [#gotchas]

Common failure modes during initial setup. Read them before you start.

`redirect_uri_mismatch` (the #1 failure) [#redirect_uri_mismatch-the-1-failure]

**Symptom:** Salesforce rejects the authorization with `error=redirect_uri_mismatch`.

**Root cause:** The `redirect_uri` the swarm sent does not match the Callback URL in your External Client App. This almost always happens because neither `PUBLIC_MCP_BASE_URL` nor `MCP_BASE_URL` is set to your public API origin on the **API server process** — so the chain falls through to an internal or localhost address (e.g. `http://localhost:3013` or `http://api:3013`), which Salesforce cannot match.

**Fix:**

* **Single-origin deploy:** Set `MCP_BASE_URL` to the public API origin in global secrets.
* **Split deploy (internal `MCP_BASE_URL`):** Set `PUBLIC_MCP_BASE_URL` to the public ingress origin instead — don't repoint `MCP_BASE_URL` if internal callers depend on the internal address.

Confirm the fix: re-click Connect, watch the Salesforce consent URL in the browser address bar, and verify the `redirect_uri` query param matches your External Client App's Callback URL exactly.

Per-container env differs [#per-container-env-differs]

In Docker or Kubernetes deployments, `MCP_BASE_URL` may be set to the public URL on worker containers but to an internal cluster address on the API server container. The OAuth flow runs on the API server, so only its environment governs the `redirect_uri`. Do not diagnose this from a worker shell.

`invalid_scope` [#invalid_scope]

Caused by using an incorrect scope string. For Hosted MCP Servers, the required scope is `mcp_api` ("Access Salesforce Hosted MCP Servers"), not the generic `api` scope. Verify your External Client App includes exactly `mcp_api` and `refresh_token`.

No Dynamic Client Registration [#no-dynamic-client-registration]

Salesforce's authorization server does not advertise a `registration_endpoint`. OAuth DCR is not available — you must paste the Consumer Key, OAuth issuer, authorize URL, token URL, scopes, and optional Consumer Secret manually. Attempting DCR will fail silently or with an undocumented error.

Wrong dashboard after OAuth success [#wrong-dashboard-after-oauth-success]

If `APP_URL` is unset, the post-authorization redirect lands on the default `https://app.agent-swarm.dev` instead of your own dashboard. Set `APP_URL` in global secrets to your dashboard's public origin.

OAuth changes take time [#oauth-changes-take-time]

Salesforce External Client App changes (new callback URLs, scope changes) can take up to 30 minutes to propagate. If you get `invalid_client` or a redirect mismatch immediately after editing the app, wait before retrying.

Related docs [#related-docs]

* [Environment Variables](/docs/reference/environment-variables) — full `PUBLIC_MCP_BASE_URL → MCP_BASE_URL` and `APP_URL → DASHBOARD_URL` fallback chains
* [MCP Tools](/docs/reference/mcp-tools) — tools available to agents once a server is connected


# Sentry Integration (/docs/integrations/sentry)



Docker workers include `sentry-cli` pre-installed, enabling agents to investigate and triage Sentry issues directly.

Setup [#setup]

1. Create an Organization Auth Token at `https://sentry.io/settings/{org}/auth-tokens/` with scopes:
   * `event:read` — Read issues and events
   * `project:read` — Read project data
   * `org:read` — Read organization info

2. Add to your worker environment:

```bash
SENTRY_AUTH_TOKEN=your-auth-token
SENTRY_ORG=your-org-slug
```

3. Verify authentication in a worker:

```bash
sentry-cli info
```

Usage [#usage]

Workers can use the `/investigate-sentry-issue` command to:

* Get issue details and stacktraces
* Analyze breadcrumbs and context
* Resolve, mute, or unresolve issues

Examples [#examples]

```
/investigate-sentry-issue https://sentry.io/organizations/myorg/issues/123456/
```

Or just the issue ID:

```
/investigate-sentry-issue 123456
```

Related [#related]

* [Environment Variables](/docs/reference/environment-variables) — Sentry configuration (`SENTRY_AUTH_TOKEN`, `SENTRY_ORG`)
* [Task Lifecycle](/docs/concepts/task-lifecycle) — How Sentry issues become tasks


# Slack Integration (/docs/integrations/slack)



Enable Slack for task creation, agent communication, and interactive workflows via direct messages and the Slack Assistant sidebar.

Setup [#setup]

1. Create a [Slack App](https://api.slack.com/apps)
2. Enable **Socket Mode** (for real-time events without public webhooks)
3. Enable **Interactivity** (for action buttons and modals)
4. Enable **Assistant View** (for sidebar conversations)
5. Add required bot token scopes:
   * `app_mentions:read`
   * `assistant:write`
   * `channels:history`, `channels:join`, `channels:manage`, `channels:read`
   * `chat:write`, `chat:write.customize`, `chat:write.public`
   * `commands`
   * `files:read`, `files:write`
   * `groups:history`, `groups:read`, `groups:write`
   * `im:history`, `im:read`, `im:write`
   * `mpim:history`, `mpim:read`, `mpim:write`
   * `reactions:write`
   * `users:read`
6. Subscribe to bot events: `app_mention`, `assistant_thread_started`, `assistant_thread_context_changed`, `message.channels`, `message.groups`, `message.im`, `message.mpim`
7. Install to your workspace and copy tokens

A ready-to-use `slack-manifest.json` is included in the repository root — import it directly in the Slack App configuration page to set up all scopes, events, and features automatically. After adding channel-management scopes to an existing app, reinstall the app to the workspace so Slack grants them to the bot token.

Configuration [#configuration]

```bash
# Required for Slack
SLACK_BOT_TOKEN=xoxb-...      # Bot User OAuth Token
SLACK_APP_TOKEN=xapp-...      # App-Level Token (Socket Mode)
SLACK_SIGNING_SECRET=...      # Signing Secret (optional for Socket Mode)

# Disable Slack (if not using)
SLACK_DISABLE=true
```

Development API processes do not open Socket Mode by default, even when Slack tokens are present in the ambient environment. This prevents a local `bun run start:http` process from consuming events intended for production. Set `SLACK_ALLOW_DEV_SOCKET_MODE=true` only when that development process must connect to the configured Slack app; the server logs the blocked reason and this opt-in name otherwise.

How It Works [#how-it-works]

Creating Tasks [#creating-tasks]

@mention the bot in Slack to create tasks. All Slack messages are routed directly as tasks — there is no separate inbox system.

**Routing priority:**

1. `swarm#<uuid>` — explicit agent targeting (always wins)
2. `swarm#all` — broadcast to all workers
3. **Thread follow-up** — if in a thread where a worker is already active, or the thread was originally started by the swarm, routes directly to that worker/flow
4. **Lead fallback** — if the bot was @mentioned and no other match, routes to the lead agent

If no agents are online, the message is queued as an unassigned task in the pool. The bot confirms that your request has been queued and will be processed when agents come back up.

Acceptance reactions [#acceptance-reactions]

When the swarm accepts a Slack message, it adds an :eyes: reaction after it has successfully created or queued the task. The same acknowledgement appears when a thread message is accepted as steering for a running task. If ingestion fails before the request is accepted, the bot leaves the message unreacted instead of implying that work started; repeated delivery of an already-acknowledged event is treated as a harmless no-op.

Additive thread buffering uses a slightly richer vocabulary: :eyes: for the first captured message, :heavy\_plus\_sign: for later messages appended to that buffer, and :zap: when `!now` triggers an immediate flush.

Each of the 6 reactions is configurable through a `swarm_config` key, scope `global`. An unset key keeps the default shown below.

| Key                        | Event                                                                                                                        | Default            |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| `SLACK_REACTION_ACCEPTED`  | Task accepted from a channel mention, thread reply, follow-up or assistant DM. Also the first message in an additive buffer. | `eyes`             |
| `SLACK_REACTION_BUFFERED`  | Message 2 and later in an additive buffer window.                                                                            | `heavy_plus_sign`  |
| `SLACK_REACTION_NOW`       | The `!now` command flushes the buffer.                                                                                       | `zap`              |
| `SLACK_REACTION_STEERED`   | A thread message is accepted as steering for a running task.                                                                 | `speech_balloon`   |
| `SLACK_REACTION_COMPLETED` | Every task linked to the trigger message reached status `completed`.                                                         | `white_check_mark` |
| `SLACK_REACTION_FAILED`    | Any linked task reached `failed`, `cancelled` or `superseded`.                                                               | `x`                |

A value is trimmed, has at most one leading and one trailing colon stripped, and is lowercased before Slack sees it — `:ThumbsUp:` and `thumbsup` both resolve to `thumbsup`. A value that is empty afterward, or contains anything outside lowercase letters, digits, `_`, `+`, `'` and `-`, is rejected at write time and falls back to the default at read time.

If Slack rejects a configured name with `invalid_name` (the emoji does not exist in the workspace), the bot logs one error-level line, increments an OTel counter, and retries once with the default for that event before giving up on the reaction. Task state and the outcome card never depend on a reaction landing. A terminal reaction (`completed` or `failed`) is never removed once added.

When a task finalizes, the bot removes the acceptance-stage reactions and adds the terminal reaction. The removal list is the configured name for each of the four acceptance-stage events (`SLACK_REACTION_ACCEPTED`, `SLACK_REACTION_BUFFERED`, `SLACK_REACTION_NOW`, `SLACK_REACTION_STEERED`). With no configuration, that list is `eyes`, `heavy_plus_sign`, `zap` and `speech_balloon`, the same fixed list the bot removed before these keys existed. The bot keeps no record of which reaction it applied to which message, so an API restart between acceptance and finalization does not change the result. `reactions.remove` only removes the bot's own reaction, so a reaction that a person or another bot applied with the same name stays in place. If you change an acceptance-stage key while a task is active, the bot removes the new name at finalization and the old reaction stays on the message.

Thread Follow-up Routing [#thread-follow-up-routing]

When you @mention the bot in a thread where a worker is already handling a task, the message routes directly to that worker — no lead delegation needed. This keeps conversations flowing naturally.

If the assigned worker is offline or unavailable, the follow-up routes to the lead agent instead of dropping the message. The lead picks up the thread context and continues the conversation, preserving `parentTaskId` continuity for chained tasks.

By default, thread follow-ups route automatically without requiring an @mention. That includes human replies to swarm-started root messages, even when no task row existed yet for the thread. Set `SLACK_THREAD_FOLLOWUP_REQUIRE_MENTION=true` to require an explicit @mention for thread follow-up routing — non-mention thread messages will be silently dropped instead of auto-routing.

Live steering instead of follow-up tasks [#live-steering-instead-of-follow-up-tasks]

Slack can send buffered thread feedback into a task that is still running:

| Variable                     | Default | Behavior                                                                                                                                                                               |
| ---------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SLACK_THREAD_STEERING`      | off     | `lead` targets the latest in-progress lead task in the thread; `all` targets the latest active task regardless of role. Unset and other values preserve normal follow-up task routing. |
| `SLACK_THREAD_STEERING_MODE` | `queue` | `queue` adds the message at a turn boundary. `steer` requests an interrupt and degrades when the harness cannot interrupt.                                                             |

This is opt-in because it changes a thread reply from a durable follow-up task into input for an already-running task. Slack posts an acknowledgement that reflects the actual server outcome (`steered`, `queued`, or promoted to a follow-up).

**Follow-up re-delegation guard:** When the lead receives a `task.worker.completed` or `task.worker.failed` follow-up, it is explicitly instructed (via prompt template) not to re-delegate the same work back to a worker. A second guard in `send-task` blocks any re-delegation on a Slack thread that already has a completed task within the last 48 hours, preventing the duplicate-response cycle where repeated re-delegations caused the bot to answer the same thread multiple times.

Slack Context Propagation [#slack-context-propagation]

Slack metadata (`slackChannelId`, `slackThreadTs`, `slackUserId`) is **auto-inherited** from the creator's current task. When a lead delegates work from a Slack-originated task, workers automatically receive the Slack context and post progress updates to the originating thread — no manual metadata passing needed.

The auto-inheritance works via the `X-Source-Task-Id` header, which links the new task back to the creator's active task to look up Slack metadata.

For cases where auto-inheritance isn't available (e.g., programmatic task creation without an active parent task), you can pass Slack metadata explicitly on `send-task`:

* `slackChannelId` — Channel ID for progress updates
* `slackThreadTs` — Thread timestamp for thread-level updates
* `slackUserId` — Original requester's Slack user ID

Treat Slack channel/thread metadata as one routing unit. When a parent task or Slack-family context key already defines the route, `send-task` rejects a different explicit channel or thread instead of silently sending updates elsewhere. Omit the explicit Slack fields to inherit the existing route. For an intentional cross-channel handoff, provide both `slackChannelId` and `slackThreadTs` and set `overrideSlackContext: true`; the override is logged for audit.

Additive Slack Buffer [#additive-slack-buffer]

When enabled via `ADDITIVE_SLACK=true`, thread replies that do NOT @mention the bot are captured, buffered, and batched into a single follow-up task. This allows multi-message feedback without requiring an @mention each time.

* Messages are buffered for a configurable debounce window (`ADDITIVE_SLACK_BUFFER_MS`, default 10s)
* Buffered messages are flushed into a single task with dependency chaining to the active task
* Use the `!now` command to flush the buffer immediately — skips dependency chaining so the task starts right away
* Reactions provide visual feedback: :eyes: for first captured message, :heavy\_plus\_sign: for subsequent appended messages, :zap: for `!now`
* When `SLACK_THREAD_FOLLOWUP_REQUIRE_MENTION=true`, the additive buffer is disabled for non-mention messages

Tree-Based Status Messages [#tree-based-status-messages]

The legacy per-task renderer remains the default. Set `SLACK_RENDER_V2=true` to opt in to the v2 preview, where each Slack thread owns exactly one engine message: a task tree that is updated in place with `chat.update`. Later asks append to the same tree, delegated tasks stay nested under their parent, and task IDs remain clickable:

```
🧵 worked for 44m
 ├─ ✅ First ask · 7m51s · task-id
 ├─ ⏳ Current ask · 8m05s · task-id · latest progress
 │  └─ ✅ Researcher · 8m24s · task-id
 └─ ✅ Completed ask · 12m · task-id
```

The tree is rendered as footer-weight context blocks and contains status, elapsed time, hierarchy, task links, and the latest bounded progress for active tasks. It does not repeat worker output or cross-link Slack messages: keeping message permalinks out of the tree prevents Slack from producing noisy link previews. Updates are debounced, link/media unfurls are disabled, and rate-limit retries honor Slack backoff.

Outcome Cards and Message Provenance [#outcome-cards-and-message-provenance]

Each completed Slack ask gets one outcome card containing the full multi-paragraph Markdown result, bounded only by Slack's presentation limit with a link to the full task when truncation is required. If the agent already delivered the result with `slack-reply`, the outcome card collapses to a compact completion instead of repeating that message. The renderer re-reads this delivery marker before finalizing, so a retry cannot preserve stale duplicate content. Outcome cards do not link back to the tree, which avoids an otherwise redundant Slack permalink unfurl.

Terminal task output is automatically delivered through the thread's outcome card. No additional relay message is created: extra messages only appear when an agent explicitly calls `slack-reply`, `slack-post`, or `slack-start-thread`. When `slack-reply` is used, the outcome card is compacted instead of duplicating that explicit reply. These tools accept optional Block Kit `blocks`; when omitted they generate a mrkdwn section. Their compact context footer contains the originating agent and task without a Slack-message permalink.

Tree, outcome, and explicit agent message timestamps are persisted in `slack_messages`, so the renderer can reuse messages after a restart.

Opt In to the Renderer [#opt-in-to-the-renderer]

Leave `SLACK_RENDER_V2` unset or set it to `false` to use the default legacy per-task assignment/progress/completion renderer. Set `SLACK_RENDER_V2=true` to preview the new task tree and streamed outcome cards without changing the deployment.

Rich Block Kit Messages [#rich-block-kit-messages]

The v2 tree uses `mrkdwn` inside footer-weight context blocks, which keeps every task ID clickable. Outcome and explicit agent messages use [Block Kit](https://api.slack.com/block-kit) where it adds structure:

* **Context blocks** for the compact provenance footer
* **Section blocks** for explicitly supplied agent content
* Markdown is automatically converted to Slack's `mrkdwn` format

Interactive Actions [#interactive-actions]

The default legacy renderer's task messages include interactive buttons when `SLACK_RENDER_V2` is unset or `false`:

* **Follow-up** — Opens a modal to send a follow-up message to the same agent, creating a new task with dependency on the completed one
* **View Full Logs** — Links to the task detail page in the dashboard
* **Cancel** — Shows a confirmation dialog before cancelling an in-progress task

Assistant Sidebar [#assistant-sidebar]

The bot supports Slack's [Assistant](https://api.slack.com/docs/apps/ai) sidebar for direct conversations:

* Open the sidebar in any channel or DM to start a conversation
* Suggested prompts help you get started ("Check agent status", "Assign a task", "List recent tasks")
* Follow-up messages in assistant threads route to the same agent that handled the original task
* Assistant-thread messages that only @mention another user are ignored unless they also mention the swarm bot, preventing accidental task creation from co-mentions like `@Devin are you here?`
* File uploads (`file_share` messages) in assistant threads are automatically detected and routed to the lead agent
* The assistant sets a typing status while the agent is working (gracefully handles permission errors in non-assistant threads)

Progress Updates [#progress-updates]

The engine reflects progress by updating the thread tree. Agents can choose to send a distinct message with `slack-reply`, but routine start, progress, completion, and failure receipts are not posted automatically.

Reading Messages [#reading-messages]

Agents can read Slack threads using `slack-read`:

* By task ID (reads the thread associated with a task)
* By channel ID (leads only, for channel history)

`slack-read` and the worker thread-context helpers extract all message layers together: top-level `text`, legacy `attachments`, and Block Kit `blocks`. That means alert threads from tools like Datadog, PagerDuty, and GitHub keep both the short summary and the richer body content (fields, context, action URLs) instead of silently dropping everything outside the root `text`.

Posting Messages [#posting-messages]

The lead agent can post messages to channels using `slack-post`. By default each call creates a new top-level message. To run a multi-message conversation under a single Slack thread, the lead first calls `slack-start-thread` to create the parent message, then passes the returned `ts` as `threadTs` on subsequent `slack-post` calls — keeping the channel tidy and the conversation discoverable.

Managing Channels [#managing-channels]

Lead agents can manage the Slack channel lifecycle through three MCP tools:

* **`slack-create-channel`** — create a public or private channel. Slack naming rules are applied and the normalized name is returned.
* **`slack-invite-to-channel`** — invite up to 100 workspace users. Users who are already members are treated as a successful no-op.
* **`slack-archive-channel`** — archive a channel. Already-archived channels are a successful no-op, while Slack's general channel remains protected.

These operations require lead privileges plus `channels:manage` for public channels and `groups:write` for private channels. If Slack reports a missing scope, update `slack-manifest.json`, apply the manifest, and reinstall the app before retrying.

Updating or Deleting Messages [#updating-or-deleting-messages]

Lead-gated Slack mutation tools can also manage an existing swarm-authored message after it has been posted:

* **`slack-update`** — replace the text or blocks of an existing message in a channel or thread
* **`slack-delete`** — remove an existing message when follow-up automation or cleanup needs it

These mutation tools follow the same public-channel auto-join behavior as the other Slack tools, but they stay lead-only because they change already-published Slack state.

Channel Membership [#channel-membership]

If the bot is not yet a member of a **public, internal** channel, `slack-read`, `slack-post`, `slack-reply`, and `slack-start-thread` automatically join it (via the `channels:join` scope) and retry — no manual `/invite` needed. **Private** channels and **external Slack Connect** channels cannot be self-joined: the tools return a clear error asking you to invite the bot with `/invite @<bot-name>` first.

User Filtering [#user-filtering]

By default, all Slack users can interact with the bot. To restrict access:

```bash
# Only users with matching email domains
SLACK_ALLOWED_EMAIL_DOMAINS=company.com,partner.com

# Specific user IDs always allowed (useful for admins)
SLACK_ALLOWED_USER_IDS=U12345678,U87654321
```

If both are set, a user must match **either** an allowed domain **or** be in the user ID whitelist.

Attachment Handling [#attachment-handling]

Slack messages with file attachments (voice memos, images, documents) are automatically recognized and processed. When a user sends a file — even without any text — the bot detects it and includes attachment metadata (filename, MIME type, size, Slack file ID) in the task description.

This means agents can:

* Receive voice messages or image uploads as tasks
* Access file metadata to decide whether to download and process attachments
* Use `slack-download-file` to retrieve the actual file content

File Handling [#file-handling]

Agents can upload and download files via Slack:

* **`slack-upload-file`** — Upload a file to a Slack channel or thread
* **`slack-download-file`** — Download a file from Slack by file ID or URL

For task-scoped uploads, `slack-upload-file` preserves the visible conversation thread: channel tasks upload under the original Slack thread, and Slack DMs prefer the user-facing DM tree root instead of an internal progress-message thread when both exist.

Files are saved to `/workspace/shared/downloads/{agentId}/slack/` by default (each agent writes to its own subdirectory).

Related [#related]

* [Environment Variables](/docs/reference/environment-variables) — Slack configuration variables (`SLACK_BOT_TOKEN`, etc.)
* [Task Lifecycle](/docs/concepts/task-lifecycle) — How Slack messages become tasks
* [GitHub App Integration](/docs/integrations/github) — Another external task source
* [Linear Integration](/docs/integrations/linear) — Bidirectional ticket tracking with Linear
* [Sentry Integration](/docs/integrations/sentry) — Automated error triage from Sentry
* [x402 Payments](/docs/guides/x402-payments) — Enable agents to make crypto micropayments


# CLI Reference (/docs/reference/cli)



Agent Swarm provides a CLI for managing the swarm, running agents, and development.

Installation [#installation]

```bash
# Run directly with bunx or npx
bunx @desplega.ai/agent-swarm <command>
npx @desplega.ai/agent-swarm <command>

# Or install globally
bun install -g @desplega.ai/agent-swarm
agent-swarm <command>
```

Commands [#commands]

onboard [#onboard]

Set up a new swarm from scratch using Docker Compose. The interactive wizard collects credentials, generates `docker-compose.yml` + `.env`, starts the stack, verifies health, and prints a dashboard deep-link that auto-connects with the generated API URL and key.

```bash
bunx @desplega.ai/agent-swarm onboard
npx @desplega.ai/agent-swarm onboard
bunx @desplega.ai/agent-swarm onboard --dry-run
bunx @desplega.ai/agent-swarm onboard --yes --preset=full
bunx @desplega.ai/agent-swarm onboard --yes --preset=dev
ANTHROPIC_API_KEY=sk-... bunx @desplega.ai/agent-swarm onboard --yes --preset=dev --pull-policy=missing
ANTHROPIC_API_KEY=sk-... bunx @desplega.ai/agent-swarm onboard --yes --preset=solo
```

| Option                       | Description                                                                           |
| ---------------------------- | ------------------------------------------------------------------------------------- |
| `--dry-run`                  | Preview what would be generated without writing                                       |
| `-y, --yes`                  | Non-interactive mode (reads from env vars)                                            |
| `--preset <name>`            | Preset: `full`, `dev`, `content`, `research`, `solo` (required with `--yes`)          |
| `--max-concurrent-tasks <n>` | Tasks per generated agent, from 1 to 100 (defaults: lead 2, worker 1)                 |
| `--pull-policy <policy>`     | Docker Compose image pull policy: `always`, `missing`, or `never` (default: `always`) |

connect [#connect]

Connect this project to an existing swarm. Creates `.mcp.json` and `.claude/settings.local.json` with server URL and API key. Auto-reads `AGENT_SWARM_API_KEY` (or legacy `API_KEY`) from `.env` if present.

```bash
bunx @desplega.ai/agent-swarm connect
npx @desplega.ai/agent-swarm connect
bunx @desplega.ai/agent-swarm connect --dry-run
bunx @desplega.ai/agent-swarm connect -y
```

| Option      | Description                                |
| ----------- | ------------------------------------------ |
| `--dry-run` | Show what would be changed without writing |
| `--restore` | Restore files from `.bak` backups          |
| `-y, --yes` | Non-interactive mode (use env vars)        |

api [#api]

Start the API + MCP HTTP server.

```bash
bunx @desplega.ai/agent-swarm api
npx @desplega.ai/agent-swarm api
bunx @desplega.ai/agent-swarm api --port 8080 --key my-secret
bunx @desplega.ai/agent-swarm api --db /data/swarm.sqlite
```

| Option              | Description                                             |
| ------------------- | ------------------------------------------------------- |
| `-p, --port <port>` | Port to listen on (default: 3013)                       |
| `-k, --key <key>`   | API key for authentication                              |
| `--db <path>`       | Database file path (default: `./agent-swarm-db.sqlite`) |

claude [#claude]

Run Claude CLI with optional message and headless mode.

```bash
agent-swarm claude
agent-swarm claude --headless -m "Hello"
agent-swarm claude -- --resume
```

| Option                | Description                                |
| --------------------- | ------------------------------------------ |
| `-m, --msg <message>` | Message to send to Claude                  |
| `--headless`          | Run in headless mode (stream JSON output)  |
| `-- <args...>`        | Additional arguments to pass to Claude CLI |

worker [#worker]

Run Claude in headless loop mode as a worker agent.

```bash
agent-swarm worker
agent-swarm worker --yolo
agent-swarm worker -m "Custom prompt"
agent-swarm worker --system-prompt "You are a Python specialist"
```

| Option                        | Description                                          |
| ----------------------------- | ---------------------------------------------------- |
| `-m, --msg <prompt>`          | Custom prompt (default: `/agent-swarm:start-worker`) |
| `--yolo`                      | Continue on errors instead of stopping               |
| `--system-prompt <text>`      | Custom system prompt (appended to Claude)            |
| `--system-prompt-file <path>` | Read system prompt from file                         |
| `-- <args...>`                | Additional arguments to pass to Claude CLI           |

lead [#lead]

Run Claude as lead agent in headless loop mode. Same options as `worker`.

```bash
agent-swarm lead
agent-swarm lead --yolo
```

codex-login [#codex-login]

Authenticate Codex via ChatGPT OAuth (browser or manual paste). Prompts interactively for the target API URL and a best-effort masked API key, then stores credentials in the swarm API config store for deployed workers. Run from your laptop, **not** inside a worker container.

```bash
bunx @desplega.ai/agent-swarm codex-login
npx @desplega.ai/agent-swarm codex-login
bunx @desplega.ai/agent-swarm codex-login --api-url https://swarm.example.com
```

| Option            | Description                                                                                             |
| ----------------- | ------------------------------------------------------------------------------------------------------- |
| `--api-url <url>` | Swarm API URL (default: `MCP_BASE_URL` or `http://localhost:3013`)                                      |
| `--api-key <key>` | Swarm API key (default: `API_KEY` or `123123`)                                                          |
| `--slot <n>`      | Store the OAuth credential in a specific pool slot (`0-100`) instead of auto-picking the next free slot |

See [Provider Auth: Codex OAuth](/docs/guides/provider-auth/codex-oauth) for the full ChatGPT OAuth flow.

rbac bootstrap [#rbac-bootstrap]

Seed the built-in RBAC roles and backfill the default role onto any zero-role
users. This is idempotent and is the operator-side bootstrap step before
turning `RBAC_ENABLED=true`.

```bash
bunx @desplega.ai/agent-swarm rbac bootstrap
npx @desplega.ai/agent-swarm rbac bootstrap
agent-swarm rbac bootstrap
```

The command accepts exactly the `bootstrap` subcommand. Anything else prints
help and exits non-zero.

claude-managed-setup [#claude-managed-setup]

Bootstrap [Anthropic Managed Agents](/docs/guides/harness-configuration#claude-managed-agents) for the swarm: create the cloud Environment, upload `plugin/commands/*.md` skills, create the managed Agent, and persist the resulting `MANAGED_AGENT_ID` + `MANAGED_ENVIRONMENT_ID` to `swarm_config` so deployed workers can restore them at boot. Prompts interactively for `ANTHROPIC_API_KEY` when not set in env. Idempotent — re-run with `--force` to recreate. Run from your laptop, **not** inside a worker container.

```bash
bunx @desplega.ai/agent-swarm claude-managed-setup
npx @desplega.ai/agent-swarm claude-managed-setup
bunx @desplega.ai/agent-swarm claude-managed-setup --force
bunx @desplega.ai/agent-swarm claude-managed-setup --api-url https://swarm.example.com
```

| Option            | Description                                                        |
| ----------------- | ------------------------------------------------------------------ |
| `--api-url <url>` | Swarm API URL (default: `MCP_BASE_URL` or `http://localhost:3013`) |
| `--api-key <key>` | Swarm API key (default: `API_KEY` or `123123`)                     |
| `--force`         | Recreate Anthropic-side resources even if already configured       |

e2b [#e2b]

Build Agent Swarm E2B templates and launch or manage grouped E2B swarms on demand for CI, Dockerless smoke tests, and ephemeral staging environments.

```bash
bunx @desplega.ai/agent-swarm e2b build-template --role worker
npx @desplega.ai/agent-swarm e2b build-template --role worker
bunx @desplega.ai/agent-swarm e2b start-worker --api-url https://swarm.example.com --api-key "$SWARM_API_KEY"
bunx @desplega.ai/agent-swarm e2b start-stack --yes --swarm demo --workers 2 --api-key "$SWARM_API_KEY"
bunx @desplega.ai/agent-swarm e2b swarms info demo
bunx @desplega.ai/agent-swarm e2b swarms logs demo --role api --follow
```

| Subcommand                          | Description                                                                                          |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `build-template --role api\|worker` | Build or rebuild an E2B template                                                                     |
| `delete-template <template...>`     | Delete E2B templates                                                                                 |
| `publish-template <template...>`    | Publish E2B templates                                                                                |
| `unpublish-template <template...>`  | Make E2B templates private                                                                           |
| `start-api --template <name>`       | Start the API in an E2B sandbox                                                                      |
| `start-worker --api-url <url>`      | Start a worker against a public API URL                                                              |
| `start-stack`                       | Launch a grouped API + lead + N worker swarm; opens an Ink wizard on a TTY unless `--yes` / headless |
| `list`                              | List active E2B sandboxes                                                                            |
| `extend <sandbox-id...>`            | Extend or reduce one or more sandbox TTLs with `--timeout-sec <seconds>`                             |
| `swarms list`                       | Group dispatcher-owned sandboxes by `metadata.swarm` slug                                            |
| `swarms info <slug>`                | Show API URL, key source, TTLs, health, and dashboard deep-link for a swarm                          |
| `swarms add <slug>`                 | Add workers or `--add-lead` to an existing swarm and re-sync their TTL to the group's current end    |
| `swarms logs <slug>`                | Stream tee'd entrypoint logs for the API, lead, or worker role; `--follow` tails live output         |
| `swarms kill <slug> \| --all`       | Tear down one grouped swarm or sweep every dispatcher-owned swarm                                    |
| `kill <sandbox-id...> \| --all`     | Stop one or more E2B sandboxes, or every dispatcher-owned sandbox                                    |

Key E2B options:

| Option                                                   | Description                                                        |
| -------------------------------------------------------- | ------------------------------------------------------------------ |
| `--swarm <slug>`                                         | Set the group slug used by `start-stack` and the `swarms` commands |
| `--workers <n>`                                          | Number of workers to start or add (default `1`)                    |
| `--no-lead`                                              | Legacy topology: start only the API plus workers                   |
| `--provider <name>`                                      | Worker harness provider (default `claude`)                         |
| `--timeout-sec <seconds>`                                | Sandbox TTL; for `extend`, the new TTL counted from now            |
| `--env-file`, `--secret`, `--inherit-env`                | Shared runtime configuration applied to all roles                  |
| `--api-env-file`, `--lead-env-file`, `--worker-env-file` | Role-scoped env layers added on top of the shared config           |
| `--api-secret`, `--lead-secret`, `--worker-secret`       | Role-scoped secrets layered on top of the shared config            |
| `--json`                                                 | Machine-readable output for automation                             |

`start-stack` now defaults to the full API + lead + workers topology. It runs interactively on a TTY, but switches to headless mode automatically under `--yes`, `--non-interactive`, `--dry-run`, or any non-TTY invocation. The `swarms logs` output is secret-scrubbed before it hits stdout, and `swarms info` can optionally embed the API key into the dashboard deep-link with `--reveal-key` when you explicitly need a copy-pasteable URL.

x [#x]

Execute an external command route. The first target is Composio:

```bash
agent-swarm x composio GET /tools
agent-swarm x composio POST /tool_router/session --body '{"user_id":"swarm-user-id"}'
```

See [The `x` command](/docs/reference/x-command) for the Composio workflow,
`swarm_x` MCP equivalent, and safety rules.

docs [#docs]

Show the documentation URL. All pages are also available in markdown format by appending `.md` to the URL.

```bash
bunx @desplega.ai/agent-swarm docs
npx @desplega.ai/agent-swarm docs
bunx @desplega.ai/agent-swarm docs --open
```

| Option   | Description                  |
| -------- | ---------------------------- |
| `--open` | Open docs in default browser |

hook [#hook]

Handle Claude Code hook events from stdin. Used internally by the agent-swarm hooks system.

```bash
agent-swarm hook
```

codex-hook [#codex-hook]

Handle Codex CLI lifecycle hook events from stdin for queued steering delivery.
This internal command is registered by the worker image's managed Codex
requirements and is not normally invoked by operators.

```bash
agent-swarm codex-hook
```

artifact [#artifact]

Manage agent artifacts — serve static files or Hono apps via localtunnel.

```bash
# Serve a directory as a public artifact
agent-swarm artifact serve ./my-report --name my-report

# Serve a Hono app (must export default Hono instance)
agent-swarm artifact serve ./server.ts --name dashboard

# List active artifacts
agent-swarm artifact list

# Stop an artifact
agent-swarm artifact stop my-report
```

**Subcommands:**

| Subcommand     | Description                                     |
| -------------- | ----------------------------------------------- |
| `serve <path>` | Serve a directory or script via localtunnel     |
| `list`         | List active artifacts from the service registry |
| `stop <name>`  | Stop an artifact and close its tunnel           |

**Options for `serve`:**

| Option              | Description                                          |
| ------------------- | ---------------------------------------------------- |
| `--name <name>`     | Name for the artifact (derived from path if omitted) |
| `--port <port>`     | Local port to use                                    |
| `--no-auth`         | Disable authentication                               |
| `--subdomain <sub>` | Request a specific localtunnel subdomain             |

Artifacts are registered in the service registry and automatically cleaned up when the session ends (via the Stop hook).

Development Commands [#development-commands]

These are available when developing Agent Swarm locally:

```bash
# Start MCP server
bun run start        # STDIO transport
bun run start:http   # HTTP transport

# Development with hot reload
bun run dev          # STDIO
bun run dev:http     # HTTP

# MCP Inspector (debug tools)
bun run inspector:http

# Run worker/lead locally
bun run worker
bun run lead

# Hook handler
bun run hook

# Linting and formatting
bun run lint
bun run lint:fix
bun run format

# Type checking
bun run tsc:check

# Build binaries
bun run build:binary      # x64
bun run build:binary:arm64  # ARM64

# Docker
bun run docker:build:worker
bun run docker:run:worker
bun run docker:run:lead

# Generate MCP docs
bun run docs:mcp
```

Dashboard UI [#dashboard-ui]

The React-based monitoring dashboard is in the `ui/` directory:

```bash
cd ui
pnpm install
pnpm run dev
```

Opens at `http://localhost:5274`. Provides real-time visibility into:

* Agent status and activity
* Task list and progress
* Inter-agent chat messages
* Service registry
* Usage and cost tracking

Related [#related]

* [Getting Started](/docs/getting-started) — Set up your first swarm
* [Environment Variables](/docs/reference/environment-variables) — Configuration reference for all env vars
* [MCP Tools Reference](/docs/reference/mcp-tools) — Complete reference for all swarm MCP tools
* [Deployment Guide](/docs/guides/deployment) — Production deployment options


# Environment Variables (/docs/reference/environment-variables)



Complete reference for all environment variables used by Agent Swarm.

Server Variables [#server-variables]

| Variable                                    | Default                                                                                                                     | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT`                                      | `3013`                                                                                                                      | Port for MCP HTTP server                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `AGENT_SWARM_API_KEY`                       | —                                                                                                                           | Preferred, namespaced API key for server authentication — takes precedence over `API_KEY` (set either one). Always read via `getApiKey()` (`src/utils/api-key.ts`), never `process.env.API_KEY` / `process.env.AGENT_SWARM_API_KEY` directly.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `API_KEY`                                   | —                                                                                                                           | Legacy API key for server authentication (required unless `AGENT_SWARM_API_KEY` is set). See `AGENT_SWARM_API_KEY` above for the preferred, namespaced variable.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `RBAC_ENABLED`                              | `false`                                                                                                                     | Gates REST requests authenticated with `aswt_` user tokens against RBAC role grants. Operator API-key requests and agent-authenticated calls are unaffected. Keep it off until you have seeded roles and validated the permission model for your deployment.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `SCRIPTS_ONLY_MCP`                          | `false` (unset/off)                                                                                                         | Experimental code-mode. Set to `true` on the API server and agent containers to expose only the eight script tools over MCP while keeping the full swarm SDK available through `script-run`. It can also be configured per agent/repository/global scope through `swarm_config`; see [Scripts-only mode](/docs/guides/scripts-only-mode).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `MCP_BASE_URL`                              | `https://api.example-swarm.dev`                                                                                             | Internal/worker-facing API base — the URL workers, the UI, and the setup command use to reach the API server. In split deploys this may be an internal/cluster address.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `PUBLIC_MCP_BASE_URL`                       | Falls back to `MCP_BASE_URL`                                                                                                | Public, browser/externally-reachable API origin used for OAuth redirect URIs and webhook URLs. Defaults to `MCP_BASE_URL` when unset; set it in split deploys where `MCP_BASE_URL` is an internal/cluster address that external providers (Linear, Jira, GitHub) can't reach.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `MCP_OAUTH_ALLOW_PRIVATE_HOSTS`             | `false`                                                                                                                     | When `true`, disables the SSRF guard that otherwise refuses `localhost`/RFC1918 private-IP hosts when the server connects to an operator-registered MCP server's OAuth discovery/token endpoints. Only needed to reach an internal-only MCP server, or for local dev/testing — keep the default deny-list in production.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `OAUTH_KEEPALIVE_DISABLE`                   | `false`                                                                                                                     | Disables the background job that proactively refreshes Linear/Jira OAuth refresh tokens every 12h to prevent silent expiry.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `SWARM_URL`                                 | `localhost`                                                                                                                 | Base domain for service discovery                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `APP_URL`                                   | —                                                                                                                           | Dashboard URL for Slack message links. `DASHBOARD_URL` is a deprecated alias of `APP_URL`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `SWARM_DASHBOARD_URL`                       | Falls back to `APP_URL`                                                                                                     | Optional override for the dashboard base URL used to build the "View in Agent Swarm" link posted back to a Linear `AgentSession`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `TRUST_BODY_REQUESTED_BY_USER_ID`           | `true` (unset/on)                                                                                                           | When on (default), the server may honor a body-supplied `requestedByUserId` on `POST /api/tasks` (validated against a real user row) if no authenticated/owned-task identity is available — this keeps UI/API task attribution working under a shared operator key. Set to `false` in multi-tenant deployments where callers of the shared key are not all equally trusted, restoring the strict anti-spoofing behavior (body field ignored for operator/global-key callers).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `STEERING_ENABLED`                          | `false` (unset/off)                                                                                                         | Set to `true` or `1` (API server and worker containers) to enable [task steering](/docs/guides/task-steering). Off by default: steering routes reject writes, `steer-task`/`accept-steer` are not registered, and workers skip steering delivery polls.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `ENV`                                       | —                                                                                                                           | Environment mode (`development` adds prefix to Slack agent names)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `DATABASE_PATH`                             | `./agent-swarm-db.sqlite`                                                                                                   | SQLite database file path                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `SESSION_LOG_RETENTION_DAYS`                | — (disabled)                                                                                                                | Permanently delete `session_logs` rows older than this many whole days. Accepted range: 1–1,000,000. Leave unset to disable this table's sweep. Start with `DB_RETENTION_DRY_RUN=true`; deleted session transcripts cannot be restored without a backup. See [Database retention](/docs/guides/deployment#database-retention).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `AGENT_LOG_RETENTION_DAYS`                  | — (disabled)                                                                                                                | Permanently delete `agent_log` rows older than this many whole days. Accepted range: 1–1,000,000. Leave unset to disable this table's sweep. Deleted task and agent history cannot be restored without a backup. See [Database retention](/docs/guides/deployment#database-retention).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `EVENTS_RETENTION_DAYS`                     | — (disabled)                                                                                                                | Permanently delete `events` rows older than this many whole days. Accepted range: 1–1,000,000. Leave unset to disable this table's sweep. Event aggregates become retention-window totals, and deleted telemetry cannot be restored without a backup. See [Database retention](/docs/guides/deployment#database-retention).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `DB_RETENTION_DRY_RUN`                      | `false`                                                                                                                     | When `true` or `1`, report the exact number of rows each enabled retention policy would delete without deleting or vacuuming data. Only `true`, `false`, `1`, and `0` are accepted through configuration writes. Use dry run before every first production activation.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `DB_RETENTION_TICK_BUDGET_MS`               | `30000`                                                                                                                     | Wall-clock budget for one retention tick, shared across enabled tables. Accepted range: 1,000–300,000 ms. Read on every tick; out-of-range environment values fall back to the default.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `DB_RETENTION_CATCHUP_INTERVAL_MS`          | `60000`                                                                                                                     | Delay before another retention tick when at least one table remains undrained. Accepted range: 5,000–3,600,000 ms. The normal cadence returns to hourly after the backlog drains.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `DB_RETENTION_MAX_STATEMENT_MS`             | `250`                                                                                                                       | Target maximum driver execution time for one retention DELETE. Accepted range: 25–5,000 ms; the adaptive batch sizer tunes each table toward this ceiling.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `ASSET_KEY_AUDIT_DISABLE_STARTUP_HARD_FAIL` | `false`                                                                                                                     | Temporary recovery switch. When `true`, the startup asset-namespace audit logs structural key failures instead of aborting the server. Repair the data and remove the switch before normal operation; mapping-drift warnings never abort startup. See [Asset Namespaces](/docs/guides/asset-namespaces#audit-and-rollout).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `SQLITE_VEC_EXTENSION_PATH`                 | —                                                                                                                           | Path to `vec0.so` native extension for sqlite-vec vector search. Set automatically in the Docker image (`/app/extensions/vec0.so`). Only needed for non-Docker deployments.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `SCRIPT_RUNTIME_DIR`                        | —                                                                                                                           | Directory holding the pre-built script-runtime bundles (`eval-harness.bundle.js`, `stdlib.bundle.js`, `swarm-sdk.bundle.js`, `zod.bundle.js`). Set automatically in the Docker image (`/app/scripts-runtime`). Required by the scripts runtime when running as a compiled binary, since the harness subprocess cannot read the binary's `/$bunfs/` virtual filesystem.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `TS_LIB_DIR`                                | —                                                                                                                           | Directory holding the TypeScript `lib.*.d.ts` files used by script typecheck. Set automatically in the Docker image (`/app/typescript-lib`). Required in compiled-binary mode so the TypeScript compiler can resolve the default lib.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `MIGRATIONS_DIR`                            | —                                                                                                                           | Directory for packaged `.sql` migration files in compiled-binary mode. It is selected explicitly when `import.meta.dir` resolves inside Bun's `/$bunfs/` virtual filesystem, even if that directory can be read. Set automatically in the Docker image (`/app/migrations`); a missing or empty directory now stops a fresh database from booting without its baseline schema. Only needed for non-Docker compiled deployments.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `SCRIPT_TYPES_DIR`                          | —                                                                                                                           | Directory whose `node_modules` holds the type declarations for the scripts SDK's bare imports (currently `zod`). Set automatically in the Docker image (`/app/script-types`). Required for script typecheck to resolve `zod` in compiled-binary mode, since the binary doesn't ship `node_modules`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `SCRIPT_WORKFLOW_RUNTIME_DIR`               | —                                                                                                                           | Directory holding the pre-built script-workflow harness bundle (`harness.bundle.js`), needed by durable script-run subprocesses. Set automatically in the Docker image (`/app/script-workflows-runtime`). Required when running as a compiled binary.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `MODELSDEV_CACHE_PATH`                      | —                                                                                                                           | Override path to the vendored `modelsdev-cache.json` pricing/model snapshot, checked before the built-in candidate paths. Only needed for non-standard deployment layouts; boot-time pricing fallback only (live updates are owned by the pricing-refresh job).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `SECRETS_ENCRYPTION_KEY`                    | Auto-generated on first boot                                                                                                | Master key for encrypting `swarm_config` secret rows at rest. Accepts base64 (43-char, e.g. `openssl rand -base64 32`) **or** 64-char hex (e.g. `openssl rand -hex 32`). Decodes to exactly 32 bytes. **Reserved**: cannot be stored in the DB config store. See [Encryption Key](/docs/guides/deployment#encryption-key).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `SECRETS_ENCRYPTION_KEY_FILE`               | —                                                                                                                           | Alternative to `SECRETS_ENCRYPTION_KEY`: absolute path to a file whose contents are the base64- or hex-encoded key. Useful with Docker secrets or k8s `Secret` volume mounts.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `SCHEDULER_INTERVAL_MS`                     | `10000`                                                                                                                     | Polling interval for scheduled tasks (ms)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `SCRIPT_RUN_CONCURRENCY_CAP`                | `10`                                                                                                                        | Maximum number of concurrently active script runs the API server allows. New run requests are rejected with HTTP 429 once the cap is reached.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `SCRIPT_RUN_SUPERVISOR_DISABLE`             | `false`                                                                                                                     | When `true`, disables the background script-run supervisor (subprocess spawn, periodic reconcile, and abort-on-limit) — durable script runs will never be started or reconciled.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `SCRIPT_WORKFLOW_DEBUG`                     | `false`                                                                                                                     | When `true`, logs verbose debug output (auth override flag, resolved API key length) each time the script-run supervisor spawns a durable script-workflow subprocess.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `WORKFLOW_MAX_ITERATIONS`                   | `100`                                                                                                                       | Max times a single workflow node may execute within one run before the engine aborts with an infinite-loop circuit-breaker error.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `WORKFLOW_MAX_STEPS_PER_RUN`                | `500`                                                                                                                       | Max total steps (across all nodes) allowed in a single workflow run before the engine's circuit breaker aborts it.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `OPENAI_API_KEY`                            | —                                                                                                                           | Fallback key for memory embeddings (used when `EMBEDDING_API_KEY` is unset).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `EMBEDDING_API_KEY`                         | —                                                                                                                           | API key for the embedding provider. Takes precedence over `OPENAI_API_KEY`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `EMBEDDING_API_BASE_URL`                    | —                                                                                                                           | Optional custom OpenAI-compatible base URL (e.g. Azure OpenAI, Together, vLLM, Ollama). Leave unset to hit `api.openai.com`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `EMBEDDING_MODEL`                           | `text-embedding-3-small`                                                                                                    | Embedding model slug sent to the provider.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `EMBEDDING_DIMENSIONS`                      | `512`                                                                                                                       | Vector dimensions requested from the model. Must match what the model supports.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `MEMORY_RECENCY_HALF_LIFE_DAYS`             | `14`                                                                                                                        | Optional global override for memory recency decay. When unset, recency defaults are source-aware: manual memories do not decay, `file_index` uses 180 days, `task_completion` uses 14 days, and `session_summary` uses 7 days.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `MEMORY_HYBRID_SEARCH`                      | `1` (on)                                                                                                                    | Hybrid memory retrieval blends vector similarity with a full-text pass before reranking. Set to `0` or `false` to use vector-only search.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `MEMORY_GRAPH_EXPANSION`                    | `1` (on)                                                                                                                    | Expands search candidates with 1-hop memory-link neighbors (resolved `[[wikilink]]` targets) before reranking. Neighbors are damped (`parentRawSimilarity × strength × 0.7`), capped at 5 per search, respect the caller's scope/source/lead ACL, and carry `retrievalSource: "graph"` so `GET /api/memory/usefulness` can measure the arm's citation rate. Set to `0` or `false` to disable expansion.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `MEMORY_MIN_SIMILARITY`                     | `0.1`                                                                                                                       | Minimum raw cosine similarity required before a memory candidate survives reranking. Filters low-relevance noise before recency/access boosts are applied.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `MEMORY_ACCESS_BOOST_MAX`                   | `1.5`                                                                                                                       | Maximum access boost multiplier for reranking                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `MEMORY_ACCESS_RECENCY_HOURS`               | `48`                                                                                                                        | Hours within which access counts for full boost                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `MEMORY_CANDIDATE_MULTIPLIER`               | `3`                                                                                                                         | Candidate set size relative to requested limit                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `MEMORY_DEMOTION_FLOOR`                     | `1.0`                                                                                                                       | Floor for the Beta-Binomial usefulness multiplier applied during memory reranking (paired with `MEMORY_RATERS`). Default `1.0` disables demotion entirely; lower values (e.g. `0.5`) let poorly-rated memories sink below neutral relevance.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `OPENROUTER_API_KEY`                        | —                                                                                                                           | Required for the Stop-hook session summarizer + LLM memory rater (calls go through the Vercel AI SDK against OpenRouter). When unset, both session-summary indexing and the `llm` rater are no-ops                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `OPENROUTER_BASE_URL`                       | `https://openrouter.ai/api/v1`                                                                                              | Optional OpenAI-compatible gateway for every OpenRouter consumer, including harness sessions, credential checks, memory/session summarizers, and raw-LLM or validation workflow nodes. The target is not required to be openrouter.ai — any gateway serving OpenRouter-compatible `GET /models` and `POST /chat/completions` works (see [Model Gateways](/docs/guides/provider-auth/model-gateways)). Trailing slashes are removed. Leave unset or blank for direct OpenRouter traffic. Also settable from Settings → Configuration.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `MEMORY_RATERS`                             | — (unset/empty → no-op rater only)                                                                                          | Comma-separated list of memory raters to enable (`noop`, `implicit-citation`, `explicit-self`, `llm`). Gates the server-side rating pass fired after task completion (`implicit-citation`) and worker-side gates: the Stop-hook's LLM-rating piggyback (`llm`) and the explicit self-rating tool hint (`explicit-self`). Unknown names are logged and skipped; unset/empty is byte-identical to pre-rater behavior.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `MEMORY_RATER_MODEL`                        | Per credential kind, e.g. `google/gemini-3-flash-preview` (OpenRouter), `anthropic/claude-haiku-4-5`, `openai/gpt-5.4-mini` | Overrides the resolved model slug for the Stop-hook session-summarizer + LLM memory rater, across whichever credential (OpenRouter/Anthropic/OpenAI/Codex OAuth/Claude CLI) the shared `internal-ai` abstraction resolves.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `MEMORY_RATER_WEIGHTS`                      | `1.0` per rater                                                                                                             | Optional `name:multiplier,...` overrides (e.g. `llm:0.5,implicit-citation:2`) applied to each rater's emitted rating weight before it's persisted. Unlisted raters use `1.0`; the final per-event weight is clamped to `[0, 1]`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `CAPABILITIES`                              | `core,task-pool,scripts,config,mcp,profiles,scheduling,memory,workflows,pages,metrics,kv,slack,tracker,skills,repo`         | Comma-separated capability flags gating which MCP tool groups the API server registers. Disabled by default: `services`, `prompt-templates`, `messaging`, `swarm-x`, `agentmail`, `kapso`. Setting this **replaces** the default list (not additive) — include every capability you want. Can also be set as a global swarm-config entry, which takes precedence over the env var at server creation. Upgrade note: when an explicit env value is set and no swarm-config `CAPABILITIES` row exists, boot auto-seeds a row backfilling the previously always-registered groups (`core`, `config`, `scripts`, `mcp`, `slack`, `tracker`, `skills`, `repo`) so legacy lists don't silently lose those tools — edit or delete the seeded row to take full control. Note: this shapes the externally exposed MCP tool list only — it is not a feature kill-switch. The scripts SDK bridge always sees the full tool surface (governed by its own allowlist), and HTTP REST routes are generally not gated. See [MCP tools reference](/docs/reference/mcp-tools) for the tool-to-capability mapping. |
| `SKILL_FILES_MAX_COUNT`                     | `100`                                                                                                                       | Max number of files allowed in a single skill file-set upsert.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `SKILL_FILES_MAX_FILE_BYTES`                | `512000` (500 KB)                                                                                                           | Max size in bytes for a single file within a skill file-set upsert.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `SKILL_FILES_MAX_TOTAL_BYTES`               | `10485760` (10 MB)                                                                                                          | Max combined size in bytes for all files in a single skill file-set upsert.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `BUDGET_ADMISSION_DISABLED`                 | `false`                                                                                                                     | Operator escape hatch — set to `true` at process boot to make budget admission always return allowed, bypassing the agent/global/user daily-spend budget gates on task claiming.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `MULTI_RUNTIME_ENABLED`                     | `false`                                                                                                                     | Lets several worker processes serve one logical agent. Set consistently on the API server and every worker; runtimes sharing an `AGENT_ID` need compatible workspace state. The agent-scoped `AGENT_MAX_TASKS` setting remains the shared logical task limit.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `HEARTBEAT_INTERVAL_MS`                     | `90000`                                                                                                                     | Heartbeat sweep interval (ms)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `HEARTBEAT_CHECKLIST_DISABLE`               | `false`                                                                                                                     | Disables the periodic `HEARTBEAT.md` checklist polling loop (separate from the infrastructure sweep).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `HEARTBEAT_CHECKLIST_INTERVAL_MS`           | `1800000`                                                                                                                   | Interval (ms) between `HEARTBEAT.md` checklist checks (default 30 min).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `HEARTBEAT_DISABLE`                         | `false`                                                                                                                     | Set to `true` to disable the heartbeat module                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `HEARTBEAT_STALL_THRESHOLD_MIN`             | `30`                                                                                                                        | Minutes before an in-progress task is considered stalled                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `HEARTBEAT_STALL_NO_SESSION_MIN`            | `5`                                                                                                                         | Minutes before an in-progress task with no active worker session is considered stalled                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `HEARTBEAT_STALL_STALE_HB_MIN`              | `15`                                                                                                                        | Minutes before an in-progress task with a stale worker heartbeat is considered stalled                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `HEARTBEAT_STALE_CLEANUP_MIN`               | `30`                                                                                                                        | Minutes before stale resources (sessions, reviewing tasks) are cleaned up                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `HEARTBEAT_MAX_AUTO_ASSIGN`                 | `5`                                                                                                                         | Max pool tasks to auto-assign per sweep                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `HEARTBEAT_MAX_RESUME_GENERATIONS`          | `3`                                                                                                                         | Max crash-recovery resume generations for a task before it is failed for Lead triage instead of resumed again                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `HEARTBEAT_PIN_CRASH_RESUME`                | `true` (on)                                                                                                                 | Rollback kill-switch for pinning `crash_recovery` resumes back to their original agent. Set to `0` to restore the pre-pin behavior requiring the `WORKER_LIVENESS_WINDOW_SECONDS` freshness check                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `HEARTBEAT_PIN_GRACEFUL_RESUME`             | `true` (on)                                                                                                                 | Rollback kill-switch for pinning `graceful_shutdown` resumes back to their original agent. Set to `0` to restore the old pool-based follow-up path                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `HEARTBEAT_RESUME_PIN_GRACE_MIN`            | `10`                                                                                                                        | Grace window (minutes) a same-agent-pinned crash-recovery or graceful-shutdown resume waits before the reaper escalates it to a Lead re-delegation decision. Set to `0` to disable the reaper                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `RUNTIME_STALE_THRESHOLD_MIN`               | `5`                                                                                                                         | Minutes without runtime traffic before an active runtime stops counting and the heartbeat sweep retires it. Used only when `MULTI_RUNTIME_ENABLED` is on.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `WORKER_LIVENESS_WINDOW_SECONDS`            | `30`                                                                                                                        | Seconds within which a worker's last-activity timestamp must be fresh to be treated as "online" for resume pre-assignment / same-agent crash-pin freshness checks                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |

<Callout type="warn">
  **Encryption key resolution** (see [Encryption Key](/docs/guides/deployment#encryption-key) for the full guide):

  1. `SECRETS_ENCRYPTION_KEY` env var
  2. `SECRETS_ENCRYPTION_KEY_FILE` env var (path to a file containing the key)
  3. `<data-dir>/.encryption-key` on the API's data volume
  4. Auto-generated on first boot **only** when the DB does not yet contain encrypted secret rows

  Losing the key while keeping the database means losing every encrypted secret with no recovery path. Back up the key alongside every database backup.
</Callout>

Docker Worker Variables [#docker-worker-variables]

Harness Provider [#harness-provider]

| Variable           | Required | Default  | Description                                                                                                                                                                                                |
| ------------------ | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `HARNESS_PROVIDER` | No       | `claude` | AI provider: `claude` (Claude Code), `codex`, `opencode`, `pi` (pi-mono), `devin` (Devin), or `claude-managed` (Anthropic Managed Agents). See [Harness Configuration](/docs/guides/harness-configuration) |

Credentials [#credentials]

Which credentials you need depends on your selected harness provider:

**Claude Code** (`HARNESS_PROVIDER=claude`, default):

| Variable                   | Required | Default  | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| -------------------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CLAUDE_CODE_OAUTH_TOKEN`  | Yes\*    | —        | OAuth token for Claude CLI. Supports comma-separated values for [multi-credential load balancing](#multi-credential-support)                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `ANTHROPIC_API_KEY`        | Alt\*    | —        | Alternative to `CLAUDE_CODE_OAUTH_TOKEN`. Also supports comma-separated values                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `SWARM_USE_CLAUDE_BRIDGE`  | No       | `false`  | Reloadable boolean (`true`/`1` enables, `false`/`0`/unset disables). When enabled, the Claude adapter routes through the installed `claude-bridge` binary from pinned package `@desplega.ai/claude-bridge@0.2.2`, a Desplega-owned `claude -p` drop-in that drives interactive Claude Code through `tmux`. Requires Bun, `claude`, and `tmux` on PATH. Claude Bridge requires `CLAUDE_CODE_OAUTH_TOKEN`; if only `ANTHROPIC_API_KEY` is present, the adapter logs a warning and falls back to stock `claude`. See [Claude Bridge](/docs/guides/claude-bridge-experimental). |
| `CLAUDE_BINARY`            | No       | `claude` | Low-level argv prefix for the Claude CLI. Accepts a single binary name (`claude`), an absolute path, or a whitespace-separated command string. Legacy bridge commands remain supported only as a deprecated compatibility path; prefer `SWARM_USE_CLAUDE_BRIDGE=true`. Reloadable via `swarm_config` (precedence: repo > agent > global > env > `claude`) so you can flip a worker via `set-config CLAUDE_BINARY=...` without a container restart.                                                                                                                          |
| `ENABLE_PROMPT_CACHING_1H` | No       | `1`      | Enables Anthropic's 1-hour prompt cache for every Claude Code session spawned by the worker. Default-on since `v1.69.1`; set to `0` (env or `swarm_config`) to opt out. Injected by `ClaudeAdapter` into the spawned `claude` process env.                                                                                                                                                                                                                                                                                                                                  |

\* One of `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY` is required.

**pi-mono** (`HARNESS_PROVIDER=pi`):

| Variable              | Required            | Default                        | Description                                                                                                                                                                      |
| --------------------- | ------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ANTHROPIC_API_KEY`   | One of\*            | —                              | Anthropic API key for Claude models via pi-mono                                                                                                                                  |
| `OPENROUTER_API_KEY`  | One of\*            | —                              | OpenRouter API key for multi-provider model access                                                                                                                               |
| `OPENROUTER_BASE_URL` | No                  | `https://openrouter.ai/api/v1` | OpenAI-compatible gateway used for OpenRouter model and summarizer traffic. Any chat-completions gateway works — see [Model Gateways](/docs/guides/provider-auth/model-gateways) |
| `BEDROCK_AUTH_MODE`   | No                  | inferred from `MODEL_OVERRIDE` | Optional Bedrock mode override. Set `sdk` to force AWS SDK credential probing / live model enumeration; `bearer` is reserved for future support                                  |
| `AWS_REGION`          | In Bedrock SDK mode | —                              | Required region for Bedrock credential probing and account-accurate model enumeration when `BEDROCK_AUTH_MODE=sdk` or `MODEL_OVERRIDE=amazon-bedrock/*`                          |

\* At least one credential source is required (API key, `~/.pi/agent/auth.json`, or the AWS SDK default chain in Bedrock SDK mode). **Do not** pass `CLAUDE_CODE_OAUTH_TOKEN` when using pi-mono — it will override the configured provider. See [Harness Configuration](/docs/guides/harness-configuration) and [Harness Providers](/docs/guides/harness-providers#pi-mono-amazon-bedrock-auth) for details.

**Codex** (`HARNESS_PROVIDER=codex`):

| Variable                        | Required | Default                                                       | Description                                                                                                                                                                 |
| ------------------------------- | -------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OPENAI_API_KEY`                | Optional | —                                                             | Standard OpenAI API key for Codex                                                                                                                                           |
| `API_KEY`                       | Yes      | —                                                             | Swarm API key used to fetch `codex_oauth` from the config store                                                                                                             |
| `MCP_BASE_URL`                  | Yes      | `http://host.docker.internal:3013`                            | Swarm API URL reachable by the worker                                                                                                                                       |
| `CODEX_SKILLS_DIR`              | No       | `~/.codex/skills`                                             | Directory used by the Codex inline slash-command resolver to find `<name>/SKILL.md` (mirrors `OPENCODE_SKILLS_DIR`)                                                         |
| `CODEX_PATH_OVERRIDE`           | No       | Set automatically in the Docker image (`/usr/bin/codex`)      | Absolute path to the Codex CLI wrapper or binary. The app-server transport invokes this path with `app-server --listen stdio://`. Use it for non-Docker or custom installs. |
| `AGENT_SWARM_CODEX_RUNNER_ARGV` | No       | Inferred from `process.argv` (dev vs. compiled-binary layout) | JSON-encoded string array overriding the argv prefix used to re-launch the `codex-session-runner` subprocess. Niche escape hatch for nonstandard install/packaging layouts  |
| `NODE_EXTRA_CA_CERTS`           | No       | —                                                             | Path to an extra CA certificate bundle, forwarded to the spawned Codex CLI subprocess so it trusts custom/corporate CAs on outbound HTTPS calls                             |

Codex can also authenticate through `~/.codex/auth.json`, including ChatGPT OAuth restored from the swarm config store. See [Provider Auth: Codex OAuth](/docs/guides/provider-auth/codex-oauth).

**opencode** (`HARNESS_PROVIDER=opencode`):

| Variable                            | Required | Default                                   | Description                                                                                                                                                                                                         |
| ----------------------------------- | -------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OPENROUTER_API_KEY`                | One of\* | —                                         | OpenRouter API key (primary — unlocks 100+ models at [openrouter.ai](https://openrouter.ai))                                                                                                                        |
| `OPENROUTER_BASE_URL`               | No       | `https://openrouter.ai/api/v1`            | OpenAI-compatible gateway used by the OpenRouter provider, model refreshes, and the session summarizer plugin. Any chat-completions gateway works — see [Model Gateways](/docs/guides/provider-auth/model-gateways) |
| `ANTHROPIC_API_KEY`                 | One of\* | —                                         | Anthropic API key for Claude models                                                                                                                                                                                 |
| `OPENAI_API_KEY`                    | One of\* | —                                         | OpenAI API key for GPT models                                                                                                                                                                                       |
| `OPENCODE_BINARY`                   | No       | `opencode`                                | Path to the opencode CLI binary (if not in `$PATH`)                                                                                                                                                                 |
| `OPENCODE_SKILLS_DIR`               | No       | `~/.opencode/skills`                      | Directory used by the OpenCode inline resolver for `/<skill>` prompts                                                                                                                                               |
| `OPENCODE_SERVER_TIMEOUT_MS`        | No       | `30000`                                   | Timeout (ms) for the opencode SDK's server-start call; override when cold-start on slow disks (e.g. E2B) exceeds the SDK's own 5s default and spawn fails with "Timeout waiting for server to start"                |
| `OPENCODE_SWARM_PLUGIN_PATH`        | No       | Set automatically in the Docker image     | Explicit override for the absolute path to the agent-swarm opencode plugin entrypoint; falls back to the Docker path if present, else a dev-relative path from source                                               |
| `CONTEXT_MODE_OPENCODE_PLUGIN_PATH` | No       | Auto-discovered under the global npm root | Override for the absolute path to context-mode's built opencode plugin entry, used when the default global npm-root search can't find it — otherwise context-mode is silently skipped for that session              |
| `MODEL_OVERRIDE`                    | No       | `openrouter/qwen/qwen3-coder-flash`       | Model string passed to opencode. Use `provider/model-id` format (e.g. `anthropic/claude-sonnet-4-6`, `openai/gpt-4o`)                                                                                               |

\* At least one of `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `~/.local/share/opencode/auth.json` is required.

**Devin** (`HARNESS_PROVIDER=devin`):

| Variable                 | Required    | Default                           | Description                                                                                                                                                                                                                                       |
| ------------------------ | ----------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DEVIN_API_KEY`          | Yes         | —                                 | Devin API key (prefix: `cog_*`)                                                                                                                                                                                                                   |
| `DEVIN_ORG_ID`           | Yes         | —                                 | Devin organization ID (prefix: `org-*`)                                                                                                                                                                                                           |
| `DEVIN_POLL_INTERVAL_MS` | No          | `15000`                           | Polling interval for Devin session events (ms)                                                                                                                                                                                                    |
| `DEVIN_ACU_COST_USD`     | No          | `2.25`                            | USD per ACU for cost tracking                                                                                                                                                                                                                     |
| `DEVIN_API_BASE_URL`     | No          | `https://api.devin.ai`            | Devin API base URL (override for testing)                                                                                                                                                                                                         |
| `DEVIN_MAX_ACU_LIMIT`    | No          | —                                 | Per-session ACU cap (sent to Devin API + UI budget bar)                                                                                                                                                                                           |
| `MAX_SKILL_CHARS`        | No          | `100000`                          | Max chars for inlined `SKILL.md` content                                                                                                                                                                                                          |
| `HAS_MCP`                | Conditional | `false`                           | Declares whether the Devin environment/session has MCP tool access. `true` is not yet supported and throws at session creation; a Devin worker configured with `AGENT_ROLE=lead` also requires MCP access, since a lead needs the MCP to function |
| `DEVIN_SKILLS_DIR`       | No          | `<project-root>/plugin/pi-skills` | Directory used by the Devin inline `@skills:<name>` resolver to find `SKILL.md` files                                                                                                                                                             |

Starter env file: `.env.docker-devin.example` at the repo root.

**Claude Managed Agents** (`HARNESS_PROVIDER=claude-managed`):

| Variable                                  | Required | Default           | Description                                                                                                                                                                                                                                                      |
| ----------------------------------------- | -------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ANTHROPIC_API_KEY`                       | Yes      | —                 | Anthropic API key (used by both the setup CLI and the runtime adapter)                                                                                                                                                                                           |
| `MANAGED_AGENT_ID`                        | Yes      | —                 | Anthropic Agent ID. Written by `claude-managed-setup`                                                                                                                                                                                                            |
| `MANAGED_ENVIRONMENT_ID`                  | Yes      | —                 | Anthropic Environment ID. Written by `claude-managed-setup`                                                                                                                                                                                                      |
| `MCP_BASE_URL`                            | Yes      | —                 | **Must be HTTPS-public** so Anthropic's sandbox can reach `/mcp`. Worker fails fast at boot otherwise                                                                                                                                                            |
| `MANAGED_AGENT_MODEL`                     | No       | `claude-sonnet-5` | Default model on `sessions.create`; per-task `task.model` overrides                                                                                                                                                                                              |
| `MANAGED_GITHUB_VAULT_ID`                 | No       | —                 | Anthropic vault ID holding a GitHub PAT, for repo-bound tasks (recommended for prod)                                                                                                                                                                             |
| `MANAGED_GITHUB_TOKEN`                    | No       | —                 | Literal GitHub PAT injected as `authorization_token` on `github_repository` resources (dev-only fallback)                                                                                                                                                        |
| `MANAGED_MCP_VAULT_ID`                    | No       | —                 | Anthropic vault ID holding the static-bearer credential for the swarm's `/mcp` endpoint, passed via `vault_ids` on `sessions.create` alongside `MANAGED_GITHUB_VAULT_ID`. Written by `claude-managed-setup` and auto-restored from `swarm_config` at worker boot |
| `CLAUDE_MANAGED_RUNTIME_FEE_USD_PER_HOUR` | No       | `0.08`            | USD-per-session-hour runtime fee used by the local cost snapshot shown in worker logs before the API server's canonical pricing table recomputes the final cost. Override for ops pricing bumps without a redeploy                                               |

Run the one-time `bunx @desplega.ai/agent-swarm claude-managed-setup` from your laptop to bootstrap the Anthropic-side Agent + Environment and persist IDs into `swarm_config`. See [Harness Configuration § Claude Managed Agents](/docs/guides/harness-configuration#claude-managed-agents).

General Worker Settings [#general-worker-settings]

| Variable                                | Required | Default                             | Description                                                                                                                                                                                                                        |
| --------------------------------------- | -------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `API_KEY`                               | Yes      | —                                   | API key for MCP server                                                                                                                                                                                                             |
| `AGENT_ID`                              | No       | Auto-generated                      | Agent UUID. Keep stable for task resume                                                                                                                                                                                            |
| `AGENT_ROLE`                            | No       | `worker`                            | Role: `worker` or `lead`                                                                                                                                                                                                           |
| `AGENT_NAME`                            | No       | Auto-generated                      | Display name for the agent                                                                                                                                                                                                         |
| `MCP_BASE_URL`                          | No       | `http://host.docker.internal:3013`  | MCP server URL                                                                                                                                                                                                                     |
| `WORKER_API_READY_TIMEOUT_SECONDS`      | No       | `90`                                | Positive-integer deadline for `docker-entrypoint.sh` to reach `${MCP_BASE_URL}/health` before provider-specific setup. This is bootstrap-only: set it in the worker/lead container environment rather than only in `swarm_config`. |
| `SESSION_ID`                            | No       | Auto-generated                      | Log folder name                                                                                                                                                                                                                    |
| `LOG_DIR`                               | No       | `/logs`                             | Base directory for session `.jsonl` log files (`<LOG_DIR>/<sessionId>/<timestamp>-<taskId8>.jsonl`)                                                                                                                                |
| `YOLO`                                  | No       | `false`                             | Continue on errors                                                                                                                                                                                                                 |
| `SYSTEM_PROMPT`                         | No       | —                                   | Custom system prompt text                                                                                                                                                                                                          |
| `SYSTEM_PROMPT_FILE`                    | No       | —                                   | Path to system prompt file                                                                                                                                                                                                         |
| `CONTEXT_MODE_DISABLED`                 | No       | `false`                             | Set to `true` to disable the default context-mode MCP wiring for local Claude Code, Codex, and opencode workers. Leaves the worker running but hides/skips the `ctx_*` tools.                                                      |
| `SCRIPTS_ONLY_MCP`                      | No       | `false`                             | Experimental code-mode. Set alongside the API server value so worker prompts explain the reduced eight-tool MCP surface; all other swarm operations remain available through `script-run` and the scripts SDK.                     |
| `CONTEXT_MODE_EXTERNAL_MCP_NUDGE_EVERY` | No       | `3`                                 | How often (in turns) the context-mode MCP plugin surfaces its external-MCP guidance nudge, across Claude Code, Codex, and opencode alike                                                                                           |
| `CONTEXT_PREAMBLE_MAX_TOKENS`           | No       | `2000`                              | Token budget for the universal follow-up context preamble prepended to a child task's prompt; applies across all harness providers                                                                                                 |
| `CONTEXT_PREAMBLE_RESUME_MAX_TOKENS`    | No       | `4000`                              | Token budget for the resume-task preamble (2x the regular budget — carries the original task brief plus a tool-call summary so the resumed agent doesn't redo completed work)                                                      |
| `STARTUP_SCRIPT_STRICT`                 | No       | `false`                             | Exit on startup script failure. By default, per-agent `setupScript` failures log a v1.106.0 privilege-boundary migration warning and the worker continues booting                                                                  |
| `SHUTDOWN_TIMEOUT`                      | No       | `30000`                             | Grace period (ms) before pausing tasks                                                                                                                                                                                             |
| `MAX_CONCURRENT_TASKS`                  | No       | `1`                                 | Maximum parallel tasks per worker                                                                                                                                                                                                  |
| `SWARM_URL`                             | No       | `localhost`                         | Base domain for service URLs                                                                                                                                                                                                       |
| `LEAD_PORT`                             | No       | `3020`                              | Host port for lead service. Example variable used in `docker-compose.example.yml` — adjust to your setup. In isolated network namespaces all services can share the same port.                                                     |
| `WORKER1_PORT`                          | No       | `3021`                              | Host port for worker-1 service. Example — see `LEAD_PORT`.                                                                                                                                                                         |
| `WORKER2_PORT`                          | No       | `3022`                              | Host port for worker-2 service. Example — see `LEAD_PORT`.                                                                                                                                                                         |
| `PM2_HOME`                              | No       | `/workspace/.pm2`                   | PM2 state directory                                                                                                                                                                                                                |
| `TEMPLATE_ID`                           | No       | —                                   | Template for initial profile on first boot (e.g., `official/coder`)                                                                                                                                                                |
| `TEMPLATE_REGISTRY_URL`                 | No       | `https://templates.agent-swarm.dev` | URL of the templates registry                                                                                                                                                                                                      |
| `MODEL_TIER_MAP`                        | No       | —                                   | JSON object overriding portable tier-to-model resolution for the current worker, for example `{\"smol\":\"gpt-5.4-mini\",\"smart\":\"gpt-5.5\"}`. Applies when a task, schedule, or workflow uses `modelTier`.                     |
| `MODEL_TIER_<TIER>`                     | No       | —                                   | Per-tier override for `smol`, `regular`, `smart`, or `ultra` (for example `MODEL_TIER_SMART=gpt-5.5`). Wins over `MODEL_TIER_MAP`.                                                                                                 |
| `SWARM_DEP_POSTGRES_ENABLED`            | No       | `false`                             | Set to `true` to start the optional bundled PostgreSQL 16 cluster on worker boot. The root-stage entrypoint runs `scripts/init-local-postgres.sh` before privilege drop; binaries ship dormant in the worker image since `v1.93.0` |
| `LOCAL_POSTGRES_DATA_DIR`               | No       | `/tmp/postgres-data`                | Data directory for the optional local Postgres cluster                                                                                                                                                                             |
| `LOCAL_POSTGRES_PORT`                   | No       | `5433`                              | Port for the optional local Postgres cluster                                                                                                                                                                                       |
| `LOCAL_POSTGRES_USER`                   | No       | `postgres`                          | Superuser name for the optional local Postgres cluster                                                                                                                                                                             |
| `LOCAL_POSTGRES_PASSWORD`               | No       | `postgres`                          | Superuser password for the optional local Postgres cluster                                                                                                                                                                         |
| `LOCAL_POSTGRES_DB`                     | No       | `app`                               | Database created in the optional local Postgres cluster                                                                                                                                                                            |

Git Configuration [#git-configuration]

| Variable       | Default                    | Description                     |
| -------------- | -------------------------- | ------------------------------- |
| `GITHUB_TOKEN` | —                          | GitHub token for git operations |
| `GITHUB_EMAIL` | `worker-agent@desplega.ai` | Git commit email                |
| `GITHUB_NAME`  | `Worker Agent`             | Git commit name                 |

Slack Integration [#slack-integration]

| Variable                                | Description                                                                                                                                                                   |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SLACK_BOT_TOKEN`                       | Bot User OAuth Token (`xoxb-...`)                                                                                                                                             |
| `SLACK_APP_TOKEN`                       | App-Level Token for Socket Mode (`xapp-...`)                                                                                                                                  |
| `SLACK_SIGNING_SECRET`                  | Signing Secret (optional for Socket Mode)                                                                                                                                     |
| `SLACK_DISABLE`                         | Set to `true` to disable Slack                                                                                                                                                |
| `SLACK_ALLOW_DEV_SOCKET_MODE`           | Set to `true` to explicitly allow a `NODE_ENV=development` API process to connect through Socket Mode (default: `false`)                                                      |
| `SLACK_RENDER_V2`                       | Set to `true` to opt in to one persistent task tree per physical Slack thread plus streamed, immutable outcome cards (default: `false`)                                       |
| `SLACK_ALLOWED_EMAIL_DOMAINS`           | Comma-separated email domains                                                                                                                                                 |
| `SLACK_ALLOWED_USER_IDS`                | Comma-separated user IDs to always allow                                                                                                                                      |
| `ADDITIVE_SLACK`                        | Set to `true` to enable non-mention thread message buffering and batching                                                                                                     |
| `ADDITIVE_SLACK_BUFFER_MS`              | Debounce window for thread buffer in ms (default: `10000`)                                                                                                                    |
| `SLACK_THREAD_FOLLOWUP_REQUIRE_MENTION` | Set to `true` to require @mention for thread follow-up routing (default: `false`)                                                                                             |
| `LEAD_MONITOR_CHANNELS`                 | Set to `true` to have the lead agent additionally poll monitored Slack channels for non-mention activity (not just @mentions/DMs), throttled to once per 60s per poll cycle   |
| `LEAD_MONITOR_CHANNEL_IDS`              | Comma-separated Slack channel IDs allowlist restricting `LEAD_MONITOR_CHANNELS` activity monitoring to specific channels. Unset monitors every channel the bot is a member of |
| `SLACK_ALERTS_CHANNEL`                  | Slack channel ID/name for OAuth/Jira keepalive failures and claimable-queue stall/recovery alerts. Unset means alerts are logged only, not sent                               |

GitHub Integration [#github-integration]

| Variable                 | Description                                                                                         |
| ------------------------ | --------------------------------------------------------------------------------------------------- |
| `GITHUB_WEBHOOK_SECRET`  | Webhook secret for GitHub App                                                                       |
| `GITHUB_BOT_NAME`        | Bot name for @mentions (default: `agent-swarm-bot`)                                                 |
| `GITHUB_BOT_ALIASES`     | Comma-separated additional @mention aliases (e.g. `heysidekick,sidekick`)                           |
| `GITHUB_EVENT_LABELS`    | Comma-separated labels that trigger agent action on PR/issue label events (default: `swarm-review`) |
| `GITHUB_APP_ID`          | GitHub App ID (for bot reactions)                                                                   |
| `GITHUB_APP_PRIVATE_KEY` | GitHub App private key (base64-encoded)                                                             |
| `GITHUB_DISABLE`         | Set to `true` to disable GitHub                                                                     |

GitLab Integration [#gitlab-integration]

| Variable                | Description                                                      |
| ----------------------- | ---------------------------------------------------------------- |
| `GITLAB_TOKEN`          | GitLab PAT or Group Access Token for API calls                   |
| `GITLAB_URL`            | GitLab instance URL (default: `https://gitlab.com`)              |
| `GITLAB_WEBHOOK_SECRET` | Shared secret for webhook verification                           |
| `GITLAB_BOT_NAME`       | Bot username for @mention detection (default: `agent-swarm-bot`) |
| `GITLAB_EMAIL`          | Git commit email for GitLab repos                                |
| `GITLAB_NAME`           | Git commit name for GitLab repos                                 |
| `GITLAB_DISABLE`        | Set to `true` to disable GitLab integration                      |

AgentMail Integration [#agentmail-integration]

| Variable                         | Description                                                                                                                                       |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AGENTMAIL_DISABLE`              | Set to `true` to skip AgentMail integration                                                                                                       |
| `AGENTMAIL_WEBHOOK_SECRET`       | Svix signing secret for webhook verification                                                                                                      |
| `AGENTMAIL_INBOX_DOMAIN_FILTER`  | Comma-separated domains to allow for incoming inbox webhooks (e.g., `yourdomain.com,example.com`). Unmatched inbox domains are silently dropped   |
| `AGENTMAIL_SENDER_DOMAIN_FILTER` | Comma-separated sender domains to allow (e.g., `gmail.com,company.com`). Unmatched sender domains are silently dropped                            |
| `ADDITIVE_AGENTMAIL`             | Set to `true` to enable non-mention thread message buffering and batching for AgentMail, mirroring `ADDITIVE_SLACK`                               |
| `ADDITIVE_AGENTMAIL_BUFFER_MS`   | Debounce window (ms) for buffering rapid AgentMail follow-up messages into a single task when `ADDITIVE_AGENTMAIL=true` is set (default: `10000`) |

Kapso / WhatsApp Integration [#kapso--whatsapp-integration]

| Variable                    | Description                                                                           |
| --------------------------- | ------------------------------------------------------------------------------------- |
| `KAPSO_API_KEY`             | Kapso API key used for outbound sends and optional webhook registration (`X-API-Key`) |
| `KAPSO_PHONE_NUMBER_ID`     | Default WhatsApp Business phone-number ID the swarm sends from                        |
| `KAPSO_WEBHOOK_HMAC_SECRET` | Shared secret used to verify Kapso's `X-Webhook-Signature` header on inbound webhooks |
| `KAPSO_API_BASE_URL`        | Optional Kapso API base URL override (default: `https://api.kapso.ai`)                |

Setup Steps [#setup-steps]

1. Set the values above in your `.env` or integrations dashboard.
2. Have the lead call `register-kapso-number` to point the number at `/api/integrations/kapso/webhook` and store the routing mapping.
3. Use `send-whatsapp-message` / `reply-whatsapp-message` for the common text path, or the `kapso-whatsapp` skill for templates, media, and reactions.

Sentry Integration [#sentry-integration]

| Variable            | Description                    |
| ------------------- | ------------------------------ |
| `SENTRY_AUTH_TOKEN` | Sentry Organization Auth Token |
| `SENTRY_ORG`        | Sentry organization slug       |

Linear Integration [#linear-integration]

| Variable                   | Description                                                                                                                                                                                                                                                                            |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `LINEAR_DISABLE`           | Set to `true` to disable Linear integration                                                                                                                                                                                                                                            |
| `LINEAR_CLIENT_ID`         | OAuth app client ID (create at Linear > Settings > API > Applications)                                                                                                                                                                                                                 |
| `LINEAR_CLIENT_SECRET`     | OAuth app client secret (shown once on creation)                                                                                                                                                                                                                                       |
| `LINEAR_REDIRECT_URI`      | OAuth callback URL (e.g., `http://localhost:3013/api/trackers/linear/callback`)                                                                                                                                                                                                        |
| `LINEAR_SIGNING_SECRET`    | Webhook signing secret from Linear app settings                                                                                                                                                                                                                                        |
| `LINEAR_ALLOWED_STATES`    | CSV of `WorkflowState.type` values that trigger task creation. Default: `unstarted,started,completed,canceled` (i.e. skip Backlog & Triage). Set to empty to lock down everything but the label override. See [Linear integration → State gate](/docs/integrations/linear#state-gate). |
| `LINEAR_SWARM_READY_LABEL` | Label name (case-insensitive) that bypasses the state gate. Default: `swarm-ready`.                                                                                                                                                                                                    |

Setup Steps [#setup-steps-1]

1. Create an OAuth app at Linear > Settings > API > Applications
2. Set Actor to "Application"
3. Set Callback URL to your `/api/trackers/linear/callback` endpoint
4. Enable "Agent session events" in webhook settings
5. Set Webhook URL to your `/api/trackers/linear/webhook` endpoint
6. Copy Client ID, Client Secret, and Webhook Signing Secret
7. Start the server, then visit `/api/trackers/linear/authorize` to complete OAuth

With portless: set `LINEAR_REDIRECT_URI=https://api.swarm.localhost:1355/api/trackers/linear/callback`

Jira Integration [#jira-integration]

| Variable             | Description                                                                                                                                                                                                                                                  |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `JIRA_DISABLE`       | Set to `true` to disable Jira integration (or set `JIRA_ENABLED=false`)                                                                                                                                                                                      |
| `JIRA_CLIENT_ID`     | OAuth 2.0 (3LO) app client ID from Atlassian (developer.atlassian.com > My Apps > Settings). Its presence also gates whether the Jira integration initializes at all                                                                                         |
| `JIRA_CLIENT_SECRET` | OAuth client secret paired with `JIRA_CLIENT_ID`                                                                                                                                                                                                             |
| `JIRA_REDIRECT_URI`  | OAuth callback URL override — must match exactly what's registered in the Atlassian app. Defaults to `<PUBLIC_MCP_BASE_URL or MCP_BASE_URL>/api/trackers/jira/callback`                                                                                      |
| `JIRA_WEBHOOK_TOKEN` | High-entropy token embedded in the registered webhook URL path — Atlassian's 3LO webhooks aren't HMAC-signed, so this is the sole inbound auth mechanism. Generate with `openssl rand -hex 32`. Webhook register/receive endpoints return 503 until it's set |

Setup Steps [#setup-steps-2]

1. Create an OAuth 2.0 (3LO) app at developer.atlassian.com > My Apps
2. Add scopes: `read:jira-work`, `write:jira-work`, `manage:jira-webhook`, `offline_access`, `read:me`
3. Set the callback URL to your `/api/trackers/jira/callback` endpoint
4. Copy the Client ID and Client Secret
5. Generate a webhook token (`openssl rand -hex 32`) and set `JIRA_WEBHOOK_TOKEN`
6. Start the server, then visit `/api/trackers/jira/authorize` to complete OAuth
7. Register the dynamic webhook via `POST /api/trackers/jira/webhook-register` (admin, requires the swarm API key)

See the [Jira Integration guide](/docs/integrations/jira) for the full walkthrough, webhook lifecycle, and known limitations.

Composio Integration [#composio-integration]

Environment variables for the [Composio](/docs/integrations/composio) Tool Router integration, used by `agent-swarm x composio` and the `swarm_x` MCP tool to call third-party APIs through Composio.

| Variable               | Description                                                                       |
| ---------------------- | --------------------------------------------------------------------------------- |
| `COMPOSIO_API_KEY`     | Project API key, sent as `x-api-key` on Composio requests                         |
| `COMPOSIO_ORG_API_KEY` | Optional organization key, sent as `x-org-api-key` when `--org` is passed         |
| `COMPOSIO_BASE_URL`    | Optional API base URL override (default: `https://backend.composio.dev/api/v3.1`) |

Portless (Local Development) [#portless-local-development]

[Portless](https://port1355.dev/) replaces port-based URLs with friendly domain names for local development.

| Variable       | With Portless                      | Description    |
| -------------- | ---------------------------------- | -------------- |
| `MCP_BASE_URL` | `https://api.swarm.localhost:1355` | API server URL |
| `APP_URL`      | `https://ui.swarm.localhost:1355`  | Dashboard URL  |

Install: `bun add -g portless`. Enable HTTPS: `portless trust && portless proxy start --https`.

UI Development Server [#ui-development-server]

| Variable                   | Default                 | Description                                                                                                                                                                                                                       |
| -------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VITE_PROXY_TARGET`        | `http://localhost:3013` | Overrides the API origin the `ui/` Vite dev server (`bun run dev`, port 5274) proxies `/api`, `/health`, and `/status` requests to. Set when the API is running on a non-default port or host during local dashboard development. |
| `VITE_API_URL`             | unset                   | Fixes a dedicated dashboard build to one API origin. Requires `VITE_API_KEY`. The connection switcher becomes a static label.                                                                                                     |
| `VITE_API_KEY`             | unset                   | Fixes the API credential for `VITE_API_URL`. Connection settings become read-only.                                                                                                                                                |
| `VITE_USER_ID`             | unset                   | Fixes the dashboard identity to an existing user. Requires `VITE_API_URL` and `VITE_API_KEY`.                                                                                                                                     |
| `VITE_DEMO_MODE`           | `false`                 | Shows a diagonal live demo ribbon when set to `true` or `1`.                                                                                                                                                                      |
| `VITE_PLAUSIBLE_ANALYTICS` | unset                   | Build-time flag. Set to `1` to inject the Plausible analytics snippet into the dashboard's `index.html` during `bun run build`. Only our hosted dashboard sets it; self-hosted and local builds ship with no analytics script.    |
| `VITE_PLAUSIBLE_SCRIPT_ID` | hosted dashboard's id   | Plausible site script id (the `pa-<id>.js` part of the snippet) used when `VITE_PLAUSIBLE_ANALYTICS` is on. Set it on a second deployment, such as the public demo, so it reports to its own Plausible site.                      |

Vite includes every `VITE_*` value in public browser assets. Use a restricted credential and a
demo-safe API deployment for fixed public dashboards.

Cloud Personalization & Adaptive Home [#cloud-personalization--adaptive-home]

Identity envs that brand the swarm and gate cloud-only UX. All are read on every `GET /status` call (cheap — env reads + one SQL aggregate, no upstream calls). See the [Personalization & Status](/docs/guides/personalization) guide for the full story.

| Variable                 | Default             | Description                                                                                                                                                                                                                                 |
| ------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWARM_CLOUD`            | `false`             | When `true`, marks the deployment as cloud-hosted. Surfaces Docs/Support/Billing items in the user-menu, suppresses the self-host marketing footer, and is sent as `metadata.is_cloud` (always present) on every anonymized telemetry event |
| `SWARM_ORG_NAME`         | `Swarm`             | Sidebar header name. Also sent as `metadata.organization_name` on every anonymized telemetry event when set                                                                                                                                 |
| `SWARM_ORG_ID`           | none                | Stable org/tenant identifier exposed on `/status` as `identity.org_id` and sent as `metadata.organization_id` on every anonymized telemetry event when set. Set by the orchestrator on cloud deployments                                    |
| `SWARM_ORG_LOGO_URL`     | bundled `/logo.png` | Sidebar logo URL (any HTTPS URL). Falls back to the bundled logo if it fails to load                                                                                                                                                        |
| `SWARM_BRAND_COLOR`      | none                | Tints the org name in the sidebar header (any CSS color, e.g. `#a855f7`)                                                                                                                                                                    |
| `SWARM_MARKETING_URL`    | none                | Self-host marketing footer link target. Suppressed when `SWARM_CLOUD=true` or `SWARM_HIDE_CLOUD_PROMO=true`                                                                                                                                 |
| `SWARM_HIDE_CLOUD_PROMO` | `false`             | Force-hide the marketing footer regardless of `SWARM_CLOUD`                                                                                                                                                                                 |
| `SWARM_VERIFY_TTL_MS`    | `3600000` (1h)      | How long a successful "Test connection" click keeps the harness milestone in `verified` state. In-memory; lost on API restart                                                                                                               |

Agent Filesystem (agent-fs) [#agent-filesystem-agent-fs]

| Variable                    | Description                                                                                                                                                                                                                                   |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AGENT_FS_API_URL`          | Agent-fs API URL. When unset, the server uses `local-fs`; when set as env or global swarm config, it enables the persistent shared filesystem provider                                                                                        |
| `AGENT_FS_LIVE_URL`         | Agent-fs live host used to build shareable file links (`<AGENT_FS_LIVE_URL>/file/~/<org_id>/<drive_id>/<file_path>`). Falls back to the public `https://live.agent-fs.dev` host when unset; self-hosted agent-fs operators should override it |
| `API_AGENT_FS_API_KEY`      | API-owned bootstrap/service API key for agent-fs. Used only by the API server to seed the swarm org/drive and invite/register agents                                                                                                          |
| `AGENT_FS_API_KEY`          | Per-agent API key for agent-fs CLI/MCP access. Generated by the API server and stored as an agent-scoped secret; workers should not share the API bootstrap key                                                                               |
| `AGENT_FS_SHARED_ORG_ID`    | Shared org ID for the swarm's agent-fs organization. Auto-created by the lead on first boot                                                                                                                                                   |
| `AGENT_FS_DEFAULT_ORG_ID`   | Default agent-fs org ID used to auto-resolve `agent-fs` attachment rows on `store-progress` when `orgId` is missing. Scope precedence: agent > global                                                                                         |
| `AGENT_FS_DEFAULT_DRIVE_ID` | Default agent-fs drive ID used to auto-resolve `agent-fs` attachment rows on `store-progress` when `driveId` is missing. Scope precedence: agent > global                                                                                     |

The no-config default is `local-fs`; set `AGENT_FS_API_URL` globally to keep shared agent-fs behavior. The API boot seeder registers a service identity with agent-fs on first boot, stores `API_AGENT_FS_API_KEY` as an encrypted global swarm config secret, creates the shared org/drive, and persists `AGENT_FS_DEFAULT_*`. Each runner then asks the API to create its own encrypted agent-scoped `AGENT_FS_API_KEY`. The two `AGENT_FS_DEFAULT_*` keys let the server fill in attachment IDs server-side so the renderer can emit full live-host URLs — per-row IDs always win, and missing config + missing row IDs still falls back to the `agent-fs:<path>` form.

Pages [#pages]

Environment variables for the hosted Pages feature (DB-backed, agent-authored web pages). See [MCP Tools Reference](/docs/reference/mcp-tools#pages-tools) for the Pages tools.

| Variable                   | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PAGE_SESSION_SECRET`      | HMAC-SHA256 secret for signing the `page_session` cookie. **Reserved-like**: not read from `swarm_config`, and never falls back to the swarm API key (that key's documented default is public). When unset, resolved via `PAGE_SESSION_SECRET_FILE`, then `<dirname(DATABASE_PATH)>/.page-session-secret` on disk, then auto-generated and persisted there on first use — mirrors `SECRETS_ENCRYPTION_KEY`'s resolution order (see [Encryption Key](/docs/guides/deployment#encryption-key)). |
| `PAGE_SESSION_SECRET_FILE` | Alternative to `PAGE_SESSION_SECRET`: absolute path to a file whose contents are the secret. Useful with Docker secrets or k8s `Secret` volume mounts.                                                                                                                                                                                                                                                                                                                                        |

x402 Payments [#x402-payments]

Environment variables for the [x402 payment module](/docs/guides/x402-payments), enabling agents to make USDC micropayments on x402-gated APIs.

Common [#common]

| Variable                | Default        | Description                                                                               |
| ----------------------- | -------------- | ----------------------------------------------------------------------------------------- |
| `X402_SIGNER_TYPE`      | Auto-detected  | Signer backend: `"openfort"` or `"viem"`. Auto-detects based on which credentials are set |
| `X402_MAX_AUTO_APPROVE` | `1.00`         | Maximum USD amount to auto-approve per request                                            |
| `X402_DAILY_LIMIT`      | `10.00`        | Daily spending limit in USD                                                               |
| `X402_NETWORK`          | `eip155:84532` | CAIP-2 network ID. `eip155:84532` = Base Sepolia (testnet), `eip155:8453` = Base mainnet  |

Openfort Signer [#openfort-signer]

| Variable                  | Required | Description                                                 |
| ------------------------- | -------- | ----------------------------------------------------------- |
| `OPENFORT_API_KEY`        | Yes      | Openfort API key (`sk_test_` or `sk_live_` prefixed)        |
| `OPENFORT_WALLET_SECRET`  | Yes      | P-256 ECDSA key for wallet authentication (base64 encoded)  |
| `OPENFORT_WALLET_ADDRESS` | No       | Reuse existing wallet address instead of creating a new one |

Viem Signer [#viem-signer]

| Variable          | Required | Description                                                                    |
| ----------------- | -------- | ------------------------------------------------------------------------------ |
| `EVM_PRIVATE_KEY` | Yes      | Wallet private key (`0x`-prefixed hex). Use a burner wallet with minimal funds |

Multi-Credential Support [#multi-credential-support]

To distribute load across multiple Claude subscriptions, provide multiple credentials as comma-separated values:

```bash
# Multiple OAuth tokens — one is randomly selected per session
CLAUDE_CODE_OAUTH_TOKEN=token1,token2,token3

# Also works with API keys
ANTHROPIC_API_KEY=sk-key1,sk-key2
```

When a session is spawned, the runner splits the credential value by commas and randomly selects one. Each session gets a single credential, distributing load across subscriptions. A log line indicates which credential index was selected (never the credential itself). Single values (no commas) work unchanged — fully backward compatible.

Telemetry [#telemetry]

| Variable                 | Default          | Description                                                                                                                                                                                           |
| ------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ANONYMIZED_TELEMETRY`   | `true` (enabled) | Set to `false` to disable anonymized telemetry. See [Telemetry](/docs/reference/telemetry) for details                                                                                                |
| `DESPLEGA_TELEMETRY_ENV` | `production`     | Explicit telemetry environment tag. Set to `development` or `test` for intentional non-production telemetry.                                                                                          |
| `INSTALL_METHOD`         | unset            | Written automatically by the onboard wizard (`onboard_interactive` / `onboard_noninteractive`); not meant to be hand-set. Unset installs are attributed as `manual` (or `e2b` inside an E2B sandbox). |
| `INSTALL_PRESET`         | unset            | Written automatically by the onboard wizard with the chosen preset (e.g. `solo`), when applicable.                                                                                                    |

OpenTelemetry [#opentelemetry]

OpenTelemetry traces and OTLP metrics are disabled unless `OTEL_EXPORTER_OTLP_ENDPOINT` is set. See [Observability with OpenTelemetry](/docs/guides/observability-opentelemetry) for SigNoz setup, emitted spans, metric counters, and query examples.

| Variable                      | Default                                                         | Description                                                                                                                                                                                          |
| ----------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | none                                                            | Base OTLP HTTP endpoint shared by traces and metrics. For SigNoz Cloud, use your region's ingest URL (e.g. `https://ingest.eu2.signoz.cloud`) and let the SDK append `/v1/traces` and `/v1/metrics`. |
| `OTEL_EXPORTER_OTLP_HEADERS`  | none                                                            | OTLP exporter headers, for example `signoz-ingestion-key=<key>`. Treated as sensitive by the secret scrubber                                                                                         |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | SDK default                                                     | Export protocol. Use `http/protobuf` for SigNoz Cloud                                                                                                                                                |
| `OTEL_SERVICE_NAME`           | `agent-swarm-api` or `agent-swarm-worker` outside local compose | OpenTelemetry `service.name`. Set to `agent-swarm` everywhere to keep one service and split by `agentswarm.service.role`                                                                             |
| `OTEL_RESOURCE_ATTRIBUTES`    | derived from `NODE_ENV`                                         | Comma-separated resource attributes such as `deployment.environment=local,env=local,service.namespace=agent-swarm`                                                                                   |
| `OTEL_TRACE_POLL`             | OFF                                                             | When set to `1`/`true`, emits trace spans for the worker poll loop (`worker.poll`) and the `/api/poll` HTTP endpoint. Skip by default to reduce span volume.                                         |

Business-Use Instrumentation [#business-use-instrumentation]

Optional integration with [`@desplega.ai/business-use`](https://github.com/desplega-ai/business-use) for tracking system invariants across the distributed API + worker architecture.

| Variable               | Description                                        |
| ---------------------- | -------------------------------------------------- |
| `BUSINESS_USE_API_KEY` | API key from `uvx business-use-core@latest init`   |
| `BUSINESS_USE_URL`     | BU backend URL (default: `http://localhost:13370`) |

SDK enters no-op mode if the API key is missing — safe to omit in environments without a BU backend.

Secrets Encryption [#secrets-encryption]

`swarm_config` rows with `isSecret=1` are encrypted at rest using AES-256-GCM (`v1.67.0+`). The server resolves the encryption key on boot in this order:

1. `SECRETS_ENCRYPTION_KEY` env var (base64-encoded 32 bytes)
2. `SECRETS_ENCRYPTION_KEY_FILE` pointing at a file containing the base64 key
3. `<data-dir>/.encryption-key` file on disk
4. Auto-generated on first boot (only when the DB has no existing encrypted rows)

Generate a key with:

```bash
openssl rand -base64 32 > ./encryption_key
chmod 600 ./encryption_key
```

<Callout type="warn">
  **Back up and preserve the encryption key alongside your SQLite database.** Losing the key means losing all encrypted secrets (API tokens, OAuth creds, etc.) with no recovery path. Do not switch between key sources unless the underlying base64 value is identical.
</Callout>

**Reserved keys:** `API_KEY` and `SECRETS_ENCRYPTION_KEY` are rejected by the `swarm_config` API (case-insensitive) and must remain environment-only.

**Upgrading from plaintext:** Legacy secrets are auto-migrated on first boot. If `SECRETS_ENCRYPTION_KEY` was not set beforehand, a one-time plaintext backup is created at `<db-path>.backup.secrets-YYYY-MM-DD.env`. Delete this file after verifying your key is backed up.

Priority [#priority]

When both CLI flags and environment variables are set:

* CLI flags take precedence over environment variables
* Inline text (`SYSTEM_PROMPT`) takes precedence over file (`SYSTEM_PROMPT_FILE`)

Related [#related]

* [Harness Configuration](/docs/guides/harness-configuration) — Configure Claude Code or pi-mono provider settings
* [Deployment Guide](/docs/guides/deployment) — Production deployment with Docker Compose
* [CLI Reference](/docs/reference/cli) — Terminal commands that complement environment variable configuration
* [MCP Tools Reference](/docs/reference/mcp-tools) — Tools available to agents at runtime


# MCP Tools Reference (/docs/reference/mcp-tools)



Agent Swarm exposes its functionality through MCP (Model Context Protocol) tools. Tools are grouped by capability.

Capability Flags [#capability-flags]

Every tool group is gated by a capability flag on the API server, controlled by the `CAPABILITIES` environment variable (or a `CAPABILITIES` global swarm-config entry, which takes precedence at server creation). When unset, the defaults apply:

* **Enabled by default:** `core`, `task-pool`, `config`, `scripts`, `mcp`, `profiles`, `repo`, `scheduling`, `memory`, `tracker`, `workflows`, `skills`, `pages`, `metrics`, `kv`, `slack`
* **Disabled by default:** `messaging` (post/read messages, channels), `services` (background service registry), `prompt-templates`, `swarm-x` (external command routes), `agentmail`, `kapso` (WhatsApp)

Setting `CAPABILITIES` **replaces** the whole list — it is not additive. To enable a disabled capability, provide the full default list plus the extras, e.g. `CAPABILITIES=core,task-pool,...,repo,messaging`. Sections below note when a tool group is disabled by default.

Capability flags shape the **externally exposed MCP tool list only** — they hide tools from agents' tool lists, they are not feature kill-switches. The scripts SDK bridge always builds a full-surface server instance, so scripts keep their full typed SDK (governed by the [scripts SDK allowlist](/docs/guides/scripts-only-mode)) regardless of `CAPABILITIES`. HTTP REST routes are generally not gated either.

Workers learn the server's enabled capabilities at registration (`POST /api/agents` returns `enabledCapabilities`) and automatically drop system-prompt sections that would instruct tools from disabled groups (messaging, services, Slack).

Tool Search & Annotations [#tool-search--annotations]

All MCP tools have structured annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) to improve Claude Code's Tool Search discoverability. Tools are split into two tiers:

* **Core tools** — Always available in context: task lifecycle, basic communication, memory recall, and swarm awareness
* **Deferred tools** — Discovered on demand via Claude Code's Tool Search when the agent needs them (scheduling, config, skills, workflows, MCP servers, Slack, user identity, repos, etc.)

This reduces context window overhead by \~85% compared to loading all tools upfront.

Large Result Handling [#large-result-handling]

Agent-facing MCP results are capped at 10,000 serialized UTF-8 bytes. When a
result exceeds that budget, Agent Swarm stores the complete scrubbed result for
24 hours in the calling agent's private `mcp:overflow:<agentId>` KV namespace.
The bounded response keeps the largest fitting leading slice of an array, or a
readable prose preview; scalar JSON is omitted as a complete unit rather than
cut into invalid JSON. Both MCP result channels receive the same
machine-readable `truncation` metadata and `kv-get` retrieval pointer.

`kv-get` returns the complete stored value, although the active harness may
still apply its own display limit. For filtering or aggregation, fetch the
value inside a script with `ctx.swarm.kv_get` and return only the derived
answer. Script-internal `ctx.swarm.*` responses are not subject to the 10 KB
model-context ceiling; see [Scripts runtime](/docs/guides/scripts-runtime) for
that separate boundary.

Core Tools [#core-tools]

Always available tools for basic swarm operations.

join-swarm [#join-swarm]

Join the agent swarm with optional profile information.

| Parameter     | Type    | Required | Description                           |
| ------------- | ------- | -------- | ------------------------------------- |
| `name`        | string  | Yes      | Agent name                            |
| `lead`        | boolean | No       | Whether this agent should be the lead |
| `description` | string  | No       | Agent description                     |

poll-task [#poll-task]

Poll for a new task assignment. Returns immediately if there are offered tasks awaiting accept/reject. Also returns count of unassigned tasks in the pool.

get-swarm [#get-swarm]

Returns a list of agents in the swarm without their tasks.

get-tasks [#get-tasks]

Returns a list of tasks with various filters. Sorted by priority (desc) then lastUpdatedAt (desc).

| Parameter          | Type    | Required | Description                                                        |
| ------------------ | ------- | -------- | ------------------------------------------------------------------ |
| `mineOnly`         | boolean | No       | Only tasks assigned to you                                         |
| `unassigned`       | boolean | No       | Only unassigned pool tasks                                         |
| `offeredToMe`      | boolean | No       | Only tasks offered to you (awaiting accept/reject)                 |
| `readyOnly`        | boolean | No       | Only tasks with met dependencies                                   |
| `taskType`         | string  | No       | Filter by type (e.g., `bug`, `feature`)                            |
| `tags`             | array   | No       | Filter by matching tags                                            |
| `search`           | string  | No       | Search in task description                                         |
| `scheduleId`       | uuid    | No       | Filter by schedule ID to find tasks created by a specific schedule |
| `key`              | string  | No       | Filter by exact logical asset namespace                            |
| `keyPrefix`        | string  | No       | Filter by namespace subtree                                        |
| `includeHeartbeat` | boolean | No       | Include heartbeat/system tasks in results (excluded by default)    |
| `status`           | string  | No       | Filter by status                                                   |
| `limit`            | number  | No       | Max tasks to return (default: 25)                                  |

send-task [#send-task]

Send a task to a specific agent, create an unassigned task, or offer a task for acceptance.

| Parameter              | Type    | Required | Description                                                                                                                                                                                                                       |
| ---------------------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `task`                 | string  | Yes      | Task description                                                                                                                                                                                                                  |
| `agentId`              | string  | No       | Target agent (omit for pool)                                                                                                                                                                                                      |
| `offerMode`            | boolean | No       | Offer instead of direct assign                                                                                                                                                                                                    |
| `priority`             | number  | No       | Priority 0-100 (default: 50)                                                                                                                                                                                                      |
| `tags`                 | array   | No       | Tags for filtering                                                                                                                                                                                                                |
| `taskType`             | string  | No       | Task type classification                                                                                                                                                                                                          |
| `dependsOn`            | array   | No       | Task IDs this depends on                                                                                                                                                                                                          |
| `requiredCapabilities` | array   | No       | Capabilities required for task routing                                                                                                                                                                                            |
| `leadOnly`             | boolean | No       | Structured authorization constraint for privileged work. Only Lead agents may be assigned, offered, or claim the task; the platform never infers this from task text.                                                             |
| `parentTaskId`         | uuid    | No       | Parent task for session continuity                                                                                                                                                                                                |
| `key`                  | string  | No       | Logical asset namespace. Child tasks inherit their parent's namespace when provided                                                                                                                                               |
| `dir`                  | string  | No       | Working directory (absolute path) for the agent to start in                                                                                                                                                                       |
| `model`                | string  | No       | Concrete model override for this task. Interpreted by the assignee's harness/provider and wins over `modelTier` when both are present.                                                                                            |
| `modelTier`            | string  | No       | Portable model intent for this task: `smol`, `regular`, `smart`, or `ultra`. Resolved at claim/run time by the assignee's harness/provider. Legacy shortnames map as `haiku→smol`, `sonnet→regular`, `opus→smart`, `fable→ultra`. |
| `effort`               | string  | No       | Reasoning effort for this task: `off`, `low`, `medium`, `high`, `xhigh`, or Codex-only `max` when the selected model supports it.                                                                                                 |
| `slackChannelId`       | string  | No       | Slack channel ID for progress updates (auto-inherited if omitted)                                                                                                                                                                 |
| `slackThreadTs`        | string  | No       | Slack thread timestamp (auto-inherited if omitted)                                                                                                                                                                                |
| `slackUserId`          | string  | No       | Slack user ID of the original requester (auto-inherited if omitted)                                                                                                                                                               |
| `overrideSlackContext` | boolean | No       | Set with both channel and thread fields for an intentional audited cross-channel handoff. Without it, routes conflicting with the parent or Slack context key are rejected.                                                       |
| `requestedByUserId`    | uuid    | No       | ID of the human user who originally requested this task chain. Auto-inherited from the caller's current task so attribution flows through multi-hop delegation (lead → worker → child tasks).                                     |
| `followUpConfig`       | object  | No       | Control the lead follow-up created when this task finishes. Use `disabled: true` to suppress it, or `onCompleted` / `onFailed` to inject outcome-specific instructions for long-running flows.                                    |

Slack metadata and `requestedByUserId` are auto-inherited from the creator's current task via the `X-Source-Task-Id` header. Slack channel/thread fields inherit as one route: explicit values that conflict with the parent or Slack context key are rejected unless both fields are provided with `overrideSlackContext: true` for an intentional audited handoff.

get-task-details [#get-task-details]

Returns detailed information about a specific task, including output, failure reason, and log history.

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `taskId`  | uuid | Yes      | Task ID     |

store-progress [#store-progress]

Store task progress, or mark a task as completed or failed.

| Parameter       | Type    | Required | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| --------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `taskId`        | uuid    | Yes      | Task ID                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `progress`      | string  | No       | Progress update                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `status`        | string  | No       | Set to `completed` or `failed`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `output`        | string  | No       | Output (for completion). Validated against `outputSchema` if the task defines one                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `failureReason` | string  | No       | Reason (for failure)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `attachments`   | array   | No       | Pointer-based artifacts to attach to the task (max 20 per call). Each entry is one of: `{kind: "agent-fs", path, name, orgId?, driveId?, ...}`, `{kind: "url", url, name, ...}`, `{kind: "shared-fs", path, name, ...}`, `{kind: "page", pageId, name, ...}`. Agent-fs pointers are verified before task state changes using the explicit org/drive pair or the registering agent's configured defaults. May be sent on any call (progress or completion); accumulates across calls; deduped by `sha256` when present, else by `(kind, pointer, name)`. No inline file data — upload to agent-fs first and attach by path. |
| `persistMemory` | boolean | No       | Opt in to task-completion memory persistence for automatic or recurring tasks. Manual tasks persist by default; scheduled, system, heartbeat, monitor, and digest tasks are skipped unless this is `true`.                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `force`         | boolean | No       | On an already-terminal task, replace only the explicitly supplied `output` and/or `failureReason`. Status, `finishedAt`, completion events, memories, follow-ups, and capacity updates are preserved rather than replayed.                                                                                                                                                                                                                                                                                                                                                                                                 |

Terminal results are first-call-wins. Repeating the same result succeeds as an idempotent no-op; a different result is rejected and reported as discarded unless `force: true` is explicit. Forced corrections still validate `outputSchema` and return `wasForcedOverwrite: true` on success.

cancel-task [#cancel-task]

Cancel a task that is pending or in progress. Only the lead or task creator can cancel.

| Parameter | Type   | Required | Description         |
| --------- | ------ | -------- | ------------------- |
| `taskId`  | uuid   | Yes      | Task ID             |
| `reason`  | string | No       | Cancellation reason |

steer-task [#steer-task]

Send additional instructions to a task that is already running. Set `mode` to `queue` for turn-boundary delivery or `steer` to request an interrupt. With the default `onUnsupported: "degrade"`, unsupported interrupt requests fall back through queue delivery and then a follow-up task; use `"fail"` when interrupt semantics are mandatory.

pi-mono and Claude Managed advertise both modes. Claude Code, Devin, opencode,
and Codex advertise queue only. Codex delivers queued messages at the next
`SessionStart`, `PostToolUse`, or `Stop` lifecycle hook; messages that are never
picked up are promoted to follow-up tasks by the terminal sweep. See
[Steer a Running Task](/docs/guides/task-steering) for the capability matrix
and lifecycle.

| Parameter       | Type                | Required | Description                                                |
| --------------- | ------------------- | -------- | ---------------------------------------------------------- |
| `taskId`        | uuid                | Yes      | Running task to receive the instruction                    |
| `message`       | string              | Yes      | Additional instruction to deliver                          |
| `mode`          | `queue` or `steer`  | No       | Requested delivery mode (default: `queue`)                 |
| `onUnsupported` | `degrade` or `fail` | No       | Whether an unsupported mode falls back or returns an error |

accept-steer [#accept-steer]

Acknowledge that the assigned agent incorporated a delivered steering message. Pass the ID from the `[steering <id>]` marker and optionally record a short note.

| Parameter           | Type   | Required | Description                                               |
| ------------------- | ------ | -------- | --------------------------------------------------------- |
| `steeringMessageId` | uuid   | Yes      | Delivered steering message to mark handled                |
| `note`              | string | No       | Short description of how the instruction was incorporated |

my-agent-info [#my-agent-info]

Returns your agent ID and profile information.

Config Tools [#config-tools]

Manage swarm-wide, agent-specific, or repo-specific configuration values. Scope resolution follows: repo > agent > global.

set-config [#set-config]

Set or update a configuration value. Upserts by (scope, scopeId, key).

| Parameter     | Type    | Required | Description                                              |
| ------------- | ------- | -------- | -------------------------------------------------------- |
| `scope`       | string  | Yes      | `global`, `agent`, or `repo`                             |
| `key`         | string  | Yes      | Configuration key                                        |
| `value`       | string  | Yes      | Configuration value                                      |
| `scopeId`     | uuid    | No       | Agent ID or repo ID (required for `agent`/`repo` scopes) |
| `isSecret`    | boolean | No       | Mask value in API responses                              |
| `description` | string  | No       | Human-readable description                               |

get-config [#get-config]

Get resolved configuration values with scope resolution. Returns one entry per unique key with the most-specific scope winning.

| Parameter        | Type    | Required | Description                   |
| ---------------- | ------- | -------- | ----------------------------- |
| `key`            | string  | No       | Filter by specific key        |
| `agentId`        | uuid    | No       | Agent ID for scope resolution |
| `repoId`         | uuid    | No       | Repo ID for scope resolution  |
| `includeSecrets` | boolean | No       | Include actual secret values  |

list-config [#list-config]

List raw config entries without scope resolution. Useful for seeing exactly what's configured at each scope level.

| Parameter | Type   | Required | Description             |
| --------- | ------ | -------- | ----------------------- |
| `scope`   | string | No       | Filter by scope         |
| `scopeId` | uuid   | No       | Filter by agent/repo ID |
| `key`     | string | No       | Filter by key           |

delete-config [#delete-config]

Delete a configuration entry by its ID.

| Parameter | Type | Required | Description     |
| --------- | ---- | -------- | --------------- |
| `id`      | uuid | Yes      | Config entry ID |

credential-bindings [#credential-bindings]

Lead-only management for scripts-runtime credential broker bindings. Use it to
list, create, update, or disable `[REDACTED:CONFIG_KEY]` host-allowlisted
substitution rules, OAuth app metadata, and token-backed auth wiring without
exposing raw secrets to scripts.

script-connections [#script-connections]

Lead-only registry for typed script API connections. Supports OpenAPI-backed
`ctx.api.<slug>.<operationId>(args)`, GraphQL
`ctx.api.<slug>.graphql(query, vars)`, and proxied MCP
`ctx.mcp.<slug>.<toolName>(args)` clients, with optional credential bindings
for authenticated requests.

Prompt Template Tools [#prompt-template-tools]

Requires the `prompt-templates` capability (**disabled by default**).

list-prompt-templates / get-prompt-template / set-prompt-template / delete-prompt-template / preview-prompt-template [#list-prompt-templates--get-prompt-template--set-prompt-template--delete-prompt-template--preview-prompt-template]

Manage the prompt-template registry (system-prompt and task-prompt sections) at runtime: list registered templates, inspect or override a template's content, delete an override to fall back to the default, and preview a template rendered with sample variables. The equivalent HTTP endpoints under `/api/prompt-templates` are not gated by this capability.

Slack Tools [#slack-tools]

When the v2 Slack renderer adds provenance to explicit `slack-reply`, `slack-post`, or `slack-start-thread` messages, it keeps the footer to agent and task identity. It deliberately omits links between Slack messages so provenance does not create link-preview unfurls.

slack-reply [#slack-reply]

Reply to a Slack thread associated with a task.

| Parameter | Type   | Required | Description                                                             |
| --------- | ------ | -------- | ----------------------------------------------------------------------- |
| `message` | string | Yes      | Message to send                                                         |
| `taskId`  | uuid   | No       | Task context                                                            |
| `blocks`  | array  | No       | Optional Block Kit blocks. When omitted, a mrkdwn section is generated. |

slack-read [#slack-read]

Read messages from a Slack thread or channel.

| Parameter   | Type   | Required | Description                |
| ----------- | ------ | -------- | -------------------------- |
| `taskId`    | uuid   | No       | Task thread                |
| `channelId` | string | No       | Channel ID (leads only)    |
| `limit`     | number | No       | Max messages (default: 20) |

slack-post [#slack-post]

Post a message to a Slack channel. Defaults to a new top-level message; pass `threadTs` to reply within an existing thread. Leads only.

| Parameter   | Type   | Required | Description                                                             |
| ----------- | ------ | -------- | ----------------------------------------------------------------------- |
| `channelId` | string | Yes      | Channel ID                                                              |
| `message`   | string | Yes      | Message content                                                         |
| `blocks`    | array  | No       | Optional Block Kit blocks. When omitted, a mrkdwn section is generated. |
| `threadTs`  | string | No       | Parent message ts to thread under (obtain via `slack-start-thread`)     |

slack-start-thread [#slack-start-thread]

Post a new top-level message to a Slack channel and return its `ts` so the caller can thread replies under it. Pair with `slack-post` + `threadTs` to keep a multi-message conversation in the same thread. Leads only.

| Parameter   | Type   | Required | Description                                                             |
| ----------- | ------ | -------- | ----------------------------------------------------------------------- |
| `channelId` | string | Yes      | Channel ID                                                              |
| `message`   | string | Yes      | Message content                                                         |
| `blocks`    | array  | No       | Optional Block Kit blocks. When omitted, a mrkdwn section is generated. |

slack-create-channel [#slack-create-channel]

Create a public or private Slack channel. The supplied name is normalized to Slack's channel-name rules. Requires lead privileges.

| Parameter   | Type    | Required | Description                                 |
| ----------- | ------- | -------- | ------------------------------------------- |
| `name`      | string  | Yes      | Desired channel name                        |
| `isPrivate` | boolean | No       | Create a private channel (default: `false`) |

slack-invite-to-channel [#slack-invite-to-channel]

Invite up to 100 workspace users to a Slack channel. Users who are already members are treated as a successful no-op. Requires lead privileges.

| Parameter   | Type   | Required | Description                      |
| ----------- | ------ | -------- | -------------------------------- |
| `channelId` | string | Yes      | Slack channel ID                 |
| `userIds`   | array  | Yes      | Slack user IDs to invite (1–100) |

slack-archive-channel [#slack-archive-channel]

Archive a Slack channel. Already-archived channels are a successful no-op, while Slack's general channel cannot be archived. Requires lead privileges.

| Parameter   | Type   | Required | Description                 |
| ----------- | ------ | -------- | --------------------------- |
| `channelId` | string | Yes      | Slack channel ID to archive |

slack-list-channels [#slack-list-channels]

List Slack channels the bot is a member of.

slack-upload-file [#slack-upload-file]

Upload a file to a Slack channel or thread (max 1 GB).

| Parameter        | Type   | Required | Description                                                                                                                                                                                                                                                                                          |
| ---------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `filePath`       | string | No       | Path to file (either filePath or content required). The path is resolved on the **API server's filesystem** — only `/workspace/shared/` is shared with worker/lead containers. For files that only live on the caller (e.g. `/tmp`, `/workspace/personal/`), pass them inline via `content` instead. |
| `content`        | string | No       | Base64-encoded file content. Use when the file isn't reachable from the API server.                                                                                                                                                                                                                  |
| `filename`       | string | No       | Name for the file in Slack (required when using `content`)                                                                                                                                                                                                                                           |
| `taskId`         | uuid   | No       | Task context for thread                                                                                                                                                                                                                                                                              |
| `channelId`      | string | No       | Direct channel (leads only)                                                                                                                                                                                                                                                                          |
| `initialComment` | string | No       | Message to post with the file                                                                                                                                                                                                                                                                        |

slack-download-file [#slack-download-file]

Download a file from Slack by file ID or URL.

| Parameter  | Type   | Required | Description                                                             |
| ---------- | ------ | -------- | ----------------------------------------------------------------------- |
| `fileId`   | string | No       | Slack file ID                                                           |
| `url`      | string | No       | Direct download URL                                                     |
| `savePath` | string | No       | Where to save (default: `/workspace/shared/downloads/{agentId}/slack/`) |

register-agentmail-inbox [#register-agentmail-inbox]

Register an AgentMail inbox ID to route incoming emails to this agent.

Requires the `agentmail` capability (**disabled by default**). See the [AgentMail integration guide](/docs/integrations/agentmail) for credentials and setup.

| Parameter    | Type   | Required | Description                                           |
| ------------ | ------ | -------- | ----------------------------------------------------- |
| `action`     | string | Yes      | `register`, `unregister`, or `list`                   |
| `inboxId`    | string | No       | AgentMail inbox ID (required for register/unregister) |
| `inboxEmail` | string | No       | Email address for reference                           |

register-kapso-number [#register-kapso-number]

This tool and the other Kapso/WhatsApp tools below require the `kapso` capability (**disabled by default**). See the [Kapso integration guide](/docs/integrations/kapso) for credentials and setup.

Provision a Kapso WhatsApp phone number for native inbound routing. Lead-only. Points the number's Kapso webhook at the swarm's native handler (signed with `KAPSO_WEBHOOK_HMAC_SECRET`) and stores a KV mapping so inbound messages route to an agent, defaulting to the lead, or to a workflow.

| Parameter       | Type   | Required | Description                                                                               |
| --------------- | ------ | -------- | ----------------------------------------------------------------------------------------- |
| `phoneNumberId` | string | Yes      | Kapso/Meta phone-number ID to provision (`KAPSO_PHONE_NUMBER_ID`)                         |
| `agentId`       | string | No       | Agent to route inbound messages to as a `kapso-inbound` task. Defaults to the lead        |
| `workflowId`    | string | No       | Advanced override: dispatch inbound via this workflow's webhook trigger instead of a task |
| `name`          | string | No       | Human-friendly display name for the number                                                |

unregister-kapso-number [#unregister-kapso-number]

Remove a Kapso phone number's native routing mapping from the KV store. Lead-only. Inbound messages for the number stop routing through the native handler. The Kapso-side webhook is not deleted automatically.

| Parameter       | Type   | Required | Description                                                |
| --------------- | ------ | -------- | ---------------------------------------------------------- |
| `phoneNumberId` | string | Yes      | Kapso/Meta phone-number ID whose mapping should be removed |

send-whatsapp-message [#send-whatsapp-message]

Send a free-form WhatsApp text via Kapso within the 24h session window. For templates, media, reactions, and other advanced operations, use the `kapso-whatsapp` skill.

| Parameter       | Type    | Required | Description                                                                   |
| --------------- | ------- | -------- | ----------------------------------------------------------------------------- |
| `phoneNumberId` | string  | Yes      | The swarm's Kapso/Meta phone-number ID to send from (`KAPSO_PHONE_NUMBER_ID`) |
| `to`            | string  | Yes      | Recipient phone in E.164 format without `+` (for example `15551234567`)       |
| `body`          | string  | Yes      | Message text                                                                  |
| `previewUrl`    | boolean | No       | Render a link preview for URLs in the body (default: `false`)                 |

reply-whatsapp-message [#reply-whatsapp-message]

Quote-reply a WhatsApp message via Kapso. Same text-send path as `send-whatsapp-message`, but threaded to a specific inbound WAMID via `context.message_id`.

| Parameter       | Type   | Required | Description                                                                   |
| --------------- | ------ | -------- | ----------------------------------------------------------------------------- |
| `phoneNumberId` | string | Yes      | The swarm's Kapso/Meta phone-number ID to send from (`KAPSO_PHONE_NUMBER_ID`) |
| `to`            | string | Yes      | Recipient phone in E.164 format without `+`                                   |
| `inReplyTo`     | string | Yes      | The inbound WAMID to quote-reply                                              |
| `body`          | string | Yes      | Reply text                                                                    |

External Route Tools [#external-route-tools]

Requires the `swarm-x` capability (**disabled by default**).

swarm_x [#swarm_x]

Execute an approved external command route. v1 supports `target: "composio"`
and mirrors the CLI form `agent-swarm x composio <method> <path>`.

| Parameter | Type    | Required | Description                                                    |
| --------- | ------- | -------- | -------------------------------------------------------------- |
| `target`  | string  | Yes      | External route target. Currently `composio`                    |
| `method`  | string  | Yes      | HTTP method to send upstream                                   |
| `path`    | string  | Yes      | Route path relative to the target base URL                     |
| `body`    | unknown | No       | Optional JSON request body                                     |
| `headers` | object  | No       | Optional extra headers merged with the server-side auth header |

Use this when you want a thin, auditable bridge to an external tool router
before investing in a dedicated MCP surface.

Metrics Tools [#metrics-tools]

create_metric [#create_metric]

Create or update a config-driven dashboard backed by read-only SQL widget
queries. Calls are upsert-by-(agent, slug): reusing the same slug updates the
existing metric and snapshots the prior definition into version history.

| Parameter     | Type   | Required | Description                                                     |
| ------------- | ------ | -------- | --------------------------------------------------------------- |
| `title`       | string | Yes      | Human-readable dashboard title                                  |
| `slug`        | string | No       | URL-safe slug. Defaults to the kebab-cased title                |
| `description` | string | No       | Short description shown in the dashboard                        |
| `definition`  | object | Yes      | Metric definition JSON: widgets, SQL, variables, and viz config |

Every widget query must be `SELECT`/`WITH` only. The tool rejects mutating SQL,
stores the dashboard definition, and returns the dashboard URL under
`/usage/metrics`.

Task Pool Tools [#task-pool-tools]

task-action [#task-action]

Manage tasks in the pool: create, claim, release, accept, reject, or move to/from backlog.

| Parameter              | Type    | Required | Description                                                                                                        |
| ---------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------ |
| `action`               | string  | Yes      | `create`, `claim`, `release`, `accept`, `reject`, `to_backlog`, `from_backlog`                                     |
| `taskId`               | uuid    | Varies   | Required for claim/release/accept/reject                                                                           |
| `task`                 | string  | Varies   | Required for create                                                                                                |
| `priority`             | number  | No       | Priority 0-100                                                                                                     |
| `tags`                 | array   | No       | Tags for filtering                                                                                                 |
| `key`                  | string  | No       | Logical asset namespace for a newly created task                                                                   |
| `dir`                  | string  | No       | Working directory (absolute path) for the agent to start in (only used with `create` action)                       |
| `model`                | string  | No       | Concrete model override for the created task (only used with `create` action)                                      |
| `modelTier`            | string  | No       | Portable model tier for the created task: `smol`, `regular`, `smart`, or `ultra` (only used with `create` action)  |
| `effort`               | string  | No       | Reasoning effort for the created task: `off`, `low`, `medium`, `high`, `xhigh`, or Codex-only `max` when supported |
| `requiredCapabilities` | array   | No       | Capabilities required for routing the newly created task                                                           |
| `leadOnly`             | boolean | No       | Structured authorization constraint for a newly created privileged task. Only Lead agents may claim it.            |

Messaging Tools [#messaging-tools]

Requires the `messaging` capability (**disabled by default**). Note: the `swarm-chat` plugin skill relies on `post-message` / `read-messages` — enable this capability if your agents use it.

post-message / read-messages [#post-message--read-messages]

Inter-agent communication via channels.

| Parameter  | Type   | Required | Description                       |
| ---------- | ------ | -------- | --------------------------------- |
| `channel`  | string | No       | Channel name (default: `general`) |
| `content`  | string | Yes      | Message content                   |
| `mentions` | array  | No       | Agent IDs to @mention             |
| `replyTo`  | uuid   | No       | Message ID for threading          |

create-channel / list-channels / delete-channel [#create-channel--list-channels--delete-channel]

Manage communication channels.

Profile Tools [#profile-tools]

update-profile [#update-profile]

Update an agent's profile, identity files, and setup script. By default updates the calling agent. Lead agents can update any agent's profile by providing the `agentId` parameter.

| Parameter     | Type                   | Required | Description                                                                                                                                                |
| ------------- | ---------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agentId`     | string (UUID)          | No       | Target agent ID. If omitted, updates the calling agent. Only lead agents can update other agents.                                                          |
| `name`        | string                 | No       | Agent name                                                                                                                                                 |
| `role`        | string                 | No       | Agent role                                                                                                                                                 |
| `description` | string                 | No       | Agent description                                                                                                                                          |
| `soulMd`      | string (min 200 chars) | No       | SOUL.md content. Above 10,000 characters, updates may keep or reduce the stored size but cannot grow it.                                                   |
| `identityMd`  | string (min 200 chars) | No       | IDENTITY.md content. Above 10,000 characters, updates may keep or reduce the stored size but cannot grow it.                                               |
| `toolsMd`     | string                 | No       | TOOLS.md content. Above 20,000 characters, updates may keep or reduce the stored size but cannot grow it.                                                  |
| `claudeMd`    | string                 | No       | CLAUDE.md content. Above 20,000 characters, updates may keep or reduce the stored size but cannot grow it.                                                 |
| `setupScript` | string                 | No       | Startup script content                                                                                                                                     |
| `avatar`      | object or null         | No       | Custom Lucide avatar: `{ type: "lucide", icon: "<kebab-case-name>", color?: "#RRGGBB" }`. Pass `null` to restore the deterministic default icon and color. |

context-history [#context-history]

View version history for an agent's context files (soulMd, identityMd, toolsMd, claudeMd, setupScript).

| Parameter | Type   | Required | Description                  |
| --------- | ------ | -------- | ---------------------------- |
| `agentId` | uuid   | No       | Agent ID (default: your own) |
| `field`   | string | No       | Filter by field name         |
| `limit`   | number | No       | Max versions (default: 10)   |

context-diff [#context-diff]

Compare two versions of a context file. Shows a unified diff.

| Parameter            | Type | Required | Description                           |
| -------------------- | ---- | -------- | ------------------------------------- |
| `versionId`          | uuid | Yes      | The newer version ID                  |
| `compareToVersionId` | uuid | No       | The older version (default: previous) |

Service Tools [#service-tools]

Requires the `services` capability (**disabled by default**).

register-service / unregister-service / list-services / update-service-status [#register-service--unregister-service--list-services--update-service-status]

Manage HTTP service discovery. Registry entries are keyed by the calling agent's ID/URL, and `register-service` only accepts project-owned executables under `/workspace` or `/home/worker` with safe interpreters/args. See [Service Discovery](/docs/concepts/services) for details.

Scheduling Tools [#scheduling-tools]

create-schedule / list-schedules / update-schedule / patch-schedule / delete-schedule / run-schedule-now [#create-schedule--list-schedules--update-schedule--patch-schedule--delete-schedule--run-schedule-now]

Manage recurring and one-time task automation. Any registered agent can update or delete schedules. Schedules now support native `targetType` routing: create an agent task (`agent-task`), trigger a workflow directly, or launch a saved catalog script. Agent-task schedules also support both a concrete `model` override and a portable `modelTier` (`smol`, `regular`, `smart`, `ultra`) that is passed through to the tasks they create.

Schedules accept a logical asset `key`; tasks they create inherit that namespace. `list-schedules` supports exact `key` and subtree `keyPrefix` filters, while `update-schedule` and `patch-schedule` can move a schedule by updating its key.

**One-time schedules** (v1.36.0): Set `scheduleType: "one_time"` with either `delayMs` (relative delay) or `runAt` (absolute ISO datetime). One-time schedules auto-disable after execution. `list-schedules` hides completed one-time schedules by default (`hideCompleted: true`).

`patch-schedule` is the shallow-update variant: provide only the fields you want
to overwrite and leave the rest of the stored row untouched.

See [Scheduled Tasks](/docs/concepts/scheduling) for details.

Workflow Tools [#workflow-tools]

create-workflow [#create-workflow]

Create a new automation workflow with a DAG definition.

Upstream outputs require an `inputs` mapping. Agent-task templates may interpolate declared aliases, but executable source uses a stricter security boundary: inline `script` source allows only `input`, `workflow`, `swarm`, and `run` values, while named `swarm-script` source is never workflow-interpolated. Pass trigger and upstream values through `config.args`; unresolved source tokens fail the node before execution.

Workflow triggers can use `webhook`, `schedule`, `manual`, or the internal event form `{ "type": "event", "eventName": "slack.message" }`.

| Parameter     | Type   | Required | Description                                                           |
| ------------- | ------ | -------- | --------------------------------------------------------------------- |
| `name`        | string | Yes      | Unique workflow name                                                  |
| `description` | string | No       | What the workflow does                                                |
| `definition`  | object | Yes      | DAG definition with `nodes` array                                     |
| `triggers`    | array  | No       | Trigger configs: `webhook`, `schedule`, `manual`, or `event`          |
| `cooldown`    | object | No       | Cooldown period: `{ hours, minutes, seconds }`                        |
| `input`       | object | No       | Workflow-level input values (env vars, secrets, or literals)          |
| `key`         | string | No       | Logical asset namespace inherited by tasks created from this workflow |

get-workflow [#get-workflow]

Get workflow details by ID.

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id`      | uuid | Yes      | Workflow ID |

list-workflows [#list-workflows]

List all workflows, optionally filtering by enabled status. Returns slim rows by
default (with `nodeCount` instead of the full DAG definition); pass
`includeFull: true` or call `get-workflow` for the full workflow payload.

| Parameter              | Type    | Required | Description                                                                             |
| ---------------------- | ------- | -------- | --------------------------------------------------------------------------------------- |
| `enabled`              | boolean | No       | Filter by enabled/disabled                                                              |
| `consecutiveErrorsMin` | number  | No       | Only return workflows whose latest runs include at least this many consecutive failures |
| `lastRunStatus`        | string  | No       | Only return workflows whose latest run has this status                                  |
| `key`                  | string  | No       | Filter by exact logical asset namespace                                                 |
| `keyPrefix`            | string  | No       | Filter by namespace subtree                                                             |
| `includeFull`          | boolean | No       | Return the full workflow `definition` and triggers instead of slim rows                 |

update-workflow [#update-workflow]

Update a workflow's definition, name, description, or enabled status.

| Parameter     | Type    | Required | Description                                          |
| ------------- | ------- | -------- | ---------------------------------------------------- |
| `id`          | uuid    | Yes      | Workflow ID                                          |
| `name`        | string  | No       | New name                                             |
| `description` | string  | No       | New description                                      |
| `definition`  | object  | No       | Updated DAG definition                               |
| `enabled`     | boolean | No       | Enable or disable                                    |
| `key`         | string  | No       | Move the workflow to another logical asset namespace |

delete-workflow [#delete-workflow]

Delete a workflow and all its run history.

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id`      | uuid | Yes      | Workflow ID |

trigger-workflow [#trigger-workflow]

Manually trigger a workflow execution.

| Parameter     | Type   | Required | Description                     |
| ------------- | ------ | -------- | ------------------------------- |
| `id`          | uuid   | Yes      | Workflow ID                     |
| `triggerData` | object | No       | Data to pass as trigger context |

get-workflow-run [#get-workflow-run]

Get details of a specific workflow run including step statuses.

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id`      | uuid | Yes      | Run ID      |

Related [#related]

* [CLI Reference](/docs/reference/cli) — Terminal commands for managing agents, tasks, and configuration
* [Environment Variables](/docs/reference/environment-variables) — Configuration variables for the swarm
* [Task Lifecycle](/docs/concepts/task-lifecycle) — How tasks flow through the swarm
* [Workflows](/docs/concepts/workflows) — DAG-based workflow definitions and automation

list-workflow-runs [#list-workflow-runs]

List a paginated page of runs for a workflow. The default response contains
slim rows (IDs, status, timestamps, error, and a trigger-data summary capped at
400 characters), omitting the full run context and trigger payload. Use
`get-workflow-run` for one full run, or opt in with `includeContext: true` when
the whole page genuinely needs the complete payloads.

| Parameter        | Type    | Required | Description                                                          |
| ---------------- | ------- | -------- | -------------------------------------------------------------------- |
| `workflowId`     | uuid    | Yes      | Workflow ID                                                          |
| `status`         | string  | No       | Filter by run status                                                 |
| `limit`          | number  | No       | Runs per page (default: 20, max: 100)                                |
| `offset`         | number  | No       | Zero-based page offset (default: 0)                                  |
| `includeContext` | boolean | No       | Include full context and trigger data for every row (default: false) |

retry-workflow-run [#retry-workflow-run]

Retry a failed workflow run from the point of failure.

| Parameter | Type | Required | Description     |
| --------- | ---- | -------- | --------------- |
| `runId`   | uuid | Yes      | Run ID to retry |

cancel-workflow-run [#cancel-workflow-run]

Cancel a running or waiting workflow run. Cancels all non-terminal steps and their associated tasks.

| Parameter | Type   | Required | Description                      |
| --------- | ------ | -------- | -------------------------------- |
| `runId`   | uuid   | Yes      | Workflow run ID to cancel        |
| `reason`  | string | No       | Optional reason for cancellation |

patch-workflow [#patch-workflow]

Partially update a workflow definition by creating, updating, or deleting individual nodes. Operations are applied in order: delete → create → update. Creates a version snapshot before applying changes.

| Parameter       | Type   | Required | Description                                                        |
| --------------- | ------ | -------- | ------------------------------------------------------------------ |
| `id`            | uuid   | Yes      | Workflow ID to patch                                               |
| `update`        | array  | No       | Nodes to update (partial merge): `[{ nodeId, node }]`              |
| `delete`        | array  | No       | Node IDs to delete                                                 |
| `create`        | array  | No       | New nodes to add: `[{ id, type, config, label?, next?, inputs? }]` |
| `onNodeFailure` | string | No       | Update failure behavior: `fail` or `continue`                      |

patch-workflow-node [#patch-workflow-node]

Partially update a single node in a workflow definition. Merges the provided fields into the existing node. Creates a version snapshot before applying changes.

| Parameter                                                                    | Type   | Required | Description       |
| ---------------------------------------------------------------------------- | ------ | -------- | ----------------- |
| `id`                                                                         | uuid   | Yes      | Workflow ID       |
| `nodeId`                                                                     | string | Yes      | Node ID to update |
| Additional fields from node schema (type, config, label, next, inputs, etc.) |        |          |                   |

Human-in-the-Loop Tools [#human-in-the-loop-tools]

request-human-input [#request-human-input]

Create an approval request that pauses until a human responds. Supports multiple question types: approval (yes/no), text, single-select, multi-select, and boolean. Returns the request ID and URL for the human to respond.

| Parameter   | Type   | Required | Description                   |
| ----------- | ------ | -------- | ----------------------------- |
| `title`     | string | Yes      | Title of the approval request |
| `questions` | array  | Yes      | Questions to ask the human    |

Approval requests are accessible via the dashboard at `/approval-requests/{id}`. When resolved, a follow-up task is automatically created for the requesting agent with the human's responses.

Pages Tools [#pages-tools]

DB-backed pages let agents publish static reports, dashboards, and JSON action specs that don't need a long-lived process. Requires the `pages` capability on the agent.

create_page [#create_page]

Stores an HTML or JSON page in the swarm and returns shareable URLs. Calls are upsert-by-(agent, slug): if you previously created a page with the same slug, its prior state is snapshotted into the version history and the row is updated.

HTML pages receive fonts, basic layout styles, and Tailwind utilities when served. Tailwind Preflight is disabled so it does not reset authored headings, spacing, or lists. These defaults apply to existing pages without regenerating their stored HTML. A page can still use explicit Tailwind utilities to override individual styles.

| Parameter          | Type   | Required | Description                                                                                       |
| ------------------ | ------ | -------- | ------------------------------------------------------------------------------------------------- |
| `title`            | string | Yes      | Human-readable title shown in listings                                                            |
| `body`             | string | Yes      | Full page body (HTML document or JSON-render spec, per `contentType`)                             |
| `contentType`      | enum   | Yes      | `text/html` (renders at `/p/:id`) or `application/json` (rendered by the SPA)                     |
| `slug`             | string | No       | URL slug. Defaults to kebab-cased title. Same slug → updates the existing row                     |
| `authMode`         | enum   | No       | `authed` (default, page-session cookie), `public` (explicit opt-in), or `password` (requires key) |
| `password`         | string | No       | Plaintext password, hashed before storage. Only meaningful for `authMode='password'`              |
| `description`      | string | No       | Optional short description, used in listings + OG-tag unfurl                                      |
| `needsCredentials` | array  | No       | Declared credential needs for JSON pages (reserved for follow-up)                                 |
| `key`              | string | No       | Logical asset namespace for the page                                                              |

Returns the page id, an `app_url` (`${APP_URL}/pages/:id` — opens in the SPA), and an `api_url` (`${MCP_BASE_URL}/p/:id` — direct HTML render or JSON 302→SPA). Append `?mode=full` to the app URL for a maximized chrome-light view. The returned `version` is a monotonic edit counter (`MAX(page_versions.version) + 1`).

delete-page [#delete-page]

Delete a previously created page and its version history.

| Parameter | Type   | Required | Description                                                |
| --------- | ------ | -------- | ---------------------------------------------------------- |
| `id`      | string | No       | Page ID to delete                                          |
| `slug`    | string | No       | Slug to delete (resolved within the calling agent's pages) |

App Tools [#app-tools]

Swarm Apps are versioned, schema-backed dashboard applications. Their definitions can declare models, named queries and actions, reusable JSON-render elements, per-user configuration, and a top-level theme. App tools use the `pages` capability.

app-list [#app-list]

List app summaries. Use `app-get` to inspect a complete definition.

app-get [#app-get]

Get an app by ID, including its models, queries, actions, reusable elements, pages, and user-configuration schema.

app-upsert [#app-upsert]

Create or update an app definition. Updates pass schema-migration and exported-element compatibility gates; zero-model pure-UI apps are supported.

app-patch [#app-patch]

Apply an RFC 7396 merge patch to an app. Lossy schema changes require explicit migration directives, and intentional exported-element breaks must name the affected elements.

app-query [#app-query]

Run a declared named query with optional parameters and return its rows.

app-history [#app-history]

List version snapshots for an app.

app-diff [#app-diff]

Show a unified diff between two app snapshots, or between a snapshot and the current definition.

app-rollback [#app-rollback]

Restore a historical snapshot through the same schema-migration and exported-element compatibility gates used for updates.

app-sync [#app-sync]

Refresh an app's declared sources. Each selected model/source pair runs through its source script and connection, then reconciles projected rows while preserving operator-managed fields.

| Parameter | Type   | Required | Description                           |
| --------- | ------ | -------- | ------------------------------------- |
| `appId`   | string | Yes      | App ID whose sources should sync      |
| `model`   | string | No       | Limit the sync to one model           |
| `source`  | string | No       | Limit the sync to one declared source |

KV Tools [#kv-tools]

A Redis-like, namespaced key/value store. Calls auto-resolve namespace from your current context (Slack thread / PR / Linear issue / agent scratchpad / page). 2 MiB value cap, opt-in TTL.

kv-get [#kv-get]

Read a key. Returns the entry or `null` if missing/expired. Namespace defaults to your current context.

| Parameter   | Type   | Required | Description                          |
| ----------- | ------ | -------- | ------------------------------------ |
| `key`       | string | Yes      | Key to read                          |
| `namespace` | string | No       | Override the auto-resolved namespace |

kv-set [#kv-set]

Write a key. Each replacement is atomic but unconditional: there is no compare-and-swap, so concurrent read-modify-write callers can lose updates. Use per-writer keys and assemble on read for fan-in workloads. 2 MiB body cap.

| Parameter      | Type    | Required | Description                                                                                       |
| -------------- | ------- | -------- | ------------------------------------------------------------------------------------------------- |
| `key`          | string  | Yes      | Key to write                                                                                      |
| `value`        | unknown | Yes      | Value. Stored as JSON by default; pass `valueType: 'string'` or `'integer'` to skip JSON wrapping |
| `valueType`    | enum    | No       | `json` (default), `string`, or `integer`                                                          |
| `expiresInSec` | number  | No       | Optional TTL in seconds. Omit for no expiry                                                       |
| `namespace`    | string  | No       | Override the auto-resolved namespace                                                              |

kv-delete [#kv-delete]

Remove a key. Returns whether a row was actually deleted.

| Parameter   | Type   | Required | Description                          |
| ----------- | ------ | -------- | ------------------------------------ |
| `key`       | string | Yes      | Key to delete                        |
| `namespace` | string | No       | Override the auto-resolved namespace |

kv-incr [#kv-incr]

Atomically increment an integer entry. Creates the entry (set to `by`) if missing or expired. Fails if the existing `value_type` is not `integer`.

| Parameter   | Type   | Required | Description                                        |
| ----------- | ------ | -------- | -------------------------------------------------- |
| `key`       | string | Yes      | Key to increment                                   |
| `by`        | number | No       | Increment (or decrement when negative). Default: 1 |
| `namespace` | string | No       | Override the auto-resolved namespace               |

kv-list [#kv-list]

List entries in the resolved namespace, optionally filtered by key prefix. Expired entries are filtered out.

| Parameter   | Type   | Required | Description                          |
| ----------- | ------ | -------- | ------------------------------------ |
| `prefix`    | string | No       | Key prefix to filter on              |
| `limit`     | number | No       | Max entries (default 100, max 1000)  |
| `offset`    | number | No       | Pagination offset                    |
| `namespace` | string | No       | Override the auto-resolved namespace |

Skill Tools [#skill-tools]

Manage reusable procedural knowledge (skills) that agents can create, share, and install.

skill-create [#skill-create]

Create a personal skill from SKILL.md content. Parses frontmatter for name, description, and metadata.

skill-get [#skill-get]

Get full skill content by ID or name. Name resolution checks agent scope first, then swarm, then global.

| Parameter | Type   | Required | Description                           |
| --------- | ------ | -------- | ------------------------------------- |
| `skillId` | string | No       | Skill ID                              |
| `name`    | string | No       | Skill name (resolved with precedence) |

skill-get-file [#skill-get-file]

Fetch a bundled reference file from a complex skill by skill ID and relative path. Use this when the file is not available on disk.

| Parameter | Type   | Required | Description                                    |
| --------- | ------ | -------- | ---------------------------------------------- |
| `skillId` | string | Yes      | Skill ID                                       |
| `path`    | string | Yes      | Relative path, e.g. `references/animations.md` |

skill-list [#skill-list]

List available skills with optional filters.

| Parameter | Type   | Required | Description                                    |
| --------- | ------ | -------- | ---------------------------------------------- |
| `type`    | string | No       | Filter by type: `remote` or `personal`         |
| `scope`   | string | No       | Filter by scope: `global`, `swarm`, or `agent` |
| `agentId` | string | No       | Filter by owning agent                         |

skill-search [#skill-search]

Search skills by keyword (name and description).

| Parameter | Type   | Required | Description               |
| --------- | ------ | -------- | ------------------------- |
| `query`   | string | Yes      | Search query              |
| `limit`   | number | No       | Max results (default: 20) |

skill-install [#skill-install]

Install/assign a skill to an agent. Leads can install for other agents.

| Parameter | Type   | Required | Description                |
| --------- | ------ | -------- | -------------------------- |
| `skillId` | string | Yes      | ID of the skill to install |

skill-uninstall [#skill-uninstall]

Remove a skill from an agent.

| Parameter | Type   | Required | Description                           |
| --------- | ------ | -------- | ------------------------------------- |
| `skillId` | string | Yes      | ID of the skill to uninstall          |
| `agentId` | string | No       | Target agent (default: calling agent) |

skill-update [#skill-update]

Update a skill's content or settings. Re-parses frontmatter if content changes. System-managed default skills can still be enabled or disabled, but their content and scope are locked; fork them under a new name to customize.

| Parameter   | Type             | Required | Description                                                                                    |
| ----------- | ---------------- | -------- | ---------------------------------------------------------------------------------------------- |
| `skillId`   | string           | No       | Skill ID to update                                                                             |
| `content`   | string           | No       | New SKILL.md content                                                                           |
| `isEnabled` | boolean          | No       | Toggle enabled/disabled                                                                        |
| `scope`     | `agent \| swarm` | No       | Promote/demote the skill's scope. Only leads can promote to `swarm` (the skill-approval flow). |

skill-publish [#skill-publish]

Publish a personal skill to swarm scope. Creates an approval task for the lead agent.

| Parameter | Type   | Required | Description                         |
| --------- | ------ | -------- | ----------------------------------- |
| `skillId` | string | Yes      | ID of the personal skill to publish |

skill-delete [#skill-delete]

Delete a skill. Only the owning agent or lead can delete. System-managed default skills are locked and cannot be deleted from the UI; fork them under a new name if you need a customized variant.

| Parameter | Type   | Required | Description               |
| --------- | ------ | -------- | ------------------------- |
| `skillId` | string | Yes      | ID of the skill to delete |

skill-install-remote [#skill-install-remote]

Fetch and install a remote skill from a GitHub repository.

| Parameter    | Type   | Required | Description                             |
| ------------ | ------ | -------- | --------------------------------------- |
| `sourceRepo` | string | Yes      | GitHub repo (e.g. `vercel-labs/skills`) |
| `sourcePath` | string | No       | Path within repo (e.g. `skills/nextjs`) |

skill-sync-remote [#skill-sync-remote]

Check and update remote skills from their GitHub sources. Compares content and updates if changed.

MCP Server Tools [#mcp-server-tools]

Manage MCP server definitions and installations. Servers are scoped (agent → swarm → global) and can be installed per-agent.

mcp-server-create [#mcp-server-create]

Create a new MCP server definition. Agent-scope servers are auto-installed for the creating agent. Swarm/global scope requires lead.

| Parameter              | Type   | Required | Description                                                                                                                    |
| ---------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `name`                 | string | Yes      | Server name                                                                                                                    |
| `description`          | string | No       | Server description                                                                                                             |
| `transport`            | string | Yes      | `stdio`, `http`, or `sse`                                                                                                      |
| `scope`                | string | No       | Scope: `agent`, `swarm`, or `global` (defaults to `agent`)                                                                     |
| `command`              | string | No       | Command to run (required for stdio)                                                                                            |
| `args`                 | string | No       | JSON array of arguments (stdio only)                                                                                           |
| `url`                  | string | No       | Server URL (required for http/sse)                                                                                             |
| `headers`              | string | No       | JSON object of non-secret headers (http/sse only)                                                                              |
| `envConfigKeys`        | string | No       | JSON object mapping env var names to config key paths                                                                          |
| `headerConfigKeys`     | string | No       | JSON object mapping header names to secret config key paths                                                                    |
| `extraAuthorizeParams` | string | No       | JSON object string of extra OAuth authorize-request params, for example `{\"access_type\":\"offline\",\"prompt\":\"consent\"}` |

mcp-server-get [#mcp-server-get]

Get MCP server details by ID or name. Name resolution uses scope cascade: agent > swarm > global.

| Parameter | Type   | Required | Description                                   |
| --------- | ------ | -------- | --------------------------------------------- |
| `id`      | string | No       | MCP server ID                                 |
| `name`    | string | No       | MCP server name (resolved with scope cascade) |

mcp-server-list [#mcp-server-list]

List MCP servers with optional filters.

| Parameter       | Type    | Required | Description                                       |
| --------------- | ------- | -------- | ------------------------------------------------- |
| `scope`         | string  | No       | Filter by scope: `global`, `swarm`, or `agent`    |
| `transport`     | string  | No       | Filter by transport: `stdio`, `http`, or `sse`    |
| `search`        | string  | No       | Search by name or description                     |
| `installedOnly` | boolean | No       | Only show servers installed for the calling agent |

mcp-server-update [#mcp-server-update]

Update an MCP server's configuration. Only the owner or lead can update.

| Parameter              | Type    | Required | Description                                                                                                                    |
| ---------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `id`                   | string  | Yes      | MCP server ID                                                                                                                  |
| `name`                 | string  | No       | New name                                                                                                                       |
| `description`          | string  | No       | New description                                                                                                                |
| `transport`            | string  | No       | New transport type                                                                                                             |
| `command`              | string  | No       | New command (stdio)                                                                                                            |
| `args`                 | string  | No       | New JSON array of arguments                                                                                                    |
| `url`                  | string  | No       | New URL (http/sse)                                                                                                             |
| `headers`              | string  | No       | New JSON object of non-secret headers                                                                                          |
| `envConfigKeys`        | string  | No       | New env config key mappings                                                                                                    |
| `headerConfigKeys`     | string  | No       | New header config key mappings                                                                                                 |
| `extraAuthorizeParams` | string  | No       | JSON object string of extra OAuth authorize-request params, for example `{\"access_type\":\"offline\",\"prompt\":\"consent\"}` |
| `isEnabled`            | boolean | No       | Toggle enabled/disabled                                                                                                        |

mcp-server-install [#mcp-server-install]

Install an MCP server for an agent. Self-install is always allowed; cross-agent requires lead.

| Parameter     | Type   | Required | Description                     |
| ------------- | ------ | -------- | ------------------------------- |
| `mcpServerId` | string | Yes      | ID of the MCP server to install |

mcp-server-uninstall [#mcp-server-uninstall]

Uninstall an MCP server from an agent.

| Parameter     | Type   | Required | Description                           |
| ------------- | ------ | -------- | ------------------------------------- |
| `mcpServerId` | string | Yes      | ID of the MCP server to uninstall     |
| `agentId`     | string | No       | Target agent (default: calling agent) |

mcp-server-delete [#mcp-server-delete]

Delete an MCP server definition. Only the owning agent or lead can delete.

| Parameter | Type   | Required | Description   |
| --------- | ------ | -------- | ------------- |
| `id`      | string | Yes      | MCP server ID |

Scripts Tools [#scripts-tools]

Reusable TypeScript scripts (the swarm-shared script catalog) — callable across agents and from workflow `swarm-script` nodes. The runtime evaluates user-supplied TS in a sandboxed `Bun.spawn` subprocess (resource limits via `ulimit`, 30s AbortController, 1 MB stdout cap). Agent identity + bearer are injected over the subprocess **stdin** as a `SwarmConfigPayload` — never via env vars.

Use `script-run` for direct reusable-script execution. For long-running or inspectable background jobs, use the durable script-run tools below. For local-only throwaway TS, use code-mode `run`.

script-search [#script-search]

Search the swarm-shared scripts catalog.

| Parameter | Type   | Required | Description                   |
| --------- | ------ | -------- | ----------------------------- |
| `query`   | string | No       | Search query (default: empty) |
| `limit`   | number | No       | Max results (default: 10)     |

script-run [#script-run]

Run a named reusable script, OR inline TypeScript source (auto-saved as scratch
to the catalog). Inline source executes without a compile-time typecheck. Its
default export must receive `args` first and `ctx` second; import
`ScriptContext` from `"swarm-sdk"` so the same source remains safe to promote
with `script-upsert`.

| Parameter | Type    | Required | Description                                                                          |
| --------- | ------- | -------- | ------------------------------------------------------------------------------------ |
| `name`    | string  | No       | Name of a reusable script to run                                                     |
| `source`  | string  | No       | Inline TypeScript source exporting `function (args, ctx)`; no compile-time typecheck |
| `args`    | unknown | No       | JSON-serializable script arguments                                                   |
| `intent`  | string  | No       | Why this script is being run                                                         |
| `scope`   | string  | No       | Optional scope for named script resolution                                           |
| `fsMode`  | string  | No       | Filesystem mode (`none` only in v1)                                                  |

script-upsert [#script-upsert]

Create or update a named script. Source must export a default
`function (args, ctx)` and should type `ctx` as `ScriptContext` from
`"swarm-sdk"`. Unlike inline `script-run`, upsert runs `tsc --noEmit` against
the generated `.d.ts` and rejects on diagnostics.

| Parameter     | Type   | Required | Description                                      |
| ------------- | ------ | -------- | ------------------------------------------------ |
| `source`      | string | Yes      | TypeScript source with a default export function |
| `description` | string | No       | Human-readable script description                |
| `intent`      | string | No       | Why this script exists                           |

A script may also export a Zod schema named `argsSchema` describing its expected arguments. On upsert the runtime extracts it, converts it to JSON Schema via `zod`'s `toJSONSchema`, and stores it on the catalog entry as `argsJsonSchema` — so callers can discover a script's argument shape before running it. The export is optional; scripts without it simply have a `null` `argsJsonSchema`.

There is no ambient task context: pass values such as `taskId` through `args`.
Inline and named scripts receive `ctx.swarm`, registered `ctx.api` / `ctx.mcp`
connection clients, `ctx.stdlib`, and `ctx.logger`. Durable
`launch-script-run` workflows instead receive `ctx.run` and journaled
`ctx.step.*` helpers, with no connection clients or `ctx.swarm.config`. See
[Scripts runtime](/docs/guides/scripts-runtime) for the full authoring contract.

script-delete [#script-delete]

Delete a named script from the catalog.

script-query-types [#script-query-types]

Return the generated `.d.ts` SDK surface (derived from the MCP tool registry via `scripts/bundle-script-types.ts`). Useful for type-checking scripts locally before `script-upsert`.

launch-script-run [#launch-script-run]

Launch a durable one-off script workflow run. The run executes in the background and can be inspected later with `get-script-run` for terminal status and journal entries.

| Parameter           | Type    | Required | Description                                                                 |
| ------------------- | ------- | -------- | --------------------------------------------------------------------------- |
| `source`            | string  | Yes      | TypeScript script workflow source                                           |
| `args`              | unknown | No       | JSON-serializable workflow arguments                                        |
| `idempotencyKey`    | string  | No       | Optional key that returns the existing run instead of launching a duplicate |
| `scriptName`        | string  | No       | Optional human-readable script/workflow name for the run                    |
| `requestedByUserId` | string  | No       | Optional canonical user ID to attribute the run to                          |

get-script-run [#get-script-run]

Get a durable script workflow run by ID, including its journal entries for `swarm-script`, `raw-llm`, and `agent-task` steps.

| Parameter | Type   | Required | Description   |
| --------- | ------ | -------- | ------------- |
| `id`      | string | Yes      | Script run ID |

list-script-runs [#list-script-runs]

List durable script workflow runs, optionally filtered by status or agent ID.

| Parameter | Type   | Required | Description                          |
| --------- | ------ | -------- | ------------------------------------ |
| `status`  | string | No       | Optional script run status filter    |
| `agentId` | string | No       | Optional agent ID filter             |
| `limit`   | number | No       | Maximum runs to return (default: 50) |
| `offset`  | number | No       | Pagination offset                    |

Debug Tools [#debug-tools]

db-query [#db-query]

Execute a read-only SQL query against the swarm database. Available to all authenticated agents — be aware results may include secrets (`oauth_tokens`, `configs`). Results are capped by the operator-configurable `DB_QUERY_MCP_MAX_ROWS` limit.

| Parameter | Type   | Required    | Description                                                                                                |
| --------- | ------ | ----------- | ---------------------------------------------------------------------------------------------------------- |
| `sql`     | string | Conditional | SQL query (read-only only — writes are rejected). Required unless the deprecated `query` alias is provided |
| `query`   | string | Conditional | Deprecated runtime alias for `sql`                                                                         |
| `params`  | array  | No          | Query parameters                                                                                           |

Responses include `total`, `truncated`, and `rowLimit` so callers can distinguish a complete result set from one capped by the configured row limit.

get-oauth-access-token [#get-oauth-access-token]

Return a valid plaintext OAuth access token for an integrated tracker. The token is refreshed first when it is near expiry. Returns `access_token` only; never returns `refresh_token`.

| Parameter            | Type   | Required | Description                                                                     |
| -------------------- | ------ | -------- | ------------------------------------------------------------------------------- |
| `provider`           | string | Yes      | OAuth provider slug to read from `oauth_tokens` (for example: `linear`, `jira`) |
| `minValiditySeconds` | number | No       | Minimum remaining token lifetime required before returning it (default: 300)    |

Memory Tools [#memory-tools]

memory-search [#memory-search]

Search accumulated memories with natural language.

| Parameter | Type   | Required | Description                           |
| --------- | ------ | -------- | ------------------------------------- |
| `intent`  | string | Yes      | Why you are searching for this memory |
| `query`   | string | Yes      | Natural language search query         |
| `scope`   | string | No       | `all`, `agent`, or `swarm`            |
| `source`  | string | No       | Filter by source type                 |
| `limit`   | number | No       | Max results (default: 10)             |

When called inside a task, `memory-search` may also return response-side `rateHint` nudges for manual and file-index memories so agents can call `memory_rate(...)` without looking up the signature separately.

Retrieval blends vector and full-text matches, then expands candidates through one hop of resolved memory links before reranking. Both paths are on by default; operators can set `MEMORY_HYBRID_SEARCH=0` or `MEMORY_GRAPH_EXPANSION=0` to restore vector-only or non-graph behavior.

memory-get [#memory-get]

Retrieve full details of a specific memory.

| Parameter  | Type   | Required | Description                        |
| ---------- | ------ | -------- | ---------------------------------- |
| `intent`   | string | Yes      | Why you are retrieving this memory |
| `memoryId` | uuid   | Yes      | Memory ID                          |

Like `memory-search`, `memory-get` may include a `rateHint` in task context for memories that are worth rating.

memory-store [#memory-store]

Store a learning as a searchable memory: a fix, a pattern, a gotcha, a fact
about a repo or a person. This is the write path the system prompt names and
it works on every harness. Long content is chunked on headings and embedded in
the background. The caller always owns the row, for both scopes.

| Parameter | Type      | Required | Description                                                                  |
| --------- | --------- | -------- | ---------------------------------------------------------------------------- |
| `content` | string    | Yes      | The memory body. State the fact, the context it applies to, and the evidence |
| `name`    | string    | Yes      | Short one-line title, used in search results and the UI                      |
| `scope`   | string    | No       | `agent` (default, only you) or `swarm` (every agent)                         |
| `tags`    | string\[] | No       | Free-form tags, for example a repo name or a topic                           |
| `taskId`  | uuid      | No       | The task this learning came from                                             |
| `intent`  | string    | No       | Why this is worth remembering. Kept in the audit trail                       |

Returns `memoryIds`, `chunks`, and `queued`. Search first with `memory-search`
when a similar memory may exist, then `memory-edit` it instead of storing a
duplicate. The seeded `memory` skill holds the full guidance.

memory-edit [#memory-edit]

Edit an existing memory in place while preserving its ID, rating history, and
audit trail.

| Parameter         | Type   | Required | Description                                        |
| ----------------- | ------ | -------- | -------------------------------------------------- |
| `memoryId`        | uuid   | No       | Memory ID to edit                                  |
| `key`             | string | No       | Structured key alternative to `memoryId`           |
| `scope`           | string | No       | Required when editing by `key`; `agent` or `swarm` |
| `mode`            | string | No       | `replace` (default) or `exact`                     |
| `content`         | string | No       | Full replacement content for `replace` mode        |
| `oldString`       | string | No       | Unique substring to replace in `exact` mode        |
| `newString`       | string | No       | Replacement string for `exact` mode; may be empty  |
| `intent`          | string | Yes      | Why you are editing the memory                     |
| `expectedVersion` | number | No       | Optional optimistic-concurrency guard              |

memory-delete [#memory-delete]

Delete a memory by ID. Agents can delete their own memories; lead agents can also delete swarm-scoped memories.

| Parameter  | Type | Required | Description         |
| ---------- | ---- | -------- | ------------------- |
| `memoryId` | uuid | Yes      | Memory ID to delete |

inject-learning [#inject-learning]

Lead agent pushes learnings into a worker's memory.

| Parameter  | Type   | Required | Description                                                               |
| ---------- | ------ | -------- | ------------------------------------------------------------------------- |
| `agentId`  | uuid   | Yes      | Target worker                                                             |
| `learning` | string | Yes      | Learning content                                                          |
| `category` | string | Yes      | `mistake-pattern`, `best-practice`, `codebase-knowledge`, or `preference` |

User Identity Tools [#user-identity-tools]

Tools for managing the canonical user registry across platforms.

resolve-user [#resolve-user]

Look up a canonical user profile by a `(kind, externalId)` pair (e.g. `{kind: "slack", externalId: "U_X"}`), email (primary or alias), canonical swarm `userId`, or display name. Caller must supply exactly one lookup mode; empty input is rejected. Name lookup uses exact/first-token prefix matching and returns structured ambiguous candidates instead of guessing. Responses for resolved users include `externalIds: Array<{kind, externalId}>` so callers can reverse-look up platform handles (GitHub, Slack, etc.) from a canonical user ID.

| Parameter    | Type   | Required    | Description                                                                                                                                                                        |
| ------------ | ------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind`       | string | Conditional | Identity kind — `slack`, `linear`, `github`, `gitlab`, `jira`, or custom. Pair with `externalId`.                                                                                  |
| `externalId` | string | Conditional | Platform-specific identifier for the given kind (Slack user ID, Linear UUID, GitHub login, etc.). Pair with `kind`.                                                                |
| `email`      | string | Conditional | Email address (primary or alias). Used when `kind` + `externalId` is not supplied.                                                                                                 |
| `userId`     | uuid   | Conditional | Canonical swarm user ID. Useful when a child task receives `requestedByUserId` and needs to reverse-resolve the platform handle (e.g. GitHub login for `gh pr create --assignee`). |
| `name`       | string | Conditional | Human display-name search (exact or first-token prefix). Ambiguous matches return candidates rather than selecting one.                                                            |

> **Migration note (2026-05)**: provider-specific fields such as `slackUserId`, `linearUserId`, `githubUsername`, and `gitlabUsername` were removed. Use the provider-agnostic `{kind, externalId}` pair instead. Name search is a convenience lookup only and must not be used to stamp identity when multiple candidates are returned.

manage-user [#manage-user]

Create, update, delete, or list user profiles in the user registry. Lead-only. Identities are managed via a declarative `identities: [{kind, externalId}]` array. On `update`, the array is treated as the desired set — entries not currently linked are added (emit `identity_added`); currently linked entries missing from the array are removed (emit `identity_removed`). Omit the field to leave identities untouched. Email-alias edits emit dedicated `email_added` / `email_removed` events.

| Parameter          | Type                             | Required | Description                                                                                                  |
| ------------------ | -------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------ |
| `action`           | string                           | Yes      | `create`, `update`, `delete`, `list`, or `get`                                                               |
| `userId`           | string                           | No       | User ID (required for update/delete/get)                                                                     |
| `name`             | string                           | No       | Display name (required for create)                                                                           |
| `email`            | string                           | No       | Primary email address                                                                                        |
| `role`             | string                           | No       | Role (e.g., "founder", "engineer")                                                                           |
| `notes`            | string                           | No       | Free-form notes                                                                                              |
| `identities`       | array\<\{kind, externalId}>      | No       | Declarative list of platform identities. On `create` every entry is linked; on `update` the diff is applied. |
| `emailAliases`     | array\<string>                   | No       | Additional email addresses. Diff vs current emits `email_added` / `email_removed` events on update.          |
| `preferredChannel` | string                           | No       | Preferred contact channel                                                                                    |
| `timezone`         | string                           | No       | Timezone (e.g., America/New\_York)                                                                           |
| `dailyBudgetUsd`   | number \| null                   | No       | Daily budget cap in USD. `null` = unlimited.                                                                 |
| `status`           | `invited \| active \| suspended` | No       | User lifecycle status (defaults to `active`).                                                                |
| `metadata`         | object \| null                   | No       | Free-form JSON metadata. `null` clears the field.                                                            |

> **Migration note (2026-05)**: the old top-level identity fields (`slackUserId`, `linearUserId`, `githubUsername`, `gitlabUsername`) were removed. Pass identities through the `identities` array instead.

Repository Tools [#repository-tools]

Tools for managing registered repos and their guidelines.

get-repos [#get-repos]

List registered repos with their guidelines (PR checks, merge policy, review guidance).

| Parameter | Type   | Required | Description                                  |
| --------- | ------ | -------- | -------------------------------------------- |
| `name`    | string | No       | Filter by repo name (returns all if omitted) |

update-repo [#update-repo]

Update a repo's configuration including guidelines.

| Parameter       | Type           | Required | Description                                                                                       |
| --------------- | -------------- | -------- | ------------------------------------------------------------------------------------------------- |
| `id`            | string         | Yes      | Repo ID to update                                                                                 |
| `url`           | string         | No       | New repo URL                                                                                      |
| `name`          | string         | No       | New repo name                                                                                     |
| `clonePath`     | string         | No       | New clone path                                                                                    |
| `defaultBranch` | string         | No       | New default branch                                                                                |
| `autoClone`     | boolean        | No       | Whether to auto-clone                                                                             |
| `hooks`         | object \| null | No       | Hook install config; set `{ enabled: true }` to opt into best-effort worker git-hook installation |


# Telemetry (/docs/reference/telemetry)



Agent Swarm includes optional, anonymized telemetry to help the maintainers understand usage patterns and improve the product. Telemetry is **enabled by default** and can be disabled at any time.

What is collected [#what-is-collected]

Telemetry tracks high-level lifecycle and operational events — no task content, prompts, outputs, stderr payloads, or personally identifiable information is ever sent.

| Event                                                         | When it fires                             | Properties                                                                                                              |
| ------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `server.started`                                              | API server boots                          | —                                                                                                                       |
| `task.created`                                                | A new task is created                     | source, hasParent, has\_repo, priority                                                                                  |
| `task.started`                                                | A task is assigned to an agent            | source, agentId                                                                                                         |
| `task.claimed`                                                | An agent auto-claims a task from the pool | source, agentId                                                                                                         |
| `task.completed`                                              | A task finishes successfully              | agentId, durationMs                                                                                                     |
| `task.failed`                                                 | A task fails                              | agentId, durationMs                                                                                                     |
| `task.cancelled`                                              | A task is cancelled                       | source, agentId, previousStatus, durationMs                                                                             |
| `task.session_completed`                                      | A provider session exits for a task       | agentId, provider, model, harnessVariant, harnessVersion, exitCode, isError, durationMs                                 |
| `session.started`                                             | A worker session begins                   | agentId, taskId                                                                                                         |
| `session.ended`                                               | A worker session ends                     | agentId, taskId, durationMs, tasksProcessed                                                                             |
| `session.cost`                                                | A provider reports token/cost usage       | agentId, provider, model, inputTokens, outputTokens, cache/read-write token counters, durationMs, totalCostUsd, isError |
| `session.failure`                                             | A provider session ends with an error     | agentId, provider, model, errorCategory, durationMs, wasRateLimited                                                     |
| `schedule.executed`                                           | A schedule run completes                  | scheduleType, triggeredWorkflows, wasRecovered                                                                          |
| `schedule.error`                                              | A schedule run errors                     | scheduleType, triggeredWorkflows, wasRecovered, consecutiveErrors                                                       |
| `integration.connected`                                       | An integration is connected               | type, provider?, first\_of\_type                                                                                        |
| `workflow.created`                                            | A workflow definition is created          | workflowId, nodeCount, source (when known)                                                                              |
| `workflow.deleted`                                            | A workflow definition is deleted          | workflowId, source (when known)                                                                                         |
| `workflow.started` / `workflow.completed` / `workflow.failed` | Workflow lifecycle transitions            | runId, workflowId, durationMs, executor metadata                                                                        |
| `compaction.triggered`                                        | A harness emits a compaction event        | compactTrigger, preCompactTokens, contextTotalTokens                                                                    |

The exact shape can expand over time as new lifecycle milestones are instrumented, but the contract stays the same: anonymous operational metadata only.

Every event also includes:

* A timestamp (`occurred_at`)
* The source component (`api-server` or `worker`)
* The environment (`production` by default; override with `DESPLEGA_TELEMETRY_ENV`)
* A schema version number
* `is_cloud` — boolean flag indicating whether the event originated from a cloud-hosted swarm or a self-hosted install. Lets the backend partition cloud vs self-hosted traffic without joining to another dimension.
* `has_embedding_key` — boolean, whether an embedding-capable key (`EMBEDDING_API_KEY` or `OPENAI_API_KEY`) is configured, i.e. whether semantic memory search is likely enabled.
* `has_slack_channel` / `has_email_channel` / `has_notification_channel` — booleans indicating whether an outbound notification channel (Slack, AgentMail email, or either) is configured. Presence only — never a token, channel ID, or address.
* `install_method` — enum: `onboard_interactive`, `onboard_noninteractive`, `e2b`, or `manual`. Which entry point produced this install.
* `install_preset` (metadata, optional) — the onboard wizard preset used (`full`, `dev`, `content`, `research`, `solo`, or `custom`), when the install went through the wizard. Allow-listed against the wizard's known preset IDs — any other value (including free text accidentally placed in `INSTALL_PRESET`) is omitted rather than forwarded.
* `install_created_at` (metadata, optional) — ISO timestamp of when this install's identity was first minted, persisted once alongside the installation ID. Only set when this process genuinely mints a new installation ID; an existing installation ID with no stored anchor emits this field as **absent**, not back-filled with the current time — absence means "pre-existing install, anchor unknown". Use `min(occurred_at)` per `installation_id` in ClickHouse as the real anchor for that population.

How it's anonymized [#how-its-anonymized]

* **Installation ID**: Each installation generates a random, opaque identifier (e.g. `install_a1b2c3d4e5f6g7h8`) stored in the `swarm_config` table. This ID cannot be traced back to you or your organization.
* **No content**: Task descriptions, prompts, outputs, agent names, and error messages are never included.
* **No IPs logged**: The telemetry endpoint (`proxy.desplega.sh`) does not store client IP addresses.
* **No auth required**: Events are sent in anonymous actor mode with no authentication headers.

Installation ID mechanism [#installation-id-mechanism]

On first startup, the telemetry module checks `swarm_config` for a `telemetry_installation_id` key:

1. If found, it reuses the existing ID for consistent tracking across restarts.
2. If not found, it generates a new random ID (`install_` + 16 hex characters) and persists it.
3. If config access fails (e.g. worker without DB access), an ephemeral ID (`ephemeral_` prefix) is generated for that session only.

Workers access the installation ID via HTTP (respecting the DB boundary invariant), while the API server reads it directly from the database.

A `telemetry_installed_at` timestamp is minted alongside the installation ID, but only when this process genuinely mints a **new** installation ID — it anchors "when this install came into being" independent of server restarts. An existing installation ID with no stored anchor (an install that predates this field) leaves it unset, and the field is omitted from events rather than back-filled with the current time; use `min(occurred_at)` per `installation_id` in ClickHouse as the real anchor for that population.

How to opt out [#how-to-opt-out]

Set the `ANONYMIZED_TELEMETRY` environment variable to `false`:

```bash title=".env"
ANONYMIZED_TELEMETRY=false
```

For Docker workers, also add it to `.env.docker`:

```bash title=".env.docker"
ANONYMIZED_TELEMETRY=false
```

When disabled:

* No events are sent
* No installation ID is generated or stored
* The telemetry module initializes as a no-op

Technical details [#technical-details]

* **Endpoint**: `POST https://proxy.desplega.sh/v1/events`
* **Timeout**: 5 seconds per request
* **Failure mode**: Fire-and-forget — telemetry never throws exceptions or blocks operations
* **Module**: `src/telemetry.ts` (importable from both API server and workers)

Related [#related]

* [Environment Variables](/docs/reference/environment-variables) — Full reference for `ANONYMIZED_TELEMETRY` and all configuration options
* [Deployment Guide](/docs/guides/deployment) — How to set environment variables in production Docker deployments
* [Architecture Overview](/docs/architecture/overview) — How the API server and workers fit together in the overall system


# The `x` command (/docs/reference/x-command)



The `x` command is the Agent Swarm surface for external command routes:

```bash
agent-swarm x <target> ...
```

The first target is `composio`. The intent is to let humans and agents execute
the same external routes from either the CLI or the swarm MCP server. Today:

* CLI: `agent-swarm x composio <method> <path> [options]`
* MCP: `swarm_x` with `target: "composio"`

Future targets can use the same pattern without becoming top-level CLI
commands.

<Callout type="warn">
  The `swarm_x` MCP tool requires the `swarm-x` capability, which is **disabled by default**. Enable it by setting `CAPABILITIES` on the API server to the full default list plus `swarm-x` (the variable replaces the defaults — it is not additive). The `agent-swarm x` CLI surface is not affected by this flag. See the [environment variables reference](/docs/reference/environment-variables).
</Callout>

Composio [#composio]

Composio routes are HTTP requests to the Composio Tool Router API. The CLI reads
`COMPOSIO_API_KEY` from the environment and sends it as `x-api-key`.

```bash
agent-swarm x composio GET /tools
agent-swarm x composio POST /tool_router/session --body '{"user_id":"swarm-user-id"}'
```

Options:

| Option                    | Description                                               |
| ------------------------- | --------------------------------------------------------- |
| `--body`, `--data <json>` | JSON request body                                         |
| `-q`, `--query k=v`       | Append a query parameter; repeatable                      |
| `-H`, `--header k=v`      | Add a non-auth header; repeatable                         |
| `--base-url <url>`        | Override `COMPOSIO_BASE_URL` or the default v3.1 API base |
| `--org`                   | Use `COMPOSIO_ORG_API_KEY` and `x-org-api-key`            |
| `--raw`                   | Print response text without JSON pretty formatting        |

Environment:

| Variable               | Description                                   |
| ---------------------- | --------------------------------------------- |
| `COMPOSIO_API_KEY`     | Project API key for Composio `x-api-key` auth |
| `COMPOSIO_ORG_API_KEY` | Optional organization key for `--org`         |
| `COMPOSIO_BASE_URL`    | Optional API base URL override                |

Session flow [#session-flow]

Create a session for one app user:

```bash
agent-swarm x composio POST /tool_router/session \
  --body '{"user_id":"swarm-user-id","toolkits":{"enable":["gmail"]},"workbench":{"enable":false}}'
```

Search for the right tool:

```bash
agent-swarm x composio POST /tool_router/session/$SESSION_ID/search \
  --body '{"queries":[{"use_case":"Check recent emails in Gmail and return metadata only."}]}'
```

Connect the toolkit if Composio reports no active connection:

```bash
agent-swarm x composio POST /tool_router/session/$SESSION_ID/execute \
  --body '{"tool_slug":"COMPOSIO_MANAGE_CONNECTIONS","arguments":{"toolkits":["gmail"]}}'
```

Execute after connection:

```bash
agent-swarm x composio POST /tool_router/session/$SESSION_ID/execute \
  --body '{"tool_slug":"GMAIL_FETCH_EMAILS","arguments":{"user_id":"me","max_results":5,"include_payload":false,"verbose":false}}'
```

MCP equivalent [#mcp-equivalent]

Use `swarm_x` when an agent should call the same route through the Agent Swarm
MCP endpoint:

```jsonc
{
  "target": "composio",
  "method": "POST",
  "path": "/tool_router/session/$SESSION_ID/execute",
  "body": {
    "tool_slug": "GMAIL_FETCH_EMAILS",
    "arguments": {
      "user_id": "me",
      "max_results": 5,
      "include_payload": false,
      "verbose": false
    }
  }
}
```

The MCP tool injects the Composio key server-side. It does not accept absolute
Composio paths.

Safety rules [#safety-rules]

* Use relative Composio API paths only, for example `/tool_router/session`.
* Search before execute; use the returned tool slug and schema.
* Use `COMPOSIO_MANAGE_CONNECTIONS` when a toolkit lacks an active connection.
* Prefer metadata-first reads for email and document tools.
* Store Composio `session_id` with the task or conversation when follow-up
  turns should reuse context.

References [#references]

* [Composio sessions](https://docs.composio.dev/tool-router/users-and-sessions)
* [Tool Router session API](https://docs.composio.dev/reference/api-reference/tool-router/postToolRouterSession)
* [Tool Router execute API](https://docs.composio.dev/reference/api-reference/tool-router/postToolRouterSessionBySessionIdExecute)
* [Agent Swarm Composio integration](/docs/integrations/composio)


# Observability Alert Management (/docs/playbooks/code-health-alert-management)



<JsonLd
  data="{
  &#x22;@context&#x22;: &#x22;https://schema.org&#x22;,
  &#x22;@type&#x22;: &#x22;HowTo&#x22;,
  name: &#x22;Wire alerts and code-health scans into an agent swarm&#x22;,
  description:
    &#x22;How to route alerts from Datadog, New Relic, Sentry, and SigNoz into the swarm, filter expected noise, and run scheduled code-health and dependency-upgrade audits.&#x22;,
  step: [
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Wire the webhook&#x22;, text: &#x22;Add a webhook bridge that turns Datadog / New Relic / Sentry / SigNoz alerts into swarm tasks with the right tags.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Triage&#x22;, text: &#x22;Lead agent classifies signal vs. noise using filter rules codified in its playbook.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Route&#x22;, text: &#x22;Real bugs route to a code-capable agent; slow-burn issues become a proposal PR.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Audit&#x22;, text: &#x22;Scheduled audits run daily/weekly: infra triage, workflow health, dependabot triage, harness upgrades, code-health (knip + desloppify).&#x22; }
  ]
}"
/>

Plug your alerting ([Datadog](https://www.datadoghq.com/) / [New Relic](https://newrelic.com/) / [Sentry](https://sentry.io/) / [SigNoz](https://signoz.io/)) into the swarm. Real signals kick off fixes or proposals; noise gets filtered. Daily/weekly health audits catch slow rot.

<Mermaid
  chart="`flowchart LR
AL[&#x22;Alert<br/>Sentry/DD/NR/SigNoz&#x22;] --> LT{&#x22;Lead<br/>signal vs. noise&#x22;}
LT -- noise --> DROP[&#x22;Filter out&#x22;]
LT -- signal --> TI[&#x22;Linear ticket&#x22;]
TI --> CO[&#x22;Coder: fix&#x22;]
CO --> RV[&#x22;Reviewer&#x22;]
SCH[&#x22;Scheduled audits<br/>daily / weekly&#x22;] --> DIG[&#x22;Digests +<br/>bundled fix PRs&#x22;]
`"
/>

What it does [#what-it-does]

Three independent flows:

1. **Reactive** — alert webhooks fire, the lead triages signal vs. noise (some alerts are expected — e.g. customer-test failures shouldn't go to [Sentry](https://sentry.io/)), and routes real bugs to a code-capable agent.
2. **Scheduled audits** — daily infra triage, daily workflow-health audit, weekly code-health scans, weekly dependency-upgrade bundling.
3. **Proposal mode** — for slow-burn issues, the agent opens a PR with a *proposed* fix and a writeup, not a silent merge.

Agents [#agents]

* **[Lead](https://templates.agent-swarm.dev/official/lead)** — triages incoming alerts. First question is always "is this signal?" — every alerting tool has expected noise (we route browser/block-runner errors *away* from [Sentry](https://sentry.io/) because they're customer-test failures, not bugs).
* **[Coder](https://templates.agent-swarm.dev/official/coder)** — implements fixes once triaged.
* **[Reviewer](https://templates.agent-swarm.dev/official/reviewer)** — code review on every alert-driven PR (no auto-merge for incident fixes).
* **[Researcher](https://templates.agent-swarm.dev/official/researcher)** — root-cause investigation when triage isn't obvious.

Tools & Skills [#tools--skills]

Built-in (ships with agent-swarm) [#built-in-ships-with-agent-swarm]

* **`investigate-sentry-issue`** — [Sentry](https://sentry.io/) triage skill. Source: [`plugin/pi-skills/investigate-sentry-issue`](https://github.com/desplega-ai/agent-swarm/tree/main/plugin/pi-skills/investigate-sentry-issue).
* **`slack-post`*&#x2A; (incident comms), **[Linear](https://linear.app) sync** ([ticketing](/docs/integrations/linear)). A Linear ticket created from an alert is auto-picked up by the swarm.

Custom (swarm-managed) [#custom-swarm-managed]

* **`signoz-interaction`** — traces/metrics/logs/alerts from [SigNoz](https://signoz.io/).
* **Host-infrastructure triage skill** — host-level triage (CPU/mem/disk, container counts, collector uptime).
* **Webhook bridge** — small custom integration that turns incoming [Datadog](https://www.datadoghq.com/) / [New Relic](https://newrelic.com/) / [Sentry](https://sentry.io/) / [SigNoz](https://signoz.io/) alerts into swarm tasks with the right tags.
* **Alert filter rules** — codified in the lead's playbook + per-product config: which alert tags are real vs. expected noise.

Third-party providers (popular tools we use) [#third-party-providers-popular-tools-we-use]

* **[desloppify](https://github.com/peteromallet/desloppify)** — open-source multi-language codebase health scanner (29 languages, tree-sitter AST analysis, gameable-resistant scoring). We wrap it with a small swarm skill that runs it in a sandboxed [sprite](https://sprites.dev/).
* **[knip](https://knip.dev/)** — open-source dead-code detector for JavaScript/TypeScript projects. We chain it into the weekly code-health workflow.

Workflows / Schedules [#workflows--schedules]

* **`daily-infra-morning-triage`** — daily. [SigNoz](https://signoz.io/)-driven checklist (alert fires + resolutions, host peaks, collector uptime, container counts, metric volume). Posts a tight digest. &#x2A;*Observation-only.**
* **`daily-workflow-health-audit`** — daily. Surfaces hard failures, halted runs, silent empty-output completions, cron-stuck schedules, consecutive-error schedules. One digest.
* **`daily-blocker-digest`** — daily. Verifies every PR/issue reference in the operational runbook is still open; flags merged-but-still-listed items.
* **`weekly-dependabot-triage`** — weekly. Closes out-of-scope dependabot PRs, bundles in-scope upgrades into one unified PR per path.
* **`weekly-harness-upgrade-check`** — weekly. Checks worker-image harness versions vs. upstream, opens ONE bundled PR.
* **`weekly-code-health`** — weekly per repo. Runs [knip](https://knip.dev/) + [desloppify](https://github.com/peteromallet/desloppify) in a sandboxed [sprite](https://sprites.dev/), evaluates top-5 concerns, drain-loops one PR per concern with an internal reviewer, hands the stack to humans.
* **`monthly-infra-cleanup`** — monthly. Docker disk-cleanup audit. **Confirmation-gated** — posts findings + recommendation, waits for human approval before any prune. Never auto-prunes.

Patterns used [#patterns-used]

* [**Drain Loops**](/docs/playbooks/patterns/drain-loops) — weekly code-health turns top-N concerns into one PR each.
* [**HITL Gates**](/docs/playbooks/patterns/hitl-gates) — destructive ops (disk prunes) wait for a human approval.
* [**No-op When Nothing Changed**](/docs/playbooks/patterns/no-op-workflows) — audits that skip silently on a quiet day.

Tips for new swarm users [#tips-for-new-swarm-users]

* **Filter alerts before letting agents act.** Codify "these tags are not real bugs" in your lead's playbook *first*. Acting on every alert is how you generate PR spam.
* **Bundle low-significance fixes** (dependabot, harness upgrades) into one weekly PR — fewer reviews, less churn, easier rollback.
* **Track a health score over time**, not just one-shot scans. The trendline tells you if you're winning the code-rot fight.
* **Confirmation-gate destructive ops** (disk prunes, branch deletions, force-pushes) behind a Slack approval.
* **Read failure reasons across runs.** A `failureReason` on every failed task lets you cluster `(agent, error)` pairs and tell provider-health issues from routing issues from genuine bugs.


# Code Health (/docs/playbooks/code-health-reports)



<JsonLd
  data="{
  &#x22;@context&#x22;: &#x22;https://schema.org&#x22;,
  &#x22;@type&#x22;: &#x22;HowTo&#x22;,
  name: &#x22;Run recurring Code Maat and D3 code-health reports with an agent swarm&#x22;,
  description:
    &#x22;How to bootstrap a recurring Code Maat + D3.js report for any Git repository, publish it as a stable Page, and refresh it on a weekly schedule.&#x22;,
  step: [
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Choose repository and scope&#x22;, text: &#x22;Pick a repo URL, branch, report slug, and path scope such as src or packages/core/src.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Install the community template&#x22;, text: &#x22;Copy run.sh, report.mjs, and lead-prompt.md from templates/community/code-health-reports into a workspace outside the target repository.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Run once manually&#x22;, text: &#x22;The runner installs Java, Node, Lizard, and the Code Maat standalone JAR when missing, then generates CSV metrics and a static D3 report.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Publish a stable report Page&#x22;, text: &#x22;Create the Page once from latest.html, save the Page ID, and update that same Page on every refresh.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Schedule weekly refreshes&#x22;, text: &#x22;Use the weekly-code-health-reports schedule template. The default cron is 0 21 * * 0 in UTC.&#x22; }
  ]
}"
/>

This is the Code Health home for recurring repository analysis. The main flow is the **reports-on-autopilot** path for Code Maat + D3.js; the alerting-specific playbook is now focused on [Observability Alert Management](/docs/playbooks/code-health-alert-management).

<Mermaid
  chart="`flowchart LR
LP[&#x22;Lead prompt<br/>repo + branch + scope&#x22;] --> WR[&#x22;Worker workspace<br/>/workspace/code-maat&#x22;]
WR --> CM[&#x22;Code Maat<br/>history CSVs&#x22;]
WR --> LZ[&#x22;Lizard<br/>complexity CSV&#x22;]
CM --> RP[&#x22;report.mjs<br/>join + score&#x22;]
LZ --> RP
RP --> PG[&#x22;Stable Page<br/>D3 report&#x22;]
SCH[&#x22;Cron<br/>0 21 * * 0&#x22;] --> WR
SCH --> PG
`"
/>

What it does [#what-it-does]

The swarm creates a stable code-health report page for one repository:

* **Hotspots** — files with high change frequency and complexity.
* **Temporal coupling** — files that tend to change together.
* **Code age** — how recently scoped files changed.
* **Ownership** — who has historically contributed the most to each file.
* **Weekly refresh** — the same Page URL updates in place, so links remain stable.

The first run installs or downloads what it needs. You do not pre-install Code Maat or D3.

Agents [#agents]

* **[Lead](https://templates.agent-swarm.dev/official/lead)** — collects the repository URL, branch, path scope, report slug, cadence, and stable Page behavior; creates or configures the weekly schedule.
* **[Coder](https://templates.agent-swarm.dev/official/coder)** — installs the runner, runs the first report, fixes local runner/report-generator issues, and updates the stable Page.
* **[Reviewer](https://templates.agent-swarm.dev/official/reviewer)** — optional, used when a runner change needs a PR before the scheduled job can keep running cleanly.

Tools & Skills [#tools--skills]

Built-in (ships with agent-swarm) [#built-in-ships-with-agent-swarm]

* **[Pages](/docs/reference/mcp-tools#pages-tools)** — hosts the generated static report HTML at a stable URL.
* **`store-progress`** — records the stable Page URL, analyzed commit, report workspace, and any dependency/chart-rendering issue.
* **`slack-reply`** — posts the report URL and weekly-refresh status back to the requesting thread.

Community template [#community-template]

* **[`templates/community/code-health-reports`](https://github.com/desplega-ai/agent-swarm/tree/main/templates/community/code-health-reports)** — the reusable package with:
  * `PLAYBOOK.md` — the full drop-in playbook.
  * `run.sh` — parameterized runner.
  * `report.mjs` — static report generator.
  * `lead-prompt.md` — copy-paste kickoff prompt for a Lead agent.

Third-party tools [#third-party-tools]

* **[Code Maat](https://github.com/adamtornhill/code-maat)** — Adam Tornhill's command-line tool for mining version-control history.
* **[D3.js](https://d3js.org)** — browser-side charts. Generated reports load D3 v7 from jsDelivr at render time; there is no front-end build step.
* **[Lizard](https://github.com/terryyin/lizard)** — cyclomatic complexity analyzer used to add a complexity axis to history metrics.
* **Java runtime** — required by the Code Maat standalone Clojure JAR.

Workflows / Schedules [#workflows--schedules]

* **[`weekly-code-health-reports`](https://templates.agent-swarm.dev/schedules/weekly-code-health-reports)** — weekly by default. Installs or updates the runner workspace, executes the Code Maat + Lizard analysis, generates `latest.html` and `latest.json`, updates the same stable Page ID, then verifies the D3 charts render.

<Callout type="info">
  Small static-scan companion: `weekly-code-health` runs knip + desloppify and routes the top concerns into focused follow-up work.
</Callout>

Default cadence:

```yaml
cron: "0 21 * * 0"
timezone: "UTC"
```

That means weekly on Sunday at 21:00 UTC. Change the `cron` field to adjust the refresh time, and change `timezone` if you want the cron interpreted in another zone:

```yaml
# Every Monday at 09:00 Europe/Madrid
cron: "0 9 * * 1"
timezone: "Europe/Madrid"
```

The cadence is separate from page identity. Changing the cron only changes when the report refreshes. Keep updating the same stable Page ID in place.

Template install shape [#template-install-shape]

Use a workspace outside the target repository so report artifacts and downloaded tools do not pollute the codebase:

```bash
mkdir -p /workspace/code-maat
cp templates/community/code-health-reports/run.sh /workspace/code-maat/
cp templates/community/code-health-reports/report.mjs /workspace/code-maat/
cp templates/community/code-health-reports/lead-prompt.md /workspace/code-maat/
chmod +x /workspace/code-maat/run.sh
```

Parameterize each run with environment variables:

```bash
BASE_DIR=/workspace/code-maat \
REPO_NAME=my-repo \
REPO_URL=https://github.com/OWNER/REPO.git \
BRANCH=main \
SCOPE_PATH=src \
bash /workspace/code-maat/run.sh
```

The runner creates this shape:

```text
/workspace/code-maat/
  run.sh
  report.mjs
  lead-prompt.md
  code-maat.jar
  repos/
    <repo-name>/
  out/
    <repo-name>/
      <YYYY-MM-DD>/
        revisions.csv
        coupling.csv
        age.csv
        authors.csv
        entity-ownership.csv
        main-dev.csv
        abs-churn.csv
        entity-churn.csv
        lizard-functions.csv
        summary.json
        report.html
      latest.json
      latest.html
      latest-pointer.json
```

Runner behavior [#runner-behavior]

`run.sh` does the following:

* Installs `default-jre-headless` if `java` is missing.
* Installs `nodejs` if `node` is missing.
* Installs Python and `lizard` if Lizard is missing.
* Downloads Code Maat v1.0.4 standalone JAR into `BASE_DIR` if missing.
* Clones the target repository into a scratch directory.
* Disables the scratch clone push URL so the scheduled job cannot push accidentally.
* Generates Code Maat CSVs and a Lizard CSV.
* Runs `report.mjs`.
* Copies the latest artifacts to stable `latest.html`, `latest.json`, and `latest-pointer.json` paths.

It generates the git log with:

```bash
git -C "$REPO_DIR" log --all --numstat --date=short --pretty=format:'--%h--%ad--%aN' --no-renames -- "$SCOPE_PATH"
```

It runs these Code Maat analyses:

```text
summary
revisions
coupling
age
authors
entity-ownership
entity-effort
main-dev
main-dev-by-revs
abs-churn
author-churn
entity-churn
```

Then it runs Lizard over the scoped path and writes the static D3 report.

Report generator [#report-generator]

`report.mjs` parses the Code Maat and Lizard CSVs, joins historical metrics to current file LOC, computes a hotspot score, and embeds the final data in a static HTML file.

The generator interface:

```bash
node /workspace/code-maat/report.mjs \
  /workspace/code-maat/out/<repo-name>/<YYYY-MM-DD> \
  /workspace/code-maat/repos/<repo-name> \
  <repo-name> \
  <YYYY-MM-DD> \
  <SCOPE_PATH>
```

The default hotspot score is:

```text
risk score = revisions * log2(total cyclomatic complexity + 1)
```

The generated report includes:

* Hotspot bubble chart.
* Change-frequency x complexity scatter.
* Top hotspot table.
* Temporal coupling table.
* Code age distribution.
* D3 v7 loaded from CDN at view time.

Patterns used [#patterns-used]

* [**No-op When Nothing Changed**](/docs/playbooks/patterns/no-op-workflows) — keep the schedule quiet if a run determines there is no meaningful new report to publish.
* [**HITL Gates**](/docs/playbooks/patterns/hitl-gates) — use a human approval gate before widening scope, changing cadence, or turning report findings into repair PRs.

Tips for new swarm users [#tips-for-new-swarm-users]

* **Start with `src`.** It avoids docs, package metadata, generated files, and examples. For monorepos, use a narrower scope such as `apps/web/src` or `packages/core/src`.
* **Keep the workspace outside the target repo.** `/workspace/code-maat` keeps reports and downloaded tools out of commits.
* **Save the Page ID immediately.** The value of this report is the stable URL. Create once, then update in place.
* **Use a code-capable worker.** Scheduled jobs sometimes need to repair the runner when upstream dependencies, branch names, or page APIs change.
* **Treat the report as a map, not a verdict.** Hotspots identify where to inspect first; they do not automatically mean a file is bad.
* **Do not vendor Code Maat casually.** This template downloads it at runtime so adopters avoid accidentally redistributing a GPLv3 JAR in their own bundle.

References [#references]

* [D3.js](https://d3js.org): JavaScript library used for the browser-side charts.
* [D3 getting started](https://d3js.org/getting-started): D3 documentation for loading and using the library.
* [Code Maat](https://github.com/adamtornhill/code-maat): Adam Tornhill's version-control mining tool used for revisions, coupling, age, and ownership metrics.
* [Code Maat analyses API index](https://cljdoc.org/d/code-maat/code-maat/1.0.1/api/code-maat.analysis): reference list for Code Maat analyses.
* [Code Maat distribution notes](https://adamtornhill.com/code/maatdistro.htm): upstream distribution page for standalone Code Maat usage.
* [Adam Tornhill](https://www.adamtornhill.com): creator of Code Maat and author of the behavioral-code-analysis framing used here.
* [Your Code as a Crime Scene](https://pragprog.com/titles/atcrime/your-code-as-a-crime-scene/): Adam Tornhill's book on hotspots, temporal coupling, code age, and social code analysis.
* [Maat D3 scripts](https://github.com/adamtornhill/maat-scripts): Adam Tornhill's D3 visualization scripts for Code Maat data, including the canonical enclosure diagram lineage.
* [Lizard](https://github.com/terryyin/lizard): complexity analyzer used here to add per-file cyclomatic complexity.
* [Code Maat GPLv3 license](https://github.com/adamtornhill/code-maat): Code Maat is GPLv3. This template does not vendor or redistribute Code Maat. It downloads the upstream standalone JAR at runtime on first run. If you choose to distribute the JAR yourself, follow GPLv3 distribution obligations, including source and license notice requirements.


# Content Generation (/docs/playbooks/content-generation)



<JsonLd
  data="{
  &#x22;@context&#x22;: &#x22;https://schema.org&#x22;,
  &#x22;@type&#x22;: &#x22;HowTo&#x22;,
  name: &#x22;Run an analytics-driven content engine with quality gates&#x22;,
  description:
    &#x22;How to combine Search Console topic mining, a writer agent, a different-model-family judge agent, and human craft to ship content that ranks.&#x22;,
  step: [
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Mine topics&#x22;, text: &#x22;Pull high-signal topics from Search Console, label and rank them, and write to a topic-supply table.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Pick next topic&#x22;, text: &#x22;Content Strategist selects the next topic using a coverage cursor that avoids duplicates.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Draft&#x22;, text: &#x22;Content Writer drafts the post (TSX component) and includes schema markup.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Litmus review&#x22;, text: &#x22;Content Reviewer (different model family) scores Depth, Code Quality, Structure, SEO, Voice & Tone, AEO. Hard-rejects sub-bar drafts.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Human craft&#x22;, text: &#x22;Human reviewer tailors, edits, or gives feedback on the draft before publish.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Publish + AEO&#x22;, text: &#x22;Open PR, publish; Discoverability Optimizer audits schema markup and acceptmarkdown compliance.&#x22; }
  ]
}"
/>

Data-driven content production with quality gates that reject low-depth content before it ships.

<Mermaid
  chart="`flowchart LR
TM[&#x22;gsc-topic-miner<br/>(weekly)&#x22;] --> TS[(&#x22;topic supply<br/>table&#x22;)]
TS --> CS[&#x22;Content Strategist<br/>pick next topic&#x22;]
CS --> CW[&#x22;Content Writer<br/>draft TSX&#x22;]
CW --> CR{&#x22;Content Reviewer<br/>litmus (diff. model)&#x22;}
CR -- reject --> CW
CR -- pass --> HR[&#x22;Human craft<br/>(tailor / edit / feedback)&#x22;]
HR --> PR[&#x22;PR + publish&#x22;]
PR --> DO[&#x22;Discoverability Optimizer<br/>schema / AEO&#x22;]
`"
/>

What it does [#what-it-does]

A topic miner pulls high-signal topics from your search analytics, a strategist picks what to write next (avoiding duplication), a writer drafts the post, a separate LLM-as-judge reviewer hard-rejects low-quality drafts, **and then a human reviews, tailors, or gives feedback before anything goes live** — *all* content is human-crafted or human-approved before release. Memes are queued for review and tailoring before LinkedIn. Daily X/Twitter cadence is automated with the same craft step. Comparison and how-to pages are auto-generated from search gaps.

Agents [#agents]

* **[Content Strategist](https://templates.agent-swarm.dev/official/content-strategist)** — analytics-driven topic selection ([Plausible](https://plausible.io/) + [Google Search Console](https://search.google.com/search-console)), content calendar, performance calibration.
* **[Content Writer](https://templates.agent-swarm.dev/official/content-writer)** — drafts posts in TSX (a `BlogArticle` component pattern), generates memes, owns voice and tone.
* **[Content Reviewer](https://templates.agent-swarm.dev/official/content-reviewer)** — LLM-as-judge quality gate scored across Depth, Code Quality, Structure, SEO, Voice & Tone, Readability/AEO. **Uses a different model family than the writer** so the review is genuinely independent.
* **[Discoverability Optimizer](https://templates.agent-swarm.dev/official/discoverability-optimizer)** — SEO/AEO/schema-markup specialist; owns `/md/` + `/llms.txt` ([acceptmarkdown](https://acceptmarkdown.com/)) compliance and structured-data wins.

Tools & Skills [#tools--skills]

Built-in (ships with agent-swarm) [#built-in-ships-with-agent-swarm]

* **[agent-fs](https://github.com/desplega-ai/agent-fs)** (drafts/research), **swarm KV** (content-state cursors), **scheduled workflows*&#x2A; (cadence), &#x2A;*`slack-post`** (visibility).

Custom (swarm-managed) [#custom-swarm-managed]

* **`meme-creation`** — [imgflip](https://imgflip.com/) catalog with cooldown dedup.
* **`gsc-analytics`** — [Google Search Console](https://search.google.com/search-console) queries (top queries/pages, WoW delta).
* **`acceptmarkdown-compliance`** — verifies `/md/` + `/llms.txt` content negotiation per [acceptmarkdown.com](https://acceptmarkdown.com/).
* **`x-posting-guidelines`, `x-api-interactions`** — brand voice + [X API](https://developer.x.com/) posting.
* **Brand-voice skill** — deployment-specific voice, positioning, and quote-bank anchors.
* **Tool-creation workflow** — when a topic miner surfaces a recurring need (e.g. "find comparable companies for outreach"), a meta-workflow spins up a new swarm-managed skill rather than inlining ad-hoc HTTP calls. Keeps the catalog growing in lockstep with the work.

Third-party providers [#third-party-providers]

* **State / cache** — we use [Turso](https://turso.tech/) for the `content-state` LibSQL DB (topic supply, dispatch history, cooldowns).
* **Social scheduler** — we use [Buffer](https://buffer.com/) for the LinkedIn queue.
* **[imgflip](https://imgflip.com/) + [X](https://x.com/) APIs** — meme generation + posting.

Workflows / Schedules [#workflows--schedules]

* **`gsc-topic-miner`** — weekly. 6 sources → label-and-enrich (multi-label) → bucket-and-rank (8 buckets × 10, target 80) → write to topic supply table. Downstream generators pull from the table.
* **`unified-daily-blog`** — Tue + Thu. One context-builder fans out to 3 parallel series branches, each producing a post with research, litmus tests, image generation, and TSX assembly; converges at a merge step.
* **`agent-swarm-blog`** — Mon + Wed. Agent-swarm learnings blog; litmus pushes for surprising/counterintuitive angles.
* **`how-to-generator-with-schema`** — single-shot how-to page generator. Litmus **hard-rejects** drafts with missing/empty `HowToSchema` — structured data is what drives the lift.
* **`competitor-page-generator`*&#x2A; + &#x2A;*`competitor-radar`** — radar scans search analytics for competitor-brand queries without a matching `/competitor/alternatives/{brand}` page; dispatches top gaps to the generator.
* **`daily-meme-tweet`** — daily [X/Twitter](https://x.com/) meme. No hashtags.
* **`weekly-meme-linkedin`** — stages weekly memes in [Buffer](https://buffer.com/) for human review and tailoring before publish.
* **`weekly-new-releases`** — release-notes blog post from product-repo commits.
* **`new-skill-bootstrap`** — meta-workflow: takes a new-skill spec from a content gap and scaffolds it into the swarm's skill registry.

Patterns used [#patterns-used]

* [**Litmus Tests**](/docs/playbooks/patterns/litmus-tests) — a different-model-family judge hard-rejects sub-bar drafts before publish.
* [**HITL Gates**](/docs/playbooks/patterns/hitl-gates) — every piece of content goes through human craft, tailoring, or feedback before release.

Tips for new swarm users [#tips-for-new-swarm-users]

* **Litmus tests are the unlock.** Use a *different model family* for the reviewer than the writer — same-family review is rubber-stamping in disguise.
* **Schema markup compounds.** Don't skip it. Page-level structured data (HowTo, FAQ, Article, BreadcrumbList) is the difference between a page that ranks and one that doesn't.
* **Choose topics by data, not gut.** A topic miner backed by real search analytics surfaces high-intent topics your team would never brainstorm.
* **Avoid duplication** with a topic-coverage cursor (KV or DB).
* **Queue social content, don't auto-post.** Stage in a [Buffer](https://buffer.com/)-style queue with human review days before go-live. Recall is cheaper than apology.


# DORA Metrics (/docs/playbooks/dora-metrics)



<JsonLd
  data="{
  &#x22;@context&#x22;: &#x22;https://schema.org&#x22;,
  &#x22;@type&#x22;: &#x22;HowTo&#x22;,
  name: &#x22;Run recurring DORA metrics reports with an agent swarm&#x22;,
  description:
    &#x22;How to bootstrap a recurring DORA metrics report for a Git repository, publish it as a stable Page, and refresh it on a weekly schedule.&#x22;,
  step: [
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Confirm deployment signal&#x22;, text: &#x22;Verify that the configured release tag pattern, such as v*, maps one-to-one to production deployments.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Install the community template&#x22;, text: &#x22;Copy run.sh, report.mjs, and lead-prompt.md from templates/community/dora-metrics into a workspace outside the target repository.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Run once manually&#x22;, text: &#x22;The runner clones the repo, disables push, fetches release tags, collects commit and optional PR metadata, then generates a static DORA report.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Publish a stable report Page&#x22;, text: &#x22;Create the Page once from latest.html, save the Page ID, and update that same Page on every refresh.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Schedule weekly refreshes&#x22;, text: &#x22;Use the weekly-dora-metrics schedule template. The default cron is 0 22 * * 0 in UTC.&#x22; }
  ]
}"
/>

This is the DORA Metrics home for recurring software-delivery performance reports. It mirrors the Code Health report pattern: a small runner, a static D3 report, and one stable Page URL that updates in place.

<Mermaid
  chart="`flowchart LR
LP[&#x22;Lead prompt<br/>repo + branch + tag pattern&#x22;] --> WR[&#x22;Worker workspace<br/>/workspace/dora-metrics&#x22;]
WR --> GT[&#x22;Git tags<br/>deployment events&#x22;]
WR --> GC[&#x22;Git commits<br/>lead-time samples&#x22;]
WR --> PR[&#x22;PR/commit titles<br/>proxy failure signals&#x22;]
GT --> RP[&#x22;report.mjs<br/>DORA metrics&#x22;]
GC --> RP
PR --> RP
RP --> PG[&#x22;Stable Page<br/>D3 report&#x22;]
SCH[&#x22;Cron<br/>0 22 * * 0&#x22;] --> WR
SCH --> PG
`"
/>

What it does [#what-it-does]

The swarm creates a stable DORA report page for one repository:

* **Deployment Frequency** — exact count and rate from release tags in the configured window.
* **Lead Time for Changes** — exact median time from commit timestamp to the release tag that deployed it.
* **Change Failure Rate** — proxy estimate from revert, rollback, hotfix, and fix-forward signals near releases.
* **Failed Deployment Recovery Time** — proxy estimate from the failed-release tag to the fixing tag.
* **Weekly refresh** — the same Page URL updates in place, so links remain stable.

The exact/proxy distinction is mandatory. CFR and recovery time should not be presented as precise incident metrics unless you connect a formal incident source.

Agents [#agents]

* **[Lead](https://templates.agent-swarm.dev/official/lead)** — collects the repository URL, branch, release tag pattern, report slug, cadence, stable Page behavior, and deployment-signal assumption.
* **[Coder](https://templates.agent-swarm.dev/official/coder)** — installs the runner, runs the first report, fixes local runner/report-generator issues, and updates the stable Page.
* **[Reviewer](https://templates.agent-swarm.dev/official/reviewer)** — optional, used when a runner change needs a PR before the scheduled job can keep running cleanly.

Tools & Skills [#tools--skills]

Built-in (ships with agent-swarm) [#built-in-ships-with-agent-swarm]

* **[Pages](/docs/reference/mcp-tools#pages-tools)** — hosts the generated static report HTML at a stable URL.
* **`store-progress`** — records the stable Page URL, analyzed commit, report workspace, exact/proxy caveat, and any dependency/chart-rendering issue.
* **`slack-reply`** — posts the report URL and weekly-refresh status back to the requesting thread.

Community template [#community-template]

* **[`templates/community/dora-metrics`](https://github.com/desplega-ai/agent-swarm/tree/main/templates/community/dora-metrics)** — the reusable package with:
  * `PLAYBOOK.md` — the full drop-in playbook.
  * `run.sh` — parameterized runner.
  * `report.mjs` — static report generator.
  * `lead-prompt.md` — copy-paste kickoff prompt for a Lead agent.

Third-party tools [#third-party-tools]

* **Git** — release tags and commit timestamps are the exact data source for Deployment Frequency and Lead Time for Changes.
* **[GitHub CLI](https://cli.github.com/manual/)** — optional PR metadata source used to enrich hotfix/revert proxy detection.
* **jq** — required utility dependency for predictable JSON handling in the runner environment.
* **[D3.js](https://d3js.org)** — browser-side charts. Generated reports load D3 v7 from jsDelivr at render time; there is no front-end build step.

Workflows / Schedules [#workflows--schedules]

* **[`weekly-dora-metrics`](https://templates.agent-swarm.dev/schedules/weekly-dora-metrics)** — weekly by default. Installs or updates the runner workspace, executes the DORA analysis, generates `latest.html` and `latest.json`, updates the same stable Page ID, then verifies the D3 charts and exact/proxy labels render.

Default cadence:

```yaml
cron: "0 22 * * 0"
timezone: "UTC"
```

That means weekly on Sunday at 22:00 UTC. Change the `cron` field to adjust the refresh time, and change `timezone` if you want the cron interpreted in another zone:

```yaml
# Every Monday at 09:00 Europe/Madrid
cron: "0 9 * * 1"
timezone: "Europe/Madrid"
```

The cadence is separate from page identity. Changing the cron only changes when the report refreshes. Keep updating the same stable Page ID in place.

Template install shape [#template-install-shape]

Use a workspace outside the target repository so report artifacts do not pollute the codebase:

```bash
mkdir -p /workspace/dora-metrics
cp templates/community/dora-metrics/run.sh /workspace/dora-metrics/
cp templates/community/dora-metrics/report.mjs /workspace/dora-metrics/
cp templates/community/dora-metrics/lead-prompt.md /workspace/dora-metrics/
chmod +x /workspace/dora-metrics/run.sh
```

Parameterize each run with environment variables:

```bash
BASE_DIR=/workspace/dora-metrics \
REPO_NAME=my-repo \
REPO_URL=https://github.com/OWNER/REPO.git \
BRANCH=main \
TAG_PATTERN='v*' \
WINDOW_DAYS=90 \
bash /workspace/dora-metrics/run.sh
```

The runner creates this shape:

```text
/workspace/dora-metrics/
  run.sh
  report.mjs
  lead-prompt.md
  repos/
    <repo-name>/
  out/
    <repo-name>/
      <YYYY-MM-DD>/
        tags.tsv
        recent-commits.tsv
        remediation-commits.tsv
        prs.json
        revision.txt
        revision-summary.txt
        summary.json
        report.html
      latest.json
      latest.html
      latest-pointer.json
```

Runner behavior [#runner-behavior]

`run.sh` does the following:

* Installs `git`, `jq`, and `nodejs` if missing.
* Clones the target repository into a scratch directory.
* Disables the scratch clone push URL so the scheduled job cannot push accidentally.
* Fetches the configured branch and release tags matching `TAG_PATTERN`.
* Writes release tag, recent commit, remediation commit, and optional PR metadata.
* Runs `report.mjs`.
* Copies the latest artifacts to stable `latest.html`, `latest.json`, and `latest-pointer.json` paths.

It extracts release tags with:

```bash
git -C "$REPO_DIR" for-each-ref "refs/tags/$TAG_PATTERN" --sort=creatordate --format='%(refname:short)%09%(objectname)%09%(creatordate:iso-strict)%09%(creatordate:unix)'
```

It extracts remediation signals with:

```bash
git -C "$REPO_DIR" log "origin/$BRANCH" --since="$WINDOW_DAYS days ago" --grep='revert' --grep='rollback' --grep='hotfix' --grep='fix-forward' --regexp-ignore-case
```

When authenticated, it also asks `gh pr list` for merged PR titles so hotfix-style PRs can contribute to the proxy stability keys.

Report generator [#report-generator]

`report.mjs` parses the runner outputs, computes the four metrics, and embeds the final data in a static HTML file.

The generator interface:

```bash
node /workspace/dora-metrics/report.mjs \
  /workspace/dora-metrics/out/<repo-name>/<YYYY-MM-DD> \
  /workspace/dora-metrics/repos/<repo-name> \
  <repo-name> \
  <YYYY-MM-DD> \
  <BRANCH> \
  <WINDOW_DAYS> \
  <HOTFIX_WINDOW_HOURS> \
  <TAG_PATTERN>
```

The generated report includes:

* Four DORA metric cards.
* `EXACT` / `PROXY` quality labels.
* Deployment and lead-time charts.
* Recent deployments table.
* Proxy failure signal table.
* D3 v7 loaded from CDN at view time.

Data-source reality check [#data-source-reality-check]

| Metric                          | Source                                                              | Quality | Caveat                                                                    |
| ------------------------------- | ------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------- |
| Deployment Frequency            | Release tags matching `TAG_PATTERN`                                 | EXACT   | Exact only when those tags are production deployments.                    |
| Lead Time for Changes           | Commit timestamp to containing release tag timestamp                | EXACT   | Uses non-merge commits included between adjacent release tags.            |
| Change Failure Rate             | Releases paired to revert, rollback, hotfix, or fix-forward signals | PROXY   | Can miss manual incidents and can include non-incident remediation work.  |
| Failed Deployment Recovery Time | Failed-release proxy tag to fixing tag                              | PROXY   | Release-to-release gap is not the same as incident open-to-resolved time. |

Use these proxy keys for trend watching and executive conversation starters. Use a formal incident tracker or production deployment event stream before treating CFR and recovery time as operational truth.

Patterns used [#patterns-used]

* [**No-op When Nothing Changed**](/docs/playbooks/patterns/no-op-workflows) — keep the schedule quiet if a run determines there is no meaningful new report to publish.
* [**HITL Gates**](/docs/playbooks/patterns/hitl-gates) — use a human approval gate before changing the deployment signal, widening scope, or treating proxy findings as incident facts.

Tips for new swarm users [#tips-for-new-swarm-users]

* **Confirm the deployment event first.** The default `v*` tag pattern is only correct for repositories where those tags mean production release.
* **Keep the workspace outside the target repo.** `/workspace/dora-metrics` keeps reports and scratch clones out of commits.
* **Save the Page ID immediately.** The value of this report is the stable URL. Create once, then update in place.
* **Use a code-capable worker.** Scheduled jobs sometimes need to repair the runner when repo conventions, branch names, tag patterns, or page APIs change.
* **Do not overclaim CFR or recovery time.** Until incident data exists, those are proxy estimates by design.

References [#references]

* [DORA metrics guide](https://dora.dev/guides/dora-metrics/): official definitions for Deployment Frequency, Lead Time for Changes, Change Failure Rate, and Failed Deployment Recovery Time.
* [2024 Accelerate State of DevOps Report](https://dora.dev/research/2024/dora-report/): 2024 performance bands and cluster-analysis context.
* [DORA research program](https://dora.dev/research/): background on the annual DORA reports and metric evolution.
* [GitHub CLI manual](https://cli.github.com/manual/): optional PR metadata source used to enrich hotfix/revert proxy detection.
* [D3.js](https://d3js.org): JavaScript library used for the browser-side charts.


# Feature Development (/docs/playbooks/feature-development)



<JsonLd
  data="{
  &#x22;@context&#x22;: &#x22;https://schema.org&#x22;,
  &#x22;@type&#x22;: &#x22;HowTo&#x22;,
  name: &#x22;Run feature development through an agent swarm&#x22;,
  description:
    &#x22;How to wire Slack and Linear into an agent swarm so feature requests become pull requests via research, plan, implementation, and review stages.&#x22;,
  totalTime: &#x22;PT2H&#x22;,
  step: [
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Triage&#x22;, text: &#x22;Lead agent picks up the Slack ping or Linear ticket and decides whether research, plan, or direct implementation is needed.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Research&#x22;, text: &#x22;Researcher agent persists findings in agent-fs.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Plan&#x22;, text: &#x22;Plan node turns findings into an implementation plan.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Implement&#x22;, text: &#x22;Coder agent opens a PR with conventional commits and triggers CI.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Review&#x22;, text: &#x22;Reviewer agent reviews the PR with gh or glab CLI; merges (squash) when approved and CI is green; transitions the Linear sub-issue to Done.&#x22; }
  ]
}"
/>

Take a feature request from [Slack](https://slack.com) or a [Linear](https://linear.app) ticket and turn it into a merged pull request without losing context across the handoffs.

<Mermaid
  chart="`flowchart LR
S[&#x22;Slack request / Linear ticket&#x22;] --> L[&#x22;Lead<br/>triage + route&#x22;]
L --> R[&#x22;Researcher<br/>research&#x22;]
R --> P[&#x22;Plan<br/>implementation plan&#x22;]
P --> I[&#x22;Coder<br/>implement + open PR&#x22;]
I --> V{&#x22;Reviewer<br/>review PR&#x22;}
V -- &#x22;approved + CI green&#x22; --> M[&#x22;Merge (squash)<br/>Linear → Done&#x22;]
V -- &#x22;changes requested&#x22; --> I
`"
/>

What it does [#what-it-does]

A request arrives in Slack or as a [Linear](https://linear.app) ticket. The [lead agent](https://templates.agent-swarm.dev/official/lead) triages, kicks off research, drafts a plan, ships an implementation PR, waits for CI, and routes a critical review — all without humans hand-walking the context across steps.

Agents [#agents]

* **[Lead](https://templates.agent-swarm.dev/official/lead)** — orchestrator. Picks up the ping, decides whether a research/plan step is needed (complex feature) or direct implementation suffices (bug fix / small change), and routes the work.
* **[Researcher](https://templates.agent-swarm.dev/official/researcher)** — deep codebase research and analysis. Outputs structured findings into [agent-fs](https://github.com/desplega-ai/agent-fs) so downstream agents can pull them.
* **[Coder](https://templates.agent-swarm.dev/official/coder)** — turns the plan into a working PR using git worktrees, conventional commits, and the repo's existing test discipline.
* **[Reviewer](https://templates.agent-swarm.dev/official/reviewer)** — critical PR reviewer that doesn't rubber-stamp. Uses [`gh`](https://cli.github.com/) / [`glab`](https://gitlab.com/gitlab-org/cli) CLI for inline comments and review submissions.

Tools & Skills [#tools--skills]

Built-in (ships with agent-swarm) [#built-in-ships-with-agent-swarm]

* **[Slack](https://slack.com), [Linear](https://linear.app), [GitHub](https://github.com), [GitLab](https://gitlab.com) integrations** — read/reply/post, [inbound Linear AgentSession sync](/docs/integrations/linear), [`gh`](https://cli.github.com/) / [`glab`](https://gitlab.com/gitlab-org/cli) CLI in every worker container.
* **`implement-issue`, `review-pr`, `respond-github`, `create-pr`** — built-in skills. Source: [`plugin/commands`](https://github.com/desplega-ai/agent-swarm/tree/main/plugin/commands) + [`plugin/pi-skills`](https://github.com/desplega-ai/agent-swarm/tree/main/plugin/pi-skills).
* **[agent-fs](https://github.com/desplega-ai/agent-fs)** — persistent, searchable filesystem shared across agents. Plans and research live here; any agent can pull them.
* **`parentTaskId`** (core swarm feature) — follow-up tasks ("address review feedback") resume the original agent session, preserving context across reviews and re-pushes.

From ai-toolbox [#from-ai-toolbox]

* **Research → plan → implement command chains** — optional [Claude Code](https://www.anthropic.com/claude-code) command wrappers can enforce "research before plan before code" and persist artifacts in [agent-fs](https://github.com/desplega-ai/agent-fs).

Custom (swarm-managed) [#custom-swarm-managed]

* **`linear-interaction`, `linear-expert`** — outbound [Linear](https://linear.app) updates ([Linear sync is inbound-only](/docs/integrations/linear) by default — these handle the outbound side: transitioning state, posting comments, creating issues).

Workflows / Schedules [#workflows--schedules]

* **`autopilot`** — manually triggered, full pipeline: research → review-research → plan → review-plan → implement → verify. Each phase has its own review gate so errata are caught before downstream steps.
* **`autopilot-plan`** — skip research when you already have one: plan → review-plan → implement → verify.
* **`autopilot-research`** — research-only, when you just want vetted findings.
* **`linear-drain-loop`** — given a Linear parent issue with sub-issues, branches off the previous PR's branch (stacked PRs), implements each, opens PRs, transitions Linear states. See the [Drain Loops pattern](/docs/playbooks/patterns/drain-loops).
* **`linear-merge-loop`** — companion: reviews the bottom-most open PR with `base=main`, squash-merges when the reviewer approves and CI is green, transitions the linked Linear sub-issue to Done, then loops up the stack.

Patterns used [#patterns-used]

* [**Drain Loops**](/docs/playbooks/patterns/drain-loops) — turn one big Linear ticket into a chain of stacked, individually-reviewable PRs.

Tips for new swarm users [#tips-for-new-swarm-users]

* Use a `dependsOn` chain for research → plan → implement. Don't let downstream steps fire before upstream artifacts exist.
* **One reviewer per PR** — a single reviewer can (and should) review multiple times across revisions. What you want to avoid is *multiple different* reviewers on the same PR, which creates conflicting requests.
* Pin code-implementing schedules to a specific code-capable agent (`targetAgentId`). Pool dispatch can land on a worker without the right harness.
* Stacked PRs are great for breaking a big ticket into reviewable chunks — but the drain/merge loop only works if each PR is independently mergeable.


# Playbooks (/docs/playbooks)



These playbooks document reusable ways to run an agent swarm in production. Each one corresponds to a "Known Use Case" from the [agent-swarm README](https://github.com/desplega-ai/agent-swarm) and gives you the same three things:

1. **Agents** — which roles do the work, with links to the [official agent templates](https://templates.agent-swarm.dev/official).
2. **Tools & Skills** — what each agent calls, grouped by source (see the legend below).
3. **Workflows / Schedules** — the actual scheduled jobs and on-demand flows that drive the use case.

<Callout type="info">
  **These are starting points, not the only way.** They're deliberately high-level — copy the structure, adapt the specifics, and lean on the [hot patterns](/docs/playbooks/patterns) (litmus tests, drain loops, HITL gates, per-customer working directories, no-op workflows) that recur across every flow.
</Callout>

Skill-source legend [#skill-source-legend]

Every skill referenced in a playbook is tagged with one of three sources:

| Tag                | Meaning                                                                            | Where it lives                                                                                           |
| ------------------ | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| **`[Built-in]`**   | Ships with the `agent-swarm` package                                               | [`desplega-ai/agent-swarm` → `plugin/`](https://github.com/desplega-ai/agent-swarm/tree/main/plugin)     |
| **`[ai-toolbox]`** | Open-source Claude Code plugin                                                     | [`desplega-ai/ai-toolbox` → `cc-plugin/`](https://github.com/desplega-ai/ai-toolbox/tree/main/cc-plugin) |
| **`[Custom]`**     | Deployment-specific skills that teams author with `skill-create` / `skill-install` | Your swarm's skill registry                                                                              |

Agent links always point at the matching [official template](https://templates.agent-swarm.dev/official) so new swarm users can clone the template as a starting point.

The ten playbooks [#the-ten-playbooks]

* [Feature Development](/docs/playbooks/feature-development) — Slack/Linear request → research → plan → implementation PR → review → merge.
* [Lead Prospecting](/docs/playbooks/lead-prospecting) — Daily discovery + drafting + scheduling, with a human approval gate before sending.
* [Content Generation](/docs/playbooks/content-generation) — Topic mining, blog/social/meme production, all gated by a different-model-family LLM-as-judge reviewer and finalized by humans.
* [UX Command Center](/docs/playbooks/ux-command-center) — Weekly UX audits, agentic session recording, design-system enforcement, telemetry-driven proposals.
* [Proactive Customer Support](/docs/playbooks/proactive-customer-support) — Per-customer working directories, scheduled value-showcase reports, post-meeting follow-ups.
* [Observability Alert Management](/docs/playbooks/code-health-alert-management) — Datadog/New Relic/Sentry/SigNoz alerts kick off fixes or proposals; noise gets filtered before agents act.
* [Code Health](/docs/playbooks/code-health-reports) — Recurring Code Maat + D3.js reports for any Git repository, refreshed weekly by default and published to one stable Page URL.
* [DORA Metrics](/docs/playbooks/dora-metrics) — Recurring DORA reports with exact deployment frequency and lead time from release tags, plus clearly labeled proxy CFR and recovery-time estimates.

<Callout type="info">
  Need lighter static scans too? The same Code Health playbook also notes `weekly-code-health`, a compact knip + desloppify scan workflow.
</Callout>

* [Reports from Multiple Sources](/docs/playbooks/reports-multiple-sources) — Join your data warehouse, analytics, billing, search, and observability into one swarm.
* [Self-Documenting & Release Reports](/docs/playbooks/self-documenting-release-reports) — Auto-update docs, generate release notes, render release videos with [Remotion](https://www.remotion.dev/) + browser captures.

Hot patterns [#hot-patterns]

Patterns that recur across multiple playbooks — read these too if you want to skip the same mistakes we made:

* [Litmus Tests](/docs/playbooks/patterns/litmus-tests) — LLM-as-judge quality gates that hard-reject sub-bar output.
* [Drain Loops](/docs/playbooks/patterns/drain-loops) — turn one big ticket into stacked, individually-reviewable PRs, then a merge loop.
* [HITL Gates](/docs/playbooks/patterns/hitl-gates) — pause a workflow until a human approves in Slack.
* [Per-Customer Working Directories](/docs/playbooks/patterns/per-customer-working-directories) — persistent per-account context in [agent-fs](https://github.com/desplega-ai/agent-fs).
* [No-op When Nothing Changed](/docs/playbooks/patterns/no-op-workflows) — detect "nothing changed" and skip silently.


# Lead Prospecting (/docs/playbooks/lead-prospecting)



<JsonLd
  data="{
  &#x22;@context&#x22;: &#x22;https://schema.org&#x22;,
  &#x22;@type&#x22;: &#x22;HowTo&#x22;,
  name: &#x22;Run daily AI prospecting with a human approval gate&#x22;,
  description:
    &#x22;How to wire growth-signal sources, a contact-finder, and AgentMail into an agent swarm so prospects are discovered and drafted by agents but only sent after a human approves in Slack.&#x22;,
  step: [
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Discover&#x22;, text: &#x22;Find companies showing growth signals via your analytics or telemetry source.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Find contacts&#x22;, text: &#x22;Resolve marketing, growth, or ops contacts via a contact-finder provider.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Skip duplicates&#x22;, text: &#x22;Check the KV store for already-contacted prospects and skip them.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Draft outreach&#x22;, text: &#x22;Generate personalized outreach drafts per prospect, persist to agent-fs.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Email digest&#x22;, text: &#x22;Email the human reviewer a digest with subject, preview, and agent-fs links.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;HITL gate&#x22;, text: &#x22;Workflow pauses until a human approves via Slack.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Send&#x22;, text: &#x22;On approval, send via AgentMail and record the contact in KV.&#x22; }
  ]
}"
/>

Plug your prospecting tools into the swarm and let agents handle discovery, drafting, and scheduling — with a human gate before anything goes out.

<Mermaid
  chart="`flowchart LR
C[&#x22;Cron: daily&#x22;] --> D[&#x22;Discover companies<br/>w/ growth signals&#x22;]
D --> F[&#x22;Find marketing/<br/>growth contacts&#x22;]
F --> X{&#x22;Already<br/>contacted?&#x22;}
X -- yes --> SK[&#x22;Skip&#x22;]
X -- no --> DR[&#x22;Draft outreach<br/>→ agent-fs&#x22;]
DR --> RV[&#x22;Email digest<br/>for review&#x22;]
RV --> G{{&#x22;HITL gate&#x22;}}
G -- approve --> SE[&#x22;Send via AgentMail<br/>+ log to KV&#x22;]
G -- reject --> AR[&#x22;Archive drafts&#x22;]
`"
/>

What it does [#what-it-does]

Every morning the swarm discovers companies with growth signals, finds the right contact (marketing, growth, ops), drafts personalized outreach per prospect, drops everything into a review folder, emails a summary, and **waits for explicit human approval** before sending. Tracks who's been contacted so re-runs don't double-touch.

Agents [#agents]

* **[Lead](https://templates.agent-swarm.dev/official/lead)** — orchestrates the daily run.
* **[Researcher](https://templates.agent-swarm.dev/official/researcher)** (or any general-purpose worker) — discovery and email drafting.
* **Human-in-the-loop reviewer** — you. The HITL gate blocks the workflow on a Slack reaction.

Tools & Skills [#tools--skills]

Built-in (ships with agent-swarm) [#built-in-ships-with-agent-swarm]

* **`request-human-input`** — the HITL gate. Workflow pauses until a human reacts in Slack. See the [HITL Gates pattern](/docs/playbooks/patterns/hitl-gates).
* **`slack-post` / `slack-reply`** — surface the review and accept the gate response.
* **[agent-fs](https://github.com/desplega-ai/agent-fs)** — drafts live here, one folder per day's batch. You can edit before approving.
* **Swarm KV store** — namespaced per-workflow state for the "contacted prospects" set, so re-fires don't re-prospect the same person.

Custom (swarm-managed) [#custom-swarm-managed]

* **`agentmail-sending`** — sends the email once approved (enforces signing + signature rules). Routes replies back into the swarm via the per-account inbox.

Third-party providers [#third-party-providers]

* **Growth-signal source** — pick any analytics or telemetry tool that can surface companies showing intent. We use [Plausible](https://plausible.io/), [Google Search Console](https://search.google.com/search-console), and our own website telemetry.
* **Contact-finder** — we use [Enginy](https://www.enginy.ai/); any CRM or contact-enrichment provider with an HTTP API works.
* **[AgentMail](https://www.agentmail.to/)** — outbound email with per-account inboxes that route replies back into the swarm.

These are *integrations*, not skills — wire them in via a [script node](/docs/guides/scripts-runtime).

Workflows / Schedules [#workflows--schedules]

* **`daily-lead-prospecting`** — runs every morning:
  1. Discover companies with growth signals.
  2. Find marketing/growth/ops contacts via your contact-finder.
  3. Skip anyone already in the "contacted" KV set.
  4. Draft a personalized outreach per prospect, store in [agent-fs](https://github.com/desplega-ai/agent-fs).
  5. Email a digest (subject + preview + agent-fs link to each draft) for human review.
  6. **HITL gate** — workflow blocks here.
  7. On approval: send via [AgentMail](https://www.agentmail.to/), log into KV. On rejection: archive and continue.

Patterns used [#patterns-used]

* [**HITL Gates**](/docs/playbooks/patterns/hitl-gates) — a human approves before any outbound email sends.

Tips for new swarm users [#tips-for-new-swarm-users]

* The HITL gate is non-negotiable for cold outbound. Agents are great at drafting; humans should sign off on tone, target, and timing.
* Keep the "contacted prospects" set in swarm KV with a TTL (90–180 days) so a prospect can be re-engaged on a sensible cadence.
* [AgentMail](https://www.agentmail.to/)'s per-account inbox is the unlock for two-way conversations — replies route back into the swarm and can trigger follow-up flows.
* Don't over-personalize. Two facts that prove you researched the prospect beat a paragraph of synthetic empathy.
* Be honest about the swarm's role. We surface "drafted by an agent, sent by a human" — deception breaks trust the moment they ask.


# Proactive Customer Support (/docs/playbooks/proactive-customer-support)



<JsonLd
  data="{
  &#x22;@context&#x22;: &#x22;https://schema.org&#x22;,
  &#x22;@type&#x22;: &#x22;HowTo&#x22;,
  name: &#x22;Maintain per-customer agents that produce proactive value-showcase reports&#x22;,
  description:
    &#x22;How to give each top account a persistent working directory in agent-fs, ingest meeting transcripts automatically, and produce customer-facing value reports on a cadence.&#x22;,
  step: [
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Per-customer directory&#x22;, text: &#x22;Create a per-account directory in agent-fs holding running notes, integration history, open questions, recent activity, and meeting transcripts.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Trigger&#x22;, text: &#x22;A trigger fires: Slack ping, calendar event, or per-customer schedule.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Pull data&#x22;, text: &#x22;Researcher pulls product usage + behavior data into the customer's directory.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Stage 1 + Stage 2&#x22;, text: &#x22;Generate a Stage 1 internal credit-usage breakdown, then a Stage 2 customer-facing value showcase that strips internal jargon.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;HITL review&#x22;, text: &#x22;Human reviews and edits the draft in agent-fs.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Send&#x22;, text: &#x22;AgentMail sends the final from a verified per-account inbox; replies route back into the swarm.&#x22; }
  ]
}"
/>

Standing agents per top account: scheduled value-showcase reports, post-meeting follow-ups, integration history, and "what changed since last touchpoint?" briefings on demand.

<Mermaid
  chart="`flowchart LR
T[&#x22;Trigger: ping /<br/>schedule / meeting&#x22;] --> RE[&#x22;Researcher<br/>pull usage + behavior&#x22;]
RE --> WD[(&#x22;per-customer<br/>agent-fs dir&#x22;)]
GR[&#x22;Granola<br/>post-call transcript&#x22;] --> WD
WD --> DR[&#x22;Draft value-<br/>showcase report&#x22;]
DR --> H{{&#x22;HITL review&#x22;}}
H -- approve --> EM[&#x22;AgentMail<br/>→ customer&#x22;]
`"
/>

What it does [#what-it-does]

Each top account gets a [per-customer working directory](/docs/playbooks/patterns/per-customer-working-directories) in the swarm's shared filesystem ([agent-fs](https://github.com/desplega-ai/agent-fs)). An agent maintains running notes, integration history, open questions, and recent activity. On a cadence (or on demand), the agent produces a customer-facing value-showcase report: data-driven usage + outcomes framed in *their* language, not your internal feature names.

Agents [#agents]

* **[Lead](https://templates.agent-swarm.dev/official/lead)** — receives the ping ("report for account X"), routes the data pull, runs the redaction gate.
* **[Researcher](https://templates.agent-swarm.dev/official/researcher)** — pulls usage data from the product DB, behavior data from analytics, cross-references meeting notes.
* **[Forward-Deployed Engineer](https://templates.agent-swarm.dev/official/forward-deployed-engineer)** — owns engineering-level customer questions and the technical-state side of the report.

Tools & Skills [#tools--skills]

Built-in (ships with agent-swarm) [#built-in-ships-with-agent-swarm]

* **[agent-fs](https://github.com/desplega-ai/agent-fs)** — per-customer working directories. Persistent across sessions; each account accumulates institutional knowledge over months. See the [Per-Customer Working Directories pattern](/docs/playbooks/patterns/per-customer-working-directories).
* **`slack-post`** — internal handoffs + human review before send.

Custom (swarm-managed) [#custom-swarm-managed]

* **`agentmail-sending`** — sends the report from the swarm's verified [AgentMail](https://www.agentmail.to/) domain (signing + signature rules enforced).
* **`customer-value-report`** — two-stage flow: &#x2A;Stage 1 (internal)* data-driven usage breakdown from the product DB; &#x2A;Stage 2 (customer-facing)* strips internal jargon and reframes raw usage as outcomes ("you ran 1,200 E2E checks across 3 critical flows, catching 4 regressions before deploy").
* **`granola-api`** — fetches meeting notes/transcripts from [Granola](https://www.granola.ai/) post-call; the agent ingests and updates the per-customer directory automatically.
* **`posthog-interaction`** (behavior via [PostHog](https://posthog.com/&#x29;), &#x2A;*`clerk-analytics`** (account state via [Clerk](https://clerk.com/&#x29;), &#x2A;*`turso-interaction`** ([Turso](https://turso.tech/)-side state).

Third-party providers [#third-party-providers]

* **[Granola](https://www.granola.ai/)** — meeting notes/transcripts ingested automatically post-call.
* **[AgentMail](https://www.agentmail.to/)** — per-account inboxes; customer replies route back into the swarm.

Workflows / Schedules [#workflows--schedules]

These tend to be **on-demand rather than scheduled** — every top account has different reporting needs and cadence (monthly QBR vs. weekly check-in vs. milestone-driven).

The recurring pattern:

1. Trigger (Slack ping, calendar event, or a per-customer schedule).
2. Researcher pulls usage + behavior data into the customer's [agent-fs](https://github.com/desplega-ai/agent-fs) directory.
3. Agent drafts the value-showcase report (Stage 2 of the skill).
4. Human reviews + edits in agent-fs.
5. [AgentMail](https://www.agentmail.to/) sends the final.
6. Replies route back via the per-account [AgentMail](https://www.agentmail.to/) inbox.

Post-call automation: a [Granola](https://www.granola.ai/) webhook fires after every customer meeting → agent ingests the transcript → appends to that account's directory → flags follow-ups.

Patterns used [#patterns-used]

* [**Per-Customer Working Directories**](/docs/playbooks/patterns/per-customer-working-directories) — persistent per-account context that compounds over months.
* [**HITL Gates**](/docs/playbooks/patterns/hitl-gates) — a human signs off before any direct-to-customer email.

Tips for new swarm users [#tips-for-new-swarm-users]

* **Per-customer working directories are the unlock.** A 6-month-old note like "they care about EU data residency" is what makes the next report feel personal.
* **Frame reports as outcomes, not features.** "You caught 4 regressions" lands; "you consumed 1,200 credits" doesn't.
* **Strip internal jargon before sending.** A Stage 1 → Stage 2 split routinely catches internal project codes leaking into draft copy.
* **Don't auto-send.** Even with great drafting, a human signs off on any direct-to-customer email — that's your reputation insurance.
* **Use [AgentMail](https://www.agentmail.to/) per-account inboxes** so customer replies route back into the swarm for thread-aware follow-up.


# Reports from Multiple Sources (/docs/playbooks/reports-multiple-sources)



<JsonLd
  data="{
  &#x22;@context&#x22;: &#x22;https://schema.org&#x22;,
  &#x22;@type&#x22;: &#x22;HowTo&#x22;,
  name: &#x22;Join multiple data sources in an agent swarm and answer team questions&#x22;,
  description:
    &#x22;How to wire ClickHouse, Postgres, Convex, Clerk, PostHog, Stripe, Google Search Console, GitHub, and SigNoz into a swarm and answer scheduled or on-demand questions.&#x22;,
  step: [
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Wire one skill per source&#x22;, text: &#x22;Install integration skills (gsc-analytics, posthog-interaction, clerk-analytics, turso-interaction, signoz-interaction, granola-api, telemetry-report) — one per source.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Fetch deterministically&#x22;, text: &#x22;Use swarm-script nodes to call sources over HTTP/SQL and emit a unified JSON payload. Keep the LLM out of the loop where it adds no value.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Synthesize&#x22;, text: &#x22;Researcher takes the JSON payload + the question and produces the narrative.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Render&#x22;, text: &#x22;Multi-chart reports render as auto-hosted HTML Pages with charts (via the report chart-generation skill); recurring digests post as Slack threads.&#x22; }
  ]
}"
/>

The swarm joins data across [ClickHouse](https://clickhouse.com/), [Postgres](https://www.postgresql.org/), [Convex](https://www.convex.dev/), [Clerk](https://clerk.com/), [PostHog](https://posthog.com/), [Stripe](https://stripe.com/), [Google Search Console](https://search.google.com/search-console), [GitHub](https://github.com), and [SigNoz](https://signoz.io/), and answers **scheduled questions** ("daily product-growth update?", "weekly GTM metrics?") and **on-demand questions** in Slack ("how many active orgs signed up last week?").

<Mermaid
  chart="`flowchart LR
subgraph SRC[&#x22;Sources&#x22;]
  CH[&#x22;ClickHouse&#x22;]
  PG[&#x22;Postgres&#x22;]
  CK[&#x22;Clerk&#x22;]
  PH[&#x22;PostHog → Convex&#x22;]
  GH[&#x22;GitHub&#x22;]
  GSC[&#x22;Search Console&#x22;]
end
SRC --> SS[&#x22;swarm-script<br/>deterministic fetch → JSON&#x22;]
SS --> LD[&#x22;Lead / Researcher<br/>synthesize&#x22;]
LD --> OUT[&#x22;Slack thread +<br/>Page w/ charts&#x22;]
`"
/>

What it does [#what-it-does]

Reports render as **auto-hosted HTML [Pages](/docs/reference/mcp-tools#pages-tools) with charts** when complex, or Slack threads with chart replies for recurring digests.

Agents [#agents]

* **[Lead](https://templates.agent-swarm.dev/official/lead)** — orchestrates; holds the scoped secrets needed for cross-source queries.
* **[Researcher](https://templates.agent-swarm.dev/official/researcher)** — synthesis: takes raw data + the question, produces the narrative.

Tools & Skills [#tools--skills]

Built-in (ships with agent-swarm) [#built-in-ships-with-agent-swarm]

* **[agent-fs](https://github.com/desplega-ai/agent-fs)** (archives), **swarm KV** (question cache + report state), **Pages*&#x2A; (auto-hosted HTML reports with charts), &#x2A;*`script-run`*&#x2A; + **`swarm-script` nodes** (deterministic catalog scripts — JSON stdout merges into node output for downstream interpolation). Use these for "fetch + JSON" sections so the LLM stays out of the loop where it adds no value.

Custom (swarm-managed) [#custom-swarm-managed]

* **Integration skills (one per source):** `gsc-analytics` ([Search Console](https://search.google.com/search-console)), `posthog-interaction` ([PostHog](https://posthog.com/) HogQL — also joins [Convex](https://www.convex.dev/) / [Clerk](https://clerk.com/) / [Stripe](https://stripe.com/) warehouses), `clerk-analytics` (user/org growth from [Clerk](https://clerk.com/)), `turso-interaction` ([Turso](https://turso.tech/) LibSQL state), `signoz-interaction` ([SigNoz](https://signoz.io/) observability), `granola-api` ([Granola](https://www.granola.ai/) meeting signal), `telemetry-report` (charts from internal telemetry).
* **`daily-growth-snapshot` swarm-script** — calls every source over HTTP/SQL and emits a unified JSON payload, so the agent-side report is mostly framing, not data-pulling. Date-window discipline (align to complete UTC days, exclude today) is encoded in the script.
* **Chart-generation skill** — renders the JSON payload into chart images embedded in the [Page](/docs/reference/mcp-tools#pages-tools) or Slack reply. Backed by [Chart.js](https://www.chartjs.org/) running inside a `swarm-script` node so output is deterministic and snapshot-comparable.

Workflows / Schedules [#workflows--schedules]

* **`daily-product-growth-update`** — daily. Joins [ClickHouse](https://clickhouse.com/) (CLI installs) + [Clerk](https://clerk.com/) (signups) + [Postgres](https://www.postgresql.org/) (product activity) + [PostHog](https://posthog.com/) → [Convex](https://www.convex.dev/) (warehouse) + [GitHub](https://github.com) (stars/forks/traffic). Posts a Slack thread: header + snapshot + up to 8 chart replies + TL;DR.
* **`gtm-weekly-review`** — weekly. [GitHub](https://github.com) stats + [Plausible](https://plausible.io/) + [Google Search Console](https://search.google.com/search-console) as a research thread.
* **`daily-hn-briefing`** — daily. Browser-automation scrape of [Hacker News](https://news.ycombinator.com/), filter for AI/dev-tools relevance, email via [AgentMail](https://www.agentmail.to/), archive in [agent-fs](https://github.com/desplega-ai/agent-fs).
* **`memory-rater-daily-digest`** — daily. Internal-tool health digest (relevant to swarms with a memory subsystem).
* **`daily-blocker-digest`** — daily prelude that verifies operational-runbook items.

Patterns used [#patterns-used]

* [**No-op When Nothing Changed**](/docs/playbooks/patterns/no-op-workflows) — digests skip on a quiet day instead of posting an empty report.

Tips for new swarm users [#tips-for-new-swarm-users]

* **Use deterministic `swarm-script` nodes for "fetch + JSON" sections.** Keep the LLM out of the loop where it adds no value — faster, cheaper, more reliable.
* **Always exclude the partial current day** from time-windowed metrics. Today's numbers are incomplete and the agent will draw wrong conclusions.
* **Render multi-chart reports as a [Page](/docs/reference/mcp-tools#pages-tools)**, not a Slack thread. People want skimmable: a linked HTML page with 8 charts beats 8 image replies.
* **One agent owns the cross-source secrets** (scoped per agent), keeping the secret surface small and auditable.
* **Cache slow warehouse queries in KV** with a short TTL — a 1-hour cache is invisible to users and saves a lot of cycles.
* **Ask the swarm questions in Slack and let it pick the source** — a well-skilled lead routes "active orgs last week?" to [Clerk](https://clerk.com/), "activation funnel?" to [PostHog](https://posthog.com/), "top GSC query?" to [Google Search Console](https://search.google.com/search-console), without you naming the source.


# Self-Documenting & Release Reports (/docs/playbooks/self-documenting-release-reports)



<JsonLd
  data="{
  &#x22;@context&#x22;: &#x22;https://schema.org&#x22;,
  &#x22;@type&#x22;: &#x22;HowTo&#x22;,
  name: &#x22;Auto-update docs and generate release videos with a swarm&#x22;,
  description:
    &#x22;How to keep a docs site, CHANGELOG, and release-notes blog post in lockstep with real commits, and generate release videos with Remotion and browser-automation captures.&#x22;,
  step: [
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Detect change&#x22;, text: &#x22;Daily check: are there commits on main after the latest published release tag? If not, no-op.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Update docs&#x22;, text: &#x22;If main is missing a release tag, cut it first; then update docs, CHANGELOG, timestamps, and (when code shipped) open the daily version-bump PR.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Release notes&#x22;, text: &#x22;Weekly: generate MDX release notes for the docs site and a release-notes blog post for the marketing site.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Release video&#x22;, text: &#x22;On demand: qa-use captures the real product flow; Remotion renders the polish (titles, transitions, music bed).&#x22; }
  ]
}"
/>

Keep your docs fresh automatically. Generate release notes from real commits. Produce release videos with [Remotion](https://www.remotion.dev/) + browser-automation captures, on the cadence you choose.

<Mermaid
  chart="`flowchart LR
C[&#x22;Cron: daily&#x22;] --> Q{&#x22;Commits after latest<br/>released tag?&#x22;}
Q -- no --> NO[&#x22;No-op (skip)&#x22;]
Q -- yes --> UD[&#x22;Update docs +<br/>CHANGELOG + bump PR&#x22;]
UD --> CI[&#x22;Wait for required<br/>CI checks to pass&#x22;]
CI --> NX[&#x22;Next daily run cuts<br/>the new GitHub release&#x22;]
RV[&#x22;Release video<br/>(on demand)&#x22;] --> QU[&#x22;qa-use capture&#x22;]
QU --> RM[&#x22;Remotion render&#x22;]
`"
/>

What it does [#what-it-does]

* **Daily** — first checks whether the version already on `main` is missing a GitHub release and cuts it if needed. Then it compares `main` with the latest published release tag. If that range has commits: update the docs site, CHANGELOG, "last updated" timestamps, derive the semver bump from conventional commits in that exact range, and open the daily release PR. The bumped GitHub release is cut by the **next** daily run after that PR lands on `main`. &#x2A;*If the range is empty, no-op.**
* **Weekly** — produce release-notes MDX (docs site) and a release-notes blog post (marketing site) from real commit history, with significance evaluation so trivial commits don't headline.
* **On-demand** — generate release videos: [Remotion](https://www.remotion.dev/) renders the polish (titles, transitions, music bed, brand colors); [qa-use](https://github.com/desplega-ai/qa-use) / [browser-use](https://github.com/browser-use/browser-use) captures the real product flow.

Agents [#agents]

* **[Coder](https://templates.agent-swarm.dev/official/coder)** — docs PRs, MDX writes, CHANGELOG bumps, release tagging, and CI-green verification before the daily release task completes.
* **[Forward-Deployed Engineer](https://templates.agent-swarm.dev/official/forward-deployed-engineer)** — [qa-use](https://github.com/desplega-ai/qa-use) captures and browser-automation for release-video recording.
* **[Content Writer](https://templates.agent-swarm.dev/official/content-writer)** — release-notes blog post drafting (separate from the in-repo CHANGELOG).

Tools & Skills [#tools--skills]

Built-in (ships with agent-swarm) [#built-in-ships-with-agent-swarm]

* **[agent-fs](https://github.com/desplega-ai/agent-fs)*&#x2A; (video drafts, intermediate compositions, audio assets), **[`gh`](https://cli.github.com/) / [`glab`](https://gitlab.com/gitlab-org/cli) CLI*&#x2A; (PRs, releases, tags), &#x2A;*`slack-post`** (release-shipped notifications).

Custom (swarm-managed) [#custom-swarm-managed]

* **`video-generation`** — [Remotion](https://www.remotion.dev/) is the default stack (project layout, brand fonts/colors, default music bed, render pipeline, "honest read" pacing all encoded).
* **`browser-use-cloud`** — escape hatch when datacenter IPs get blocked (YouTube transcripts past bot walls, captcha pages, login walls). Drives a real cloud browser via [Browser Use Cloud](https://browser-use.com/).

Third-party providers (popular tools we use) [#third-party-providers-popular-tools-we-use]

* **[Remotion](https://www.remotion.dev/)** — programmatic video framework. Default stack for release videos.
* **[qa-use](https://github.com/desplega-ai/qa-use)** — agent-first browser-automation CLI. Drives a real browser through your product so captures are real, not mockups.
* **[browser-use](https://github.com/browser-use/browser-use)** — alternative browser-automation framework (also available via [Browser Use Cloud](https://browser-use.com/)).
* **[Fumadocs](https://fumadocs.dev/)** — MDX-based, [Next.js](https://nextjs.org/) docs site. This site you're reading is built on it.

Workflows / Schedules [#workflows--schedules]

* **`daily-docs-update`** — daily. First cuts the GitHub release for the version already on `main` if that tag is still missing. Then checks `latest-release-tag..origin/main`. &#x2A;*No-op if that range is empty.** Otherwise updates docs + CHANGELOG + timestamps, derives the bump type from conventional commits in the range, opens the daily release PR, posts the bump rationale, enables merge automation when available, and waits for required CI to go green. See the [No-op pattern](/docs/playbooks/patterns/no-op-workflows).
* **`docs-site-releases`** — weekly. Generates MDX release notes for the docs site: pulls commits, evaluates significance, plans, writes MDX, validates, opens a PR.
* **`weekly-new-releases`** — weekly. Release-notes **blog post** for the marketing site (product repos only, not internal infra).

Patterns used [#patterns-used]

* [**No-op When Nothing Changed**](/docs/playbooks/patterns/no-op-workflows) — the daily docs job skips silently on a quiet day.

Tips for new swarm users [#tips-for-new-swarm-users]

* **"No-op when nothing changed" is the most important property.** Agents that always write something fill your changelog with noise. Detect "did anything ship?" and skip if not.
* **Pair [Remotion](https://www.remotion.dev/) + [qa-use](https://github.com/desplega-ai/qa-use) for release videos.** qa-use captures the real flow (so the demo isn't a lie); Remotion adds polish. Don't do both in one tool.
* **Cap video length** — 45–90 seconds, hard limit. The skill's "honest read" pacing enforces this.
* **`browser-use-cloud` is your escape hatch** when datacenter IPs get blocked. Don't fight Cloudflare from your swarm; drive a real cloud browser.
* **Two-track release docs** — in-repo `CHANGELOG.md` (developer-facing, terse, all changes) vs. marketing release notes (customer-facing, narrative, only what users care about). One audience, one format.
* **Significance-evaluate commits before writing** — a patch dependency bump shouldn't headline a release.


# UX Command Center (/docs/playbooks/ux-command-center)



<JsonLd
  data="{
  &#x22;@context&#x22;: &#x22;https://schema.org&#x22;,
  &#x22;@type&#x22;: &#x22;HowTo&#x22;,
  name: &#x22;Run a weekly UX audit and convert findings into Linear tickets&#x22;,
  description:
    &#x22;How to use a UX-focused agent to walk the frontend, audit against UX principles and a design system, and create a Linear umbrella with one sub-issue per finding.&#x22;,
  step: [
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Audit&#x22;, text: &#x22;Clone the frontend repo and run a UX-principles audit against your design system.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Reproduce&#x22;, text: &#x22;Use qa-use to reproduce problematic flows; cross-reference PostHog funnels and Sentry errors.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Create umbrella&#x22;, text: &#x22;Create a Linear umbrella ticket with one sub-issue per finding, scoped to a single component.&#x22; },
    { &#x22;@type&#x22;: &#x22;HowToStep&#x22;, name: &#x22;Notify&#x22;, text: &#x22;Post the umbrella URL to Slack so humans see the curated todo list.&#x22; }
  ]
}"
/>

A UX-focused agent walks your frontend every week, audits against UX principles and your design system, and creates a [Linear](https://linear.app) umbrella ticket with one sub-issue per finding — so humans process a curated todo list, not a fire-hose audit report.

<Mermaid
  chart="`flowchart LR
C[&#x22;Cron: weekly&#x22;] --> UX[&#x22;UX Principles Agent&#x22;]
UX --> A[&#x22;Audit FE vs.<br/>design system&#x22;]
A --> B[&#x22;qa-use repro +<br/>PostHog signals&#x22;]
B --> LI[&#x22;Linear umbrella<br/>+ sub-issues&#x22;]
LI --> SL[&#x22;Slack notify&#x22;]
`"
/>

What it does [#what-it-does]

A UX agent walks the frontend weekly, audits against [UX principles + your design system](https://templates.agent-swarm.dev/official/ux-principles), and **creates a [Linear](https://linear.app) umbrella ticket with one sub-issue per finding** — so humans process a curated todo list. The same agent records agentic browser sessions ([qa-use](https://github.com/desplega-ai/qa-use)) and pulls user-behavior signals from product analytics.

Agents [#agents]

* **[UX Principles Agent](https://templates.agent-swarm.dev/official/ux-principles)** — React / [MUI](https://mui.com/) component analysis, design-system adherence checks, accessibility, frontend code review. Domain-trained on your design system.

Tools & Skills [#tools--skills]

Built-in (ships with agent-swarm) [#built-in-ships-with-agent-swarm]

* **[agent-fs](https://github.com/desplega-ai/agent-fs)** — audit reports persisted here for human review across sessions.
* **[Linear](https://linear.app) integration** — inbound sync. See [Linear integration](/docs/integrations/linear).
* **`slack-post`** — the weekly audit-complete notification.
* **`investigate-sentry-issue`** — cross-references UX issues that throw [Sentry](https://sentry.io/) errors.

Custom (swarm-managed) [#custom-swarm-managed]

* **`linear-interaction`** — outbound [Linear](https://linear.app) writes (creating the umbrella + sub-issues programmatically).
* **`posthog-interaction`** — funnel/cohort data from [PostHog](https://posthog.com/) via the HogQL API.

Third-party providers [#third-party-providers]

* **[qa-use](https://github.com/desplega-ai/qa-use)** — agent-first browser-automation CLI. The agent drives it to reproduce a problematic flow and embeds the recording in the sub-issue.
* **[PostHog](https://posthog.com/)** — funnel/cohort/event data for "users are dropping off here" cross-references.
* **[Sentry](https://sentry.io/)** — cross-reference UX issues that throw errors.

Workflows / Schedules [#workflows--schedules]

* **`weekly-ux-audit`** — weekly. Clones the frontend repo, runs a full UX-principles audit, creates a [Linear](https://linear.app) umbrella ticket with sub-issues for each finding, posts the umbrella URL to Slack. Each sub-issue is single-component-scoped so it's individually fixable.

Patterns used [#patterns-used]

* [**Drain Loops**](/docs/playbooks/patterns/drain-loops) — the umbrella's sub-issues can feed a drain loop to auto-fix the low-risk ones.

Tips for new swarm users [#tips-for-new-swarm-users]

* **Scope each audit to one app/dir.** Frontend audits compound when focused; running against the whole monorepo every week burns tokens and produces unactionable noise.
* **Create Linear sub-issues, not a markdown dump.** Humans process work, not raw reports. One sub-issue per finding turns "we should fix UX" into a concrete backlog.
* **Pair the UX agent with [qa-use](https://github.com/desplega-ai/qa-use)** for "show me where this UI feels broken" loops. The agent reasons; qa-use reproduces.
* **Cross-reference behavioral data.** A claim like "users are confused by X" is far more credible with a [PostHog](https://posthog.com/) funnel showing the drop-off.
* **Cap audit-round complexity.** If a sub-issue takes more than \~3 review rounds, escalate to a human — it's a design decision, not a UX nit.


# Week of April 7, 2026 (/docs/releases/2026-04-07)



Highlights [#highlights]

* **GitHub eyes reaction on task pickup** — When an agent picks up a task created from a GitHub issue or PR, it now adds an eyes reaction to the source comment, giving immediate visual feedback that work has started. ([#310](https://github.com/desplega-ai/agent-swarm/pull/310))

Improvements [#improvements]

* **Discoverability optimizer template** — New official template for agents that optimize SEO and content discoverability across platforms.
* **Blog content updates** — Cleaned up fabricated blog content and improved editorial standards.

Bug Fixes [#bug-fixes]

* No notable bug fixes this week.

Breaking Changes [#breaking-changes]

* No breaking changes this week.


# Release Notes — Week of May 19-25, 2026 (/docs/releases/2026-05-25)



Highlights [#highlights]

Five releases (v1.80.0 → v1.83.0) in seven days turn agent-swarm production-grade. The hero this week is full-stack distributed tracing; the supporting cast is a new scripts runtime, pointer-based attachments, and a People page that finally treats humans like the users they are.

OpenTelemetry tracing across the stack [#opentelemetry-tracing-across-the-stack]

Every task, MCP tool call, and Claude Code session is now a span in a single distributed trace. TRACEPARENT propagates from worker tasks into API calls, MCP tool spans, and the spawned Claude Code subprocess — so "where did this task spend its time?" is now answerable without sprinkling timers across the codebase.

* API spans are named by Hono route template instead of the generic `http.server`, with proper nesting for child requests (`033890f7`, `12434583`).
* HTTP semantic conventions (`http.route`, `http.method`, `http.status_code`) attached to every span (`55725280`).
* MCP tool calls show up as named spans — no more anonymous tool entries (`75b720d4`).
* Implicit-close on assistant-message boundaries prevents leaked open spans (`609cf6d6`).
* Eight follow-up PRs (#488, #492, #496, #497, #516, #528, #531, #535) landed the polish in the same week.

Drop a TRACEPARENT into any task and follow it end-to-end in SigNoz, Jaeger, or Tempo.

Pointer-based task attachments + agent-fs integration [#pointer-based-task-attachments--agent-fs-integration]

`store-progress` now accepts attachments as pointers (agent-fs path, URL, shared-fs path, or swarm Page) instead of inline blobs. Slack threads render them as live agent-fs URLs, the UI surfaces them in the task tree, and terminal tasks still accept attachments after they've completed.

* Phase 1: pointer-based attachments on `store-progress` (`831ce2ed`).
* Phase 2a: Slack tree-message render + live agent-fs URLs (`96342ddf`, `62a39c9e`).
* Late attachments allowed on already-terminal tasks — useful for audit screenshots and follow-up logs (`67622114`).
* Org/drive IDs auto-resolve from swarm config — no more boilerplate (`c8e634e2`).

Attachments stop bloating task records. Store the pointer, render the URL, ship.

Reusable scripts runtime + 10 seeded global scripts [#reusable-scripts-runtime--10-seeded-global-scripts]

A new scripts runtime foundation with a Zod-first `argsSchema` convention exposed as `argsJsonSchema`. 10 built-in scripts seed into the catalog on startup, so a fresh swarm ships with ready-made utilities out of the box.

* Typed once, runnable from any agent or workflow node (`49626825`, `6d74b356`).
* Typecheck relaxed to match runtime semantics — no more false positives on runtime globals (`9b81f912`).
* 10 global scripts seeded at startup (`9d7e61a9`).

Humans as first-class users [#humans-as-first-class-users]

The People page is now a proper directory: humans live alongside agents, GitHub/Linear/AgentMail externalIds are exposed, and webhook-triggered tasks correctly inherit the requesting user.

* `send-task` and `resolve-user` inherit `requestedByUserId` and expose `externalIds` (`5859616f`, `8b8eb862`).
* Sessions list can filter by active user — no SQL required (`e988b093`).
* GitHub webhook tasks (comment + review events) set `requestedByUserId` correctly (`90a01bec`).

Every task now traces back to the human who triggered it.

Improvements [#improvements]

* **Codex OAuth multi-credential pool** — Multiple Codex OAuth credentials can be pooled and rotated, removing single-credential rate-limit bottlenecks at scale (`7d22c44c`).
* **API + DB performance pass** — List-endpoint payloads slimmed by default with a `?fields=full` opt-in; filter-aware pagination; `listRecentSessions` single-pass; `getLogsByTaskId` LIMIT clamp; `refetchInterval` bumped 5s → 10s; three new indexes on `agent_tasks` plus SQLite PRAGMAs from the Linear-fast-UI research (`9f3b63af`, `da94261b`, `d1e4a2fd`, `6e4928bf`).
* **Resilient 5h cooldown on Claude rate-limit events** — Runner captures `rate_limit_event` and waits out the 5h cooldown instead of crash-looping (CAI-1279, `81d62260`).
* **Bedrock authentication via AWS SDK** — `pi-mono` delegates Amazon Bedrock auth to the AWS SDK instead of hand-rolling credential resolution; bun-compile entry points correctly wired (`4773b8c2`, `09e293ea`).
* **First-party Helm chart** — Self-hosted users get a Helm chart for Kubernetes deployments (`24d8a472`).
* **tini as PID 1 in worker containers** — Workers run tini as PID 1 to reap orphaned grandchildren and avoid zombie accumulation in long-running containers (`9e71e1fe`).
* **CI guard for DB migration numbering conflicts** — PRs with duplicate or out-of-order migration numbers fail CI instead of silently colliding at runtime (`4baca519`).
* **AgentMail integration card requires `AGENTMAIL_API_KEY`** — The integrations UI now surfaces `AGENTMAIL_API_KEY` as a required field on the AgentMail card (`b82fef6e`).

Bug Fixes [#bug-fixes]

* **Docker entrypoint: split `CLAUDE_BINARY` into argv before PATH lookup** — Multi-word `CLAUDE_BINARY` values (e.g. `claude --foo bar`) are split into argv before the PATH lookup, fixing "command not found" on custom binaries (`2e9e67da`).
* **Workflow webhook trigger honors `hmacHeader` + resolves `hmacSecret` refs** — Webhook triggers read the configured `hmacHeader` and resolve `hmacSecret` references through the swarm config instead of hardcoding `X-Signature` (`de8bcfde`).
* **Workflow graph connects single-branch condition node edges** — Conditional nodes with only one branch wired up no longer leave dangling edges in the UI graph view (`b72c34d1`).
* **Workflows redact resolved secrets from persisted step inputs** — Resolved secrets from refs like `${{ secrets.FOO }}` are redacted before being persisted to step inputs, closing a credential-leak vector in the audit log (`545e1ae6`).
* **Linear: post `task.progress` as action activity** — `task.progress` updates post as Linear action activities with the required `actionId` parameter, fixing silent dropped updates (`9d553f22`).
* **Scripts runtime: defensive `rawArgs` parsing + compiled-binary path resolution** — `eval-harness` parses `rawArgs` defensively and resolves the `/$bunfs/` path correctly in the compiled binary, fixing crashes in production builds (`58924378`, `1bea6708`, `14020277`).
* **Workflows populate script executor output on timeout/spawn-error** — Script executor failures (timeout, spawn error) populate the step output so downstream nodes can branch on the failure instead of receiving null (`4def062e`).
* **AgentMail events** — Event-handling fixes (`ed22cc4d`).


# Release Notes — Week of May 25-June 1, 2026 (/docs/releases/2026-06-01)



Highlights [#highlights]

This week’s release is about operational leverage. The headline change is a new self-serve workflow for running E2B-backed swarms: teams can now launch, inspect, extend, group, and shut down swarm stacks with native logs instead of stitching together lower-level dispatch steps. In practice, that means less operator glue between “I want a sandbox swarm” and “I can see what it’s doing and control its lifecycle.” That work landed in `300a5ad7` / #601.

Two other changes make that story more complete:

* **Follow-up work now carries context more reliably across harnesses.** Universal context preambles and `ctx_*` tooling now span Claude, Codex, and OpenCode, so resumed or handed-off tasks start with more of the right state and less manual re-briefing. That came in `e34f8324` / #567 and `0d2b4537` / #599.
* **Messaging workflows got closer to production-ready.** Native Kapso/WhatsApp integration plus inbound acknowledgement support means the swarm can both receive and respond through a more complete messaging loop. That shipped in `f8a60508` / #560 and `ee6b0924` / #607.

Across the week, `agent-swarm` shipped 109 commits. The pattern behind them is consistent: fewer manual steps for operators, fewer dropped threads for agents, and better visibility when workflows hit real infrastructure.

Improvements [#improvements]

* **E2B swarm management moved up a level.** Instead of treating E2B as a low-level dispatch surface, the platform now exposes swarm stack lifecycle controls, stack grouping, and native logs in a more operator-friendly workflow. For CTOs and ICs, the value is straightforward: sandbox swarms are easier to start, reason about, and clean up without custom runbooks. Primary reference: `300a5ad7` / #601.
* **Task control is getting more deliberate.** Graceful pause/resume via supersede-and-follow-up and per-task follow-up controls make it easier to interrupt work without losing continuity or spawning messy task trees. This is useful both for human-led review loops and for agents that need a clear continuation path. See `67321c79` / #594 and `aac03da9` / #587.
* **The operator UI is more informative during live sessions.** Session logs now render code blocks more cleanly, stream better, and auto-follow more predictably, reducing the amount of log friction during active debugging or long-running workflows. That polish landed in `8b3e570d` / #606.
* **Templates are easier to evaluate before you run them.** The templates UI now surfaces skills, schedules, and workflows directly, and adds a “prompt for the lead” affordance on detail pages. That reduces the gap between discovering a template and understanding how to use it. See `332a142b` / #580.
* **Model and integration coverage expanded.** The platform added a user-facing MCP token flow, an Amazon Bedrock integration card, and Claude Opus 4.8 plus 4.6 in model registries. For teams standardizing on mixed provider stacks, this lowers setup friction and makes provider choice more visible in-product. References: `5fbab82f` / #536, `0d294b78` / #572, and `39ef95ff` / #582.

Bug Fixes [#bug-fixes]

* **Codex and runner stability got meaningful hardening.** Several fixes targeted subprocess reliability directly: isolating the Codex SDK into a subprocess, tightening spawn argument budgets, lazy-loading provider adapters to avoid startup crashes, and cleaning up subprocess error propagation and TTY noise. These changes matter because they reduce the class of failures where agents die before work starts or fail without actionable diagnostics. See `4d00d64e` / #581, `91158c8d` / #585, and `6a3e52da` / #584.
* **Rate-limit and workflow behavior became more predictable.** The runner now detects qualified cooldown messages more accurately, scheduled task memories are gated correctly, and derived tasks no longer inherit a parent’s concrete model by mistake. Those changes reduce surprising retry behavior and keep workflow state cleaner over time. References: `cf8c7e1e`, `210214c8` / #597, and `d19c8094` / #595.
* **OAuth and integration state handling improved.** Refresh token persistence was fixed for OAuth flows, rotated Jira OAuth tokens now persist correctly, and Kapso sender-to-user mapping was corrected. These are the kinds of fixes that remove slow-burn operational support issues rather than flashy product bugs. See `a911f52c`, `2a804249`, and `e4ee85ab` / #563.
* **UI trust issues were cleaned up.** Task pricing now renders consistently with GPT-5.5 backfill, and Slack task trees render icons for all statuses. Small surface-area fixes like these matter because they improve operator confidence that what the UI shows is complete and current. References: `a4a8d6e8` / #577 and `8df69e4d` / #604.
* **Config and telemetry edge cases were tightened.** Env-var export now skips non-identifier keys, and telemetry defaults to production where expected, reducing avoidable misconfiguration noise. See `398c5549` / #573 and `cb8b8f05` / #591.


# Release Notes — Week of June 22-29, 2026 (/docs/releases/2026-06-29)



Highlights [#highlights]

Secure Script Integrations [#secure-script-integrations]

Scripts can now integrate with external APIs without exposing secrets in source code or runtime logs. This is the biggest unlock for agents writing automation this week.

Two new primitives ship together:

* **Typed API connection registry** (`56919945`) — Register connections with OpenAPI specs and get generated TypeScript types. Agents write `ctx.api.<slug>.getCustomers()` instead of hand-rolling fetch calls. Connections are scoped (global, agent, repo) and managed via the `script-connections` tool, so teams can share integrations without sharing raw keys. Generated types are verified at authoring time through a typecheck pass, catching mismatches before runtime.

* **Credential broker with scoped bindings** (`3747d6a6`) — Secrets stay on the server. Scripts declare a credential binding (config key → allowed hosts) and the broker injects tokens at fetch time via header or query templates. Nothing leaks into source code, logs, or the KV store. The broker also blocks egress to unregistered hosts, giving operators a clear audit trail of which services each script can reach.

Agent Memory Upgrades [#agent-memory-upgrades]

Memory gets two overdue upgrades (`5235b58a`):

* **Hybrid search** — Semantic + keyword retrieval with a new reranker gives agents dramatically better recall. A search for "how to fix login bug" now surfaces the actual Auth0 pattern you documented three weeks ago instead of the generic troubleshooting page. The retrieval store now tracks source attribution so operators can see where a memory came from and how it's been used over time.

* **In-place memory editing** — Operators can now fix or update a memory without deleting and recreating it. Use `memory-edit` with either `replace` mode (full rewrite) or `exact` mode (surgical find-and-replace). Version history is preserved either way, and the new structured key + versioning system keeps schemas consistent across edits.

Platform Reliability [#platform-reliability]

A cluster of stability improvements makes the platform smoother to operate day-to-day:

* **Opt-in repo hook installation** (`47186da6`) — Configure hooks per-repo from the UI instead of relying on automatic detection. Workers check the repo's hook config on session init and install only when explicitly enabled. This replaces the old best-effort auto-detect approach and gives operators full control over which repos run hooks.

* **Live-reload env on MCP config mutations** (`7b0011ca`) — Changing an MCP server's env vars or credential bindings now takes effect without a restart, matching HTTP route behavior. No more "did you restart?" guessing when updating API keys or endpoint URLs.

* **Runner TDZ fix** (`e31d55a6`) — Fixed a temporal dead zone bug in the runner's `session_init` that could cause startup failures under specific timing conditions. This was a sneaky one — the variable was referenced before initialization in an edge path that only triggered on certain container states.

* **Worker hook config detection** (`e24d4efd`) — Workers now correctly detect hook configurations in `prek.toml` projects, closing a gap where project-level hook settings were silently ignored.

Improvements [#improvements]

* **Telemetry docs** (`e143747b`) — Expanded documentation covering session cost tracking, workflow execution metrics, and schedule monitoring. Includes a new reference section on the `ai.telemetry` event schema so operators can build custom dashboards and alerts on top of agent activity data.

* **Code Health Reports community playbook** — A new guide in the docs shows teams how to set up automated code health reporting with agent-swarm, from PR quality gates to weekly trend dashboards. Covers configuration, scheduling, and integrating reports with Slack notifications.

* **Weekly harness dependency pin bumps** (`f193d00f`) — Routine maintenance keeping the runtime stable across Node, Bun, and Docker environments. Includes `bun.lock` and `package.json` updates.

* **Docker image size optimizations** (`28f2a4b6`) — Trimmed the worker image footprint and added automated size tracking in CI with a JSONL history file so regressions are caught before they hit production.

Bug Fixes [#bug-fixes]

* Fixed `session_init` runningTask temporal dead zone in the runner that could cause session startup failures (`e31d55a6`)
* Fixed worker `prek.toml` hook config not being detected when hook installation was enabled at the repo level (`e24d4efd`)


# Release Notes — Week of July 6-12, 2026 (/docs/releases/2026-07-13)



Highlights [#highlights]

Script Connections MVP [#script-connections-mvp]

Scripts now have first-class, typed access to external APIs and MCP servers without ever seeing raw credentials. The connections MVP (`c159a1a0`, #934) plus follow-ups (`0b2a5e00`, `248daf5b`, `41ef7111`) introduce:

* OpenAPI-by-URL registration and generated `ctx.api.<slug>` clients
* OAuth and GraphQL support via `ctx.mcp`
* Credential broker + bindings at egress time; nothing in source or logs
* Authored typechecking and scope control (global/agent/repo)

Agents can now call Stripe, Notion, custom services, and other swarms from script nodes with auditability and safety by default.

Asset Namespace Keys [#asset-namespace-keys]

A new canonical addressing scheme for swarm resources rolled out (`9fc74c7e`, #963). Tasks, workflows, schedules, pages and files now have stable namespace-qualified identifiers usable across the SDK, tools, UI, and skills. This removes ad-hoc string hacks and sets the foundation for safer cross-agent and cross-drive operations.

RBAC and Access Controls (DES-445) [#rbac-and-access-controls-des-445]

Major RBAC surface arrived this week across multiple increments (`80614625`, `672f7f01`, `1aa25c93`, `b1a05246`, `31c77c1b`):

* Central `can()` check + audit logging
* Role engine with default auto-backfill for new users
* Gates on MCP user admission, swarm-config write/delete/secret-read, route backlog
* RBAC\_ENABLED flag and import-completeness guards for favorites

Operators gain fine-grained control; AI agents get explicit permission boundaries instead of broad access.

Memory Retrieval v2 + Usefulness [#memory-retrieval-v2--usefulness]

Memory system received update (`24798ed8`, #894; polish `9015e5be`, #915):

* Usefulness ratings on recall
* Memory link side for read paths
* New UI panel with tooltips, axes, legend improvements

Agents and operators can now give feedback on what memories were actually useful, improving future retrieval quality.

Improvements [#improvements]

* **Model support and guidance**: gpt-5.6 tiers added (`9eafd225`, #958) along with portable `modelTier` + `effort` controls. Coders can now steer between fast and deep reasoning explicitly.
* **UI & product polish**:
  * Activity timeline replaces dashboard graph (`286e5647`, #945)
  * List search + filters completed (`6a7964f0`, `e0cd9b33`, #961/#964)
  * Connection errors surfaced rather than silent "Disconnected" (`34bbb21f`, #950)
  * Prose outputs shown in Slack completions (`f1d32324`, #957)
* **New tooling**:
  * Workflow and schedule triage (`ccabfb50`, #933)
  * Single-page delete (`5bc1397d`, #940)
  * Lead-gated slack-delete and slack-update tools (`7f4dd929`, #918)
* **Workflows & scripts**: Configurable webhook verification formats (timestamped, token, legacy) (`24998dfa`, #941). Raw ctx API responses available for scripting (`248daf5b`, #952). Tightened script-usage and target-type prompts (`93481dc8`, `033b9247`).
* **Evals & CI**: Dispatch/hop structural scoring (`b1e8fb84`, #937). Docs clarification on delegation thresholds (`2a6dc1bd`, #938).
* **Daily releases + docs** updates (multiple #9xx release commits) keep the site current.
* **Worker & infra**: Chromium runtime deps fixed, pgvector added to worker images, graceful shutdown pinning (`2c7d5d5b`, `ac8dc86c`, `03722a2c`).

Bug Fixes [#bug-fixes]

* Slack DM file upload threading fixed (`dc335bb3`, #912)
* Routing coherence + affinity gates for interrupted/pooled tasks prevent misrouted work (`bc1efcc6`, `cb0a41df`, #960/#954)
* GitHub inline PR comments no longer dropped silently (`669e2050`, #917)
* Identity "confabulation" eliminated via provider-agnostic resolve-user (`b8aa7321`, #939)
* Connection state and favorites supported under hosted auth (`966ead0a`, #967; `5e5f28a5`, #926)
* Stale FTS flags and memory leaks fixed in delete paths (`ba5fdbb5`, #924)
* UI list candidates now complete when filtered (`e0cd9b33`, #964)
* Script connections preserve query-only bindings (`0b2a5e00`, #942)

Great week for foundations and developer/agent power. Next weeks will focus on polishing these new surfaces and adding more discoverability paths.


# Release Notes — Week of July 13-20, 2026 (/docs/releases/2026-07-20)



Highlights [#highlights]

Run agent-swarm as a scripts-only MCP surface [#run-agent-swarm-as-a-scripts-only-mcp-surface]

This week’s headline is a new, opt-in deployment mode for teams that want agent-swarm’s workflow and automation surface without running a full LLM agent. **Scripts-only mode** exposes the scripts and MCP capabilities needed to orchestrate work programmatically, making it a better fit for controlled operations, custom automation, and integrations where another system supplies the reasoning layer.

The mode ships with a practical seed-script pack for common coordination tasks: delegating work, waiting for child tasks, completing tasks, reporting progress, collecting child outputs, and viewing the swarm. It also includes a dedicated `docker-compose.scripts-only.yml` configuration, gating tests, and a new scripts-only-mode guide. The feature is explicitly flag-gated, so existing agent deployments continue unchanged. \[#969, `0200d26f`]

The outcome is a leaner way to deploy the platform: use agent-swarm as an MCP and script execution layer when that is the right boundary, while keeping the full-agent runtime available for workloads that need it. Teams can begin with the packaged Compose configuration, then connect their own control plane or automation to the MCP surface. This keeps operational responsibilities explicit: scripts execute well-defined work, while the caller owns higher-level policy and reasoning. It is designed for environments where execution requirements are stable, repeatable, and subject to deliberate external control. Because the feature is opt-in, existing installations can evaluate this focused boundary alongside their current agent workflows and move individual automation cases only when the operational model is proven.

Improvements [#improvements]

* **Safer shared-organization provisioning for agent-fs.** Shared-org invitations can now be seeded through a server-side endpoint, which simplifies agent-fs co-deployment and removes setup friction for teams bringing the two services up together. The accompanying co-deployment guide documents the supported path. \[#978, `621352f6`]

* **More operational guidance, closer to the release.** Three new docs guides cover scripts-only mode, the evals harness, and agent-fs co-deployment. The environment-variables reference was refreshed with the v1.119.0 release, helping operators find the current configuration surface without chasing implementation details. \[#969, #977, #979, `ef84fdc8`, `ebba27fa`, `64784d14`]

* **A steady patch-release cadence.** Versions **v1.119.0** through **v1.119.3** shipped during the week, with daily documentation and API-reference refreshes. This keeps the Helm chart, package metadata, OpenAPI description, and docs site aligned as the deployment surface evolves. \[#973, #976, #977, #979]

* **Release notes remain part of the product record.** Last week’s release notes were added to the docs site, making the ongoing changes easier to trace for operators and teams evaluating upgrades. \[#970, `82f3db7b`]

Bug Fixes [#bug-fixes]

* **agent-fs recovers the correct provider after reload.** A reload now re-selects the configured filesystem provider instead of retaining an incorrect selection. This improves reliability for deployments that initialize or reconfigure agent-fs across process reloads. \[#978, `621352f6`]

* **Evaluation sandbox traffic no longer pollutes production telemetry.** Sandbox telemetry is now explicitly tagged as test data, so production metrics more accurately reflect real workloads and operational behavior. \[#974, `6e4c43f0`]

* **Self-hosted star-history assets generate correctly.** The README chart generation workflow and its light/dark assets were repaired, restoring a working project-health visualization for self-hosted users and contributors. \[#975, `c551cb91`]


# Release Notes — Week of July 20-27, 2026 (/docs/releases/2026-07-27)



Highlights [#highlights]

This week introduces a redesigned Connections experience that makes it straightforward to link agents to the services where work already happens.

A new Connections experience [#a-new-connections-experience]

The previous script-connections flow has been replaced with an embedded Connections UI backed by a unified OAuth core. Teams can now connect directly to five curated integrations—GitHub, Gmail, Jira, Linear, and Slack—without leaving the product or managing configuration manually.

Embedded OAuth handling lets you authorize an account and immediately use it for tool access or egress secrets. A catalog browser surfaces available integrations with clear visual feedback for connection status. Credential bindings are resolved at runtime for both MCP and scripts execution paths, so once a connection exists you can reference it from prompt steps, workflow nodes, or raw script runs.

The OAuth credential broker now properly handles `authKind: oauth` bindings, fixing an earlier gap where only simple config-key secrets were resolved. This matters when you wire an MCP server or external script to an external API that requires ongoing token refresh; the broker performs refresh sweeps and surfaces a live access token when the script executes.

See [#990](https://github.com/desplega-ai/agent-swarm/pull/990) and [#983](https://github.com/desplega-ai/agent-swarm/pull/983).

A workspace that is easier to recognize at a glance [#a-workspace-that-is-easier-to-recognize-at-a-glance]

Session titles are now customizable. You can set a clear, memorable name for each root task so histories and runs are easier to scan. The title is stored on the root task record and propagates to breadcrumbs, session listings, and the API. If unset, the system falls back to the initial user message or a generic label.

Agent avatars support custom icon selection plus color swatches (including free-form hex input). You can pick from a curated set of lucide icons and apply a deterministic shade, or enter any valid six-digit hex. Default avatars remain available for agents that have not been customized; resetting clears custom choices and reverts to the hash-derived defaults. The picker lives on the agent detail page and updates render immediately across the UI.

These presentation details make multi-agent environments and team workspaces faster to navigate, especially when you are tracking many concurrent sessions or hand off context between people and agents.

See [#1000](https://github.com/desplega-ai/agent-swarm/pull/1000) and [#1001](https://github.com/desplega-ai/agent-swarm/pull/1001).

Improvements [#improvements]

Claude Opus 5 in the model registry [#claude-opus-5-in-the-model-registry]

Claude Opus 5 models are now registered and selectable alongside existing Claude variants. The model list, context-window accounting, and cost reporting all recognize the new keys so downstream features (session summaries, spend tracking, prompt routing) continue to work without special configuration.

See [#1004](https://github.com/desplega-ai/agent-swarm/pull/1004).

MCP capability gating for self-hosted deployments [#mcp-capability-gating-for-self-hosted-deployments]

Self-hosters can now gate which MCP tool groups are exposed. Server-capability prompt gating and the full-surface scripts bridge provide deliberate access boundaries: individual tool families can be enabled or disabled at the seed level and the prompt surface reflects those decisions at session start.

Combined with the existing scripts system, this lets operators choose the exact surface area available to agents without broad allow/deny lists or post-hoc filtering. The capability matrix is documented alongside environment variable and MCP reference guides.

See [#996](https://github.com/desplega-ai/agent-swarm/pull/996).

Bug Fixes [#bug-fixes]

Workflow watchdogs now honor inline script timeouts [#workflow-watchdogs-now-honor-inline-script-timeouts]

Previously, a workflow step's watchdog could ignore the timeout configured directly on an inline script. The engine now correctly reads and applies that per-step timeout, so long-running inline scripts are terminated as expected rather than running to the global default or to completion.

This is especially relevant when you use inline `swarm-script` or raw source steps that perform external calls or heavy local work; the watchdog respects the field you set on the node.

See [#986](https://github.com/desplega-ai/agent-swarm/pull/986).


# Release Notes — Week of July 27 to August 3, 2026 (/docs/releases/2026-08-03)



Highlights [#highlights]

**61 commits** across **4 themes** landed this week (v1.121.0 → v1.126.0). The headline: you can now steer a running task mid-flight, every OpenRouter consumer routes through one configurable gateway, and the operator surface gets its own configuration page.

Steer a running agent task mid-flight [#steer-a-running-agent-task-mid-flight]

Long-running agent tasks are no longer fire-and-forget. You can now redirect, correct, or queue instructions to a running task across every harness provider — pi, claude, opencode, and codex — without killing and restarting it. Codex queue steering delivers guidance at the next tool-call boundary via harness-side lifecycle hooks, and the runner now reliably returns the agent's true final message as task output.

What this means in practice: a task that takes a wrong turn can be corrected in real time rather than discarded. Tasks are now a collaborative conversation, not a commit-and-pray.

See [#1014](https://github.com/desplega-ai/agent-swarm/pull/1014), [#1036](https://github.com/desplega-ai/agent-swarm/pull/1036), [#1039](https://github.com/desplega-ai/agent-swarm/pull/1039), [#1042](https://github.com/desplega-ai/agent-swarm/pull/1042), and [#1054](https://github.com/desplega-ai/agent-swarm/pull/1054).

Single OpenRouter gateway for all consumers [#single-openrouter-gateway-for-all-consumers]

`OPENROUTER_BASE_URL` now routes every OpenRouter consumer — summarizers, raters, raw-llm workflow nodes, all provider adapters — through one configurable endpoint. Point your stack at a proxy or self-hosted endpoint once instead of patching each consumer individually.

See [#1010](https://github.com/desplega-ai/agent-swarm/pull/1010).

Operator configuration page at /settings/configuration [#operator-configuration-page-at-settingsconfiguration]

All swarm configuration now lives in one place. Grouped cards with editable rows replace env-file hunting. The page surfaces every config key the operator needs to touch, with immediate save and validation.

See [#1017](https://github.com/desplega-ai/agent-swarm/pull/1017).

Honest MCP tool results [#honest-mcp-tool-results]

A new `SwarmToolResult` contract surfaces real success and error state from tool calls instead of opaque wrappers. Scripts can finally branch on what actually happened — a tool that returned an error produces a structured error result, not a silent failure. Oversized results are bounded and spilled to KV to protect context windows.

See [#1023](https://github.com/desplega-ai/agent-swarm/pull/1023), [#1027](https://github.com/desplega-ai/agent-swarm/pull/1027), and [#1032](https://github.com/desplega-ai/agent-swarm/pull/1032).

Live model catalog + dynamic UI picker [#live-model-catalog--dynamic-ui-picker]

A live endpoint now serves the current model catalog, driving a dynamic model picker in the UI that always reflects what's actually available. No more stale dropdowns.

See [#1022](https://github.com/desplega-ai/agent-swarm/pull/1022).

Mature memory retrieval on by default [#mature-memory-retrieval-on-by-default]

Graph-expansion and mature retrieval flags are enabled out of the box. New installs get richer recall without additional configuration.

See [#1002](https://github.com/desplega-ai/agent-swarm/pull/1002).

Improvements [#improvements]

* **Codex queue steering via harness lifecycle hooks** — Steering extends to codex by delivering queued guidance at the next tool-call boundary through harness hooks. See [#1036](https://github.com/desplega-ai/agent-swarm/pull/1036).

* **Script-workflow agent-task steps wait for terminal status** — `ctx.step.agentTask` now blocks until the task reaches a terminal state, removing flaky early-returns from script workflows. See [#1059](https://github.com/desplega-ai/agent-swarm/pull/1059).

* **Workflow definition lifecycle tracking** — Workflow definitions are now versioned and lifecycle-tracked alongside their runs. See [#1040](https://github.com/desplega-ai/agent-swarm/pull/1040).

* **Searchable Lucide avatar picker** — Agent avatars get an expanded, searchable Lucide icon picker with normalized search and result caps. See [#1011](https://github.com/desplega-ai/agent-swarm/pull/1011).

* **Activation-funnel telemetry** — Previously blind spots in the install-to-activation funnel are now instrumented. See [#1024](https://github.com/desplega-ai/agent-swarm/pull/1024).

* **Persistent Slack thread tree** — Ephemeral Slack task relays are replaced with a durable, navigable thread tree. See [#1056](https://github.com/desplega-ai/agent-swarm/pull/1056).

* **worker-slim Docker target** — A slimmer worker image with npx-skills installs and archil removal. See [#1021](https://github.com/desplega-ai/agent-swarm/pull/1021).

* **gh-stack extension preinstalled** — Workers ship with the official gh-stack extension. See [#1055](https://github.com/desplega-ai/agent-swarm/pull/1055).

* **Boot gated on control-plane readiness** — Workers wait for the control-plane API before booting, eliminating cold-start races. See [#1060](https://github.com/desplega-ai/agent-swarm/pull/1060).

* **65% faster CI root tests** — Root test wall time cut by 65%. See [#1062](https://github.com/desplega-ai/agent-swarm/pull/1062).

* **Third-party Actions pinned to commit SHAs** — Supply-chain safety through SHA pinning. See [#1048](https://github.com/desplega-ai/agent-swarm/pull/1048).

Bug Fixes [#bug-fixes]

* **Runner returns the agent's true final message** — Final output is delivered as task output; buffered output is scrubbed to prevent stale residue from leaking across runs. See [#1042](https://github.com/desplega-ai/agent-swarm/pull/1042) and [#1054](https://github.com/desplega-ai/agent-swarm/pull/1054).

* **MCP: bound oversized results + preserve full SDK responses** — Oversized tool results are bounded and spilled to KV; full SDK responses are preserved by the tool layer. See [#1032](https://github.com/desplega-ai/agent-swarm/pull/1032) and [#1037](https://github.com/desplega-ai/agent-swarm/pull/1037).

* **Script safety: KV CRUD + unknown args** — KV CRUD operations are safe from scripts; unknown top-level `ctx.api` args and repeated array params are rejected. See [#1043](https://github.com/desplega-ai/agent-swarm/pull/1043) and [#1049](https://github.com/desplega-ai/agent-swarm/pull/1049).

* **Slack thread tree hardening** — Truncated tree delivered before cleanup, v2 historical backfill prevented, v2 thread rendering finalized, card-link unfurls suppressed. See [#1020](https://github.com/desplega-ai/agent-swarm/pull/1020), [#1063](https://github.com/desplega-ai/agent-swarm/pull/1063), [#1065](https://github.com/desplega-ai/agent-swarm/pull/1065), and [#1070](https://github.com/desplega-ai/agent-swarm/pull/1070).

* **Devin health-check moved to v3** — Points the Devin health-check at v3 instead of the deprecated v1 API. See [#1016](https://github.com/desplega-ai/agent-swarm/pull/1016).

* **Workflow run list bounded** — Run list no longer returns unbounded rows. See [#1028](https://github.com/desplega-ai/agent-swarm/pull/1028).

* **Skills: baked vs seeded de-collision** — Baked and seeded skills no longer collide; bundled files are seeded correctly. See [#1044](https://github.com/desplega-ai/agent-swarm/pull/1044).

* **Production agent IDs removed from examples** — Real agent IDs scrubbed from example docs and configs. See [#1041](https://github.com/desplega-ai/agent-swarm/pull/1041).

* **GitHub: self-authored PR reviews ignored** — Self-authored reviews no longer counted toward review coverage. See [#1058](https://github.com/desplega-ai/agent-swarm/pull/1058).

* **Bun timeout flakes attributable** — Flaky Bun timeouts now produce attributable diagnostics. See [#1033](https://github.com/desplega-ai/agent-swarm/pull/1033).

Migration Notes [#migration-notes]

* **SwarmToolResult contract** — If you author custom scripts that consume tool results, move to the `SwarmToolResult` shape. Old opaque wrappers are superseded but not a runtime break — update proactively to stay current.

* **archil removal** — The archil tooling is removed from the worker image. Operators with archil-dependent workflows should move to the `worker-slim` target and npx-skills installs before upgrading.


# Release Notes — Week of August 3 to August 10, 2026 (/docs/releases/2026-08-10)



Highlights [#highlights]

**65 merged pull requests** across **5 themes** shipped from v1.126.0 through v1.130.0. This count is the 65 unique `(#NNNN)` references in `git log v1.126.0..v1.130.0` (64 PR-labeled commits; the unlabeled commit is the v1.127.0 release). The headline: agents can now build versioned, schema-backed internal apps, connect them to live sources, and use generated TypeScript types — while the platform gains complete typed API responses and a hardened script execution boundary.

Swarm Apps: from schema to live internal tool [#swarm-apps-from-schema-to-live-internal-tool]

Swarm Apps turns existing swarm primitives into versioned internal applications. Agents can define models and named queries, migrate schemas safely, compose reusable elements across apps, expose script or task actions, and render the result directly in the dashboard. Definition history, diffs, forward-only rollback, per-user configuration, RBAC, and compatibility checks make those apps safe to iterate instead of disposable prototypes.

Apps can now sync from scripts or swarm tasks, preserve source provenance, track freshness, and reconcile records through one guarded engine. Seven theme presets, per-app theming, motion, loading states, and inline form errors bring the rendered surface closer to a finished product.

See [#1066](https://github.com/desplega-ai/agent-swarm/pull/1066), [#1123](https://github.com/desplega-ai/agent-swarm/pull/1123), and [#1140](https://github.com/desplega-ai/agent-swarm/pull/1140).

Generated TypeScript types for every app [#generated-typescript-types-for-every-app]

Scripts now receive types generated from live app definitions. Each app contributes row interfaces, enum unions, action names, and typed query overloads to the script SDK and Monaco editor. Schema edits are reflected without a server restart, while the loose fallback keeps existing scripts source-compatible.

See [#1130](https://github.com/desplega-ai/agent-swarm/pull/1130).

Complete typed OpenAPI responses [#complete-typed-openapi-responses]

All **343 API routes** now declare their response shape. Roughly 80 named entity components, a shared error envelope, deterministic generation, and a merge-gate coverage check replace the previous 19 typed operations. SDK generators can now consume the whole API without falling back to untyped responses, and new uncovered routes fail CI.

See [#1141](https://github.com/desplega-ai/agent-swarm/pull/1141).

Workflow fan-out with a parent join [#workflow-fan-out-with-a-parent-join]

The new `foreach` workflow node fans an array out into one agent task per item, tracks each child under the parent run, and joins the results before continuing. Per-item interpolation, empty-array handling, failure-policy support, and crash recovery are built into the engine and visible in the workflow run graph.

Workflow scripts can also opt into wall-clock limits up to five minutes for I/O-bound work, while keeping a 60-second CPU ceiling.

See [#1093](https://github.com/desplega-ai/agent-swarm/pull/1093) and [#1089](https://github.com/desplega-ai/agent-swarm/pull/1089).

A tighter execution and proxy security boundary [#a-tighter-execution-and-proxy-security-boundary]

Script workflows and inline workflow scripts now launch with a clean environment, resource limits, bounded output, scoped working directories, and enforced termination. Dynamic workflow values must travel through arguments rather than being interpolated into executable source, and a follow-up closes Bun's trailing-argument flag re-parsing path.

The same hardening wave blocks SSRF through registered MCP servers, limits page proxy routes, gives page sessions their own persisted signing secret, enforces page ownership, and rejects public webhook calls for workflows that never declared a webhook trigger.

See [#1107](https://github.com/desplega-ai/agent-swarm/pull/1107), [#1112](https://github.com/desplega-ai/agent-swarm/pull/1112), [#1113](https://github.com/desplega-ai/agent-swarm/pull/1113), and [#1138](https://github.com/desplega-ai/agent-swarm/pull/1138).

Accurate cost and requester attribution [#accurate-cost-and-requester-attribution]

Session cost storage now preserves harness-reported cost beside the server recomputation, prices cache writes by TTL, handles per-model usage and web search, accumulates Codex turns, and deduplicates finalized OpenCode events. Drift is visible in telemetry and on task cost badges.

The Usage page adds per-user cost reporting, while requester backfills repair historical Slack, schedule, workflow, and child-task attribution without overwriting existing owners or crossing handoffs.

See [#1115](https://github.com/desplega-ai/agent-swarm/pull/1115), [#1132](https://github.com/desplega-ai/agent-swarm/pull/1132), and [#1134](https://github.com/desplega-ai/agent-swarm/pull/1134).

Improvements [#improvements]

* **Live multi-provider transcript events** — Codex, Claude, and OpenCode lifecycle, progress, tool, error, and sub-agent events render as readable session rows with a shared-axis agent waterfall. See [#1092](https://github.com/desplega-ai/agent-swarm/pull/1092) and [#1095](https://github.com/desplega-ai/agent-swarm/pull/1095).

* **Requester filtering in Tasks** — Filter the task list by a specific requester or by tasks with no requester. See [#1080](https://github.com/desplega-ai/agent-swarm/pull/1080).

* **Skills move to live DB seeding** — Swarm-owned and vendored skills now have one versioned, live-updatable delivery path with collision, parity, and upstream-integrity checks. See [#1083](https://github.com/desplega-ai/agent-swarm/pull/1083) and [#1106](https://github.com/desplega-ai/agent-swarm/pull/1106).

* **Host-agnostic MCP Registry publishing** — The project now publishes a registry entry for both remote and localhost package endpoints. See [#1135](https://github.com/desplega-ai/agent-swarm/pull/1135).

* **Current agent-fs deployment pins** — Helm and Compose now track agent-fs 0.12.2, with a drift check to keep backend image pins aligned. See [#1109](https://github.com/desplega-ai/agent-swarm/pull/1109) and [#1127](https://github.com/desplega-ai/agent-swarm/pull/1127).

* **Consistent CI timing** — The main-branch root suite uses the same four-way sharding as the merge gate, making PR timing comparisons meaningful. See [#1097](https://github.com/desplega-ai/agent-swarm/pull/1097).

Bug Fixes [#bug-fixes]

* **Terminal task results are conflict-safe** — Differing writes to an already-finished task are reported as conflicts; deliberate text-only corrections require `force: true` across both MCP and HTTP finish paths. See [#1082](https://github.com/desplega-ai/agent-swarm/pull/1082) and [#1084](https://github.com/desplega-ai/agent-swarm/pull/1084).

* **Slack acknowledgements settle cleanly** — Accepted messages move from in-progress reactions to a final success or failure reaction, render-v2 honors agent-authored replies, and outcome cards avoid duplicate text. See [#1081](https://github.com/desplega-ai/agent-swarm/pull/1081), [#1094](https://github.com/desplega-ai/agent-swarm/pull/1094), [#1103](https://github.com/desplega-ai/agent-swarm/pull/1103), [#1111](https://github.com/desplega-ai/agent-swarm/pull/1111), and [#1121](https://github.com/desplega-ai/agent-swarm/pull/1121).

* **Worker identity and startup permissions** — The entrypoint reclaims the Claude session directory and pins Git author/committer identity above repo-local config. See [#1078](https://github.com/desplega-ai/agent-swarm/pull/1078) and [#1105](https://github.com/desplega-ai/agent-swarm/pull/1105).

* **Profile edits survive boot reconciliation** — Persisted profile changes are no longer overwritten by stale startup files. See [#1116](https://github.com/desplega-ai/agent-swarm/pull/1116).

* **Attachment scope and filenames stay intact** — Agent-fs links require a complete org/drive pair, provider operations reject partial scope, and download filenames preserve safe punctuation through RFC 5987 encoding. See [#1118](https://github.com/desplega-ai/agent-swarm/pull/1118) and [#1126](https://github.com/desplega-ai/agent-swarm/pull/1126).

* **Approval responses enforce required fields** — Incomplete required approval payloads are rejected instead of entering the workflow. See [#1119](https://github.com/desplega-ai/agent-swarm/pull/1119).

* **Superseded tasks are terminal in the UI** — Session and task views no longer present superseded work as running or actionable. See [#1128](https://github.com/desplega-ai/agent-swarm/pull/1128).

* **MCP OAuth reuses dynamic clients** — Connector authorization persists and reuses one registered DCR client instead of creating an orphan on every attempt. See [#1124](https://github.com/desplega-ai/agent-swarm/pull/1124).

* **Session summaries use the real transcript** — Claude stream events are buffered and summarized through the compiled CLI path, with observable fallbacks across all harnesses. See [#1131](https://github.com/desplega-ai/agent-swarm/pull/1131) and [#1137](https://github.com/desplega-ai/agent-swarm/pull/1137).

* **Codex credentials survive standalone and reload modes** — Standalone OAuth files no longer masquerade as pool slots, and runtime config reloads preserve the model override. See [#1114](https://github.com/desplega-ai/agent-swarm/pull/1114).

* **Aborted script fetches stop immediately** — `runtimeFetch` honors already-aborted signals and does not retry caller-cancelled requests. See [#1146](https://github.com/desplega-ai/agent-swarm/pull/1146).

Migration Notes [#migration-notes]

* **Workflow script values belong in args** — Dynamic trigger or upstream values are no longer interpolated into executable script source. Pass them through the node's argument configuration and read them as data inside the script.

* **Regenerate API clients** — The OpenAPI document now contains typed responses for every route plus shared named components and error responses. Regenerating clients will replace many previously untyped return values with concrete types.

* **Cost totals may change after upgrade** — Cache-write TTLs, multi-model usage, web search, Codex turn accumulation, and OpenCode deduplication now participate in stored cost. Compare `harnessCostUsd` with the server result when auditing drift.

* **Page session signing is independent** — Page sessions resolve `PAGE_SESSION_SECRET`, then `PAGE_SESSION_SECRET_FILE`, and otherwise use an auto-generated persisted secret. The swarm API key is no longer used as the signing fallback.

Merged after v1.130.0 — ships in the next release [#merged-after-v11300--ships-in-the-next-release]

The following changes merged during the calendar week but are not ancestors of the v1.130.0 tag, so they are not included in the 65-PR release count above:

* **Token-derived dashboard identity** — Tabs authenticated with an `aswt_` user token derive identity from the server and hide misleading user-switching controls. Operator-key behavior remains unchanged. See [#1147](https://github.com/desplega-ai/agent-swarm/pull/1147).

* **App and script asset namespaces** — Apps and scripts receive validated resource keys with history, audits, moves, and API discovery support. See [#1150](https://github.com/desplega-ai/agent-swarm/pull/1150).

* **Unresolved script tokens fail before execution** — Inline and named workflow script bodies now reject unsupported workflow-style tokens with an explicit node and token diagnostic. After upgrading to the next release, pass dynamic values through script args; unsupported workflow-style tokens in script bodies will fail before execution. See [#1153](https://github.com/desplega-ai/agent-swarm/pull/1153).


# Release Notes — Week of August 24 to August 31, 2026 (/docs/releases/2026-08-31)



Highlights [#highlights]

Agent Swarm v1.135.1 and v1.135.2 turn several operational edge cases into recoverable states. Across 16 merged commits, this week’s work strengthens persistent storage, makes compiled deployments safer to start, and gives developers a smaller, more coherent API surface.

Agent-fs credentials recover automatically [#agent-fs-credentials-recover-automatically]

Agents can now recover their agent-fs credentials after a provisioning conflict. Previously, the conflict could leave an agent without the credentials needed to reconnect to its persistent files, even when the underlying storage account already existed. The provisioning path now repairs that missing connection automatically.

For managed and self-hosted swarms, this removes a high-impact manual recovery step from infrastructure that every durable workflow depends on. Agents can resume access to shared artifacts and persistent state instead of remaining stranded after an otherwise recoverable conflict. [#1265](https://github.com/desplega-ai/agent-swarm/pull/1265) (`73744ac2`)

Compiled deployments bootstrap the database safely [#compiled-deployments-bootstrap-the-database-safely]

Database startup is now reliable when Agent Swarm runs from Bun’s compiled filesystem. The migration runner no longer treats a successful directory read as proof that the baseline schema was found. It verifies the schema itself before deciding whether bootstrap work is complete.

This closes a subtle startup failure where a compiled deployment could silently skip its baseline schema. Operators get predictable initialization across source and compiled builds, while contributors get compatibility coverage for the Bun filesystem path. [#1263](https://github.com/desplega-ai/agent-swarm/pull/1263) (`4fa6c069`)

One consolidated API reference [#one-consolidated-api-reference]

The generated API reference now groups tasks, workflow events, workflow APIs, and task templates into a more compact navigation surface. Developers and agents can scan related endpoints without moving through fragmented tag pages, and the generator now preserves that organization as the OpenAPI source changes.

Both v1.135.1 and v1.135.2 include refreshed reference material, keeping the published documentation aligned with the server. [#1256](https://github.com/desplega-ai/agent-swarm/pull/1256) (`08547fc1`), [#1254](https://github.com/desplega-ai/agent-swarm/pull/1254) (`7158f97b`), [#1268](https://github.com/desplega-ai/agent-swarm/pull/1268) (`348ac62b`)

Improvements [#improvements]

* **Observable query limits.** `db-query` responses now expose row-truncation metadata. Callers can distinguish a complete result from a bounded response and decide whether to narrow, paginate, or rerun a query instead of assuming every returned row set is exhaustive. The OpenAPI definition and generated types reflect the same behavior. [#1260](https://github.com/desplega-ai/agent-swarm/pull/1260) (`f5d0c5c9`)

* **Predictable Sonnet 5 costs.** Sonnet 5 remains on its permanent launch pricing in the managed-model catalog. Teams can model ongoing usage against stable catalog values rather than treating the launch rate as temporary. Adapter coverage and pricing-source documentation were updated with the catalog change. [#1270](https://github.com/desplega-ai/agent-swarm/pull/1270) (`4cc59006`)

* **Safer migration authoring.** Contributor guidance now explains how to avoid migration ordinal collisions. This makes ordering expectations explicit and reduces the chance that independently authored migrations compete for the same position. [#1258](https://github.com/desplega-ai/agent-swarm/pull/1258) (`b4f246fc`)

* **Aligned deployment dependencies.** Worker harness packages were refreshed, the worker image and supported deployment examples now agree on agent-fs 0.13.2, and Docker Buildx moved to 4.3.0. Keeping these pins synchronized prevents version-drift gates from blocking deployments and keeps local, Compose, Helm, and worker-image paths on compatible tooling. [#1253](https://github.com/desplega-ai/agent-swarm/pull/1253) (`4f9361d2`), [#1261](https://github.com/desplega-ai/agent-swarm/pull/1261) (`11589e1f`), [#1267](https://github.com/desplega-ai/agent-swarm/pull/1267) (`26cf1be6`), [#1269](https://github.com/desplega-ai/agent-swarm/pull/1269) (`a7b2317b`), [#1257](https://github.com/desplega-ai/agent-swarm/pull/1257) (`998db89a`)

Bug Fixes [#bug-fixes]

* **MCP Registry releases publish current metadata.** The registry workflow now publishes more reliably and uses the current server version instead of leaving stale version data in `server.json`. Registry consumers get metadata that matches the shipped Agent Swarm release. [#1246](https://github.com/desplega-ai/agent-swarm/pull/1246) (`21bdb8d7`)

* **Slack replies remain gated while an agent is working.** The thread handler now waits for the active-agent lookup before deciding whether to buffer a reply. Messages no longer race ahead because an unresolved lookup was treated like a negative result, preserving orderly coordination between users and agents in active threads. [#1255](https://github.com/desplega-ai/agent-swarm/pull/1255) (`fe01a9a0`)

* **Root tests no longer leak shared state.** Slack-handler and artifact SDK coverage now isolate mutable test state across the root suite. This removes order-dependent interference and makes the release signal more trustworthy when the full suite runs together. [#1271](https://github.com/desplega-ai/agent-swarm/pull/1271) (`e8b4878e`)


# Release Notes — Week of August 31 to September 7, 2026 (/docs/releases/2026-09-07)



Highlights [#highlights]

Bring your own ACP runtime [#bring-your-own-acp-runtime]

Agent Swarm can now connect to generic Agent Client Protocol runtimes and expose ACP as a selectable runtime in the dashboard. Teams are no longer limited to a fixed adapter catalog: operators can add an ACP-compatible provider, see its supported capabilities, and assign it to an agent without editing backend configuration by hand. This turns Agent Swarm into a more flexible control plane for heterogeneous agent infrastructure. [#1320](https://github.com/desplega-ai/agent-swarm/pull/1320) [#1363](https://github.com/desplega-ai/agent-swarm/pull/1363)

Provider choice also appears earlier in onboarding, while runtime capability state is available to the product and operators. That makes it clearer which execution features are available before a team launches work, especially when an ACP runtime does not implement every optional capability. [#1315](https://github.com/desplega-ai/agent-swarm/pull/1315) [#1327](https://github.com/desplega-ai/agent-swarm/pull/1327)

Three new frontier models [#three-new-frontier-models]

Agents can now run GPT-6 Astra through Codex and select Claude Fable or Claude Mythos 5.1. Model metadata, pricing, reasoning controls, context limits, and cost normalization were updated with the new entries, so model selection and usage reporting remain consistent across the dashboard and runtime. [#1358](https://github.com/desplega-ai/agent-swarm/pull/1358) [#1303](https://github.com/desplega-ai/agent-swarm/pull/1303)

Microsoft 365 access and browser automation [#microsoft-365-access-and-browser-automation]

Microsoft Graph is now a built-in integration, giving connected agents a supported path to Microsoft 365 data. The full worker image also ships with `agent-browser` and seeded browser-QA guidance, so agents can reproduce frontend issues, capture evidence, and verify web workflows from the same execution environment. [#1318](https://github.com/desplega-ai/agent-swarm/pull/1318) [#1352](https://github.com/desplega-ai/agent-swarm/pull/1352) [#1345](https://github.com/desplega-ai/agent-swarm/pull/1345)

Improvements [#improvements]

* **Bounded database growth.** Self-hosted operators can opt into retention for non-critical database logs. Cleanup now drains in index order, completes under load, and reports telemetry so teams can control storage without losing visibility into the maintenance cycle. Retention remains disabled unless configured. [#1252](https://github.com/desplega-ai/agent-swarm/pull/1252) [#1299](https://github.com/desplega-ai/agent-swarm/pull/1299)

* **More actionable setup.** Onboarding exposes provider selection and a configurable image pull policy. The dashboard proposes next steps and surfaces first-task setup failures, replacing silent dead ends with a clear recovery path. [#1302](https://github.com/desplega-ai/agent-swarm/pull/1302) [#1308](https://github.com/desplega-ai/agent-swarm/pull/1308) [#1331](https://github.com/desplega-ai/agent-swarm/pull/1331)

* **Route-aware observability.** Inbound API spans are emitted as server work with resolvable routes, integration connections emit telemetry, and runtime feature state is available through stats. Operators can now distinguish traffic by endpoint and understand which capabilities are actually connected. [#1293](https://github.com/desplega-ai/agent-swarm/pull/1293) [#1324](https://github.com/desplega-ai/agent-swarm/pull/1324) [#1327](https://github.com/desplega-ai/agent-swarm/pull/1327)

* **Configurable deployments.** Builds can opt into Plausible analytics and choose the site ID, while demo deployments can use a fixed operating mode. Slack behavior also gained broader visual and black-box coverage. [#1309](https://github.com/desplega-ai/agent-swarm/pull/1309) [#1334](https://github.com/desplega-ai/agent-swarm/pull/1334) [#1319](https://github.com/desplega-ai/agent-swarm/pull/1319)

* **Sharper discovery and accounting.** Skill search handles multi-token queries more usefully, and memory-search consumption is counted per document for clearer usage accounting. [#1328](https://github.com/desplega-ai/agent-swarm/pull/1328) [#1264](https://github.com/desplega-ai/agent-swarm/pull/1264)

Bug Fixes [#bug-fixes]

* **Retries preserve workflow intent.** Retried steps rehydrate their context before execution, and external retries retain branch routing. Recovery no longer drops valid state or continues down the wrong workflow edge. [#1297](https://github.com/desplega-ai/agent-swarm/pull/1297) [#1298](https://github.com/desplega-ai/agent-swarm/pull/1298)

* **Tasks reach the correct owner.** Direct assignments are no longer blocked by inherited provenance, lead-only work remains lead-only, pool-starvation escalation is explicit, and UI-created follow-ups route to Lead. [#1362](https://github.com/desplega-ai/agent-swarm/pull/1362) [#1276](https://github.com/desplega-ai/agent-swarm/pull/1276) [#1344](https://github.com/desplega-ai/agent-swarm/pull/1344) [#1316](https://github.com/desplega-ai/agent-swarm/pull/1316)

* **Lifecycle state stays accurate.** Cancelling a workflow closes its pending approval requests. Reboot recovery avoids newly claimed tasks, sibling timestamps reflect the correct run, and attachment-backed tasks remain unavailable until their attachments exist. [#1300](https://github.com/desplega-ai/agent-swarm/pull/1300) [#1351](https://github.com/desplega-ai/agent-swarm/pull/1351) [#1281](https://github.com/desplega-ai/agent-swarm/pull/1281) [#1273](https://github.com/desplega-ai/agent-swarm/pull/1273)

* **Capacity pressure is retryable.** Sandboxes have more process headroom and classify resource pressure as capacity exhaustion, allowing callers to retry instead of treating a transient condition as a permanent script failure. [#1326](https://github.com/desplega-ai/agent-swarm/pull/1326)

* **Storage and worker startup recover cleanly.** Runner registration retries agent-fs provisioning, deployment paths now carry agent-fs 0.13.5, and startup scripts remain writable and readable across container restarts. [#1355](https://github.com/desplega-ai/agent-swarm/pull/1355) [#1347](https://github.com/desplega-ai/agent-swarm/pull/1347) [#1350](https://github.com/desplega-ai/agent-swarm/pull/1350) [#1360](https://github.com/desplega-ai/agent-swarm/pull/1360)

* **Credentials and environment-sensitive tests are safer.** Codex OAuth honors custom home-directory overrides, nightly end-to-end runs preserve credential blobs, and sandbox and attachment tests now fail or skip for the correct environmental reason. [#1313](https://github.com/desplega-ai/agent-swarm/pull/1313) [#1348](https://github.com/desplega-ai/agent-swarm/pull/1348) [#1337](https://github.com/desplega-ai/agent-swarm/pull/1337) [#1354](https://github.com/desplega-ai/agent-swarm/pull/1354)


# Release Notes (/docs/releases)



Weekly updates on what's shipping in Agent Swarm. Each release covers new features, improvements, bug fixes, and breaking changes from the past week.

Releases are published every Monday and link directly to the relevant PRs and commits.


# Configuration (/docs/ui/configuration)







The **Configuration** page (Settings → Configuration, at `/settings/configuration` in the dashboard) surfaces the environment variables that shape how your swarm behaves — steering, memory retrieval, heartbeat and crash recovery, harness selection, integration toggles, security, workflow limits, and branding — as editable settings, so you can tune a running deployment without shelling into the host to edit `.env` files.

<img alt="The Configuration settings page" src="__img0" />

How values resolve [#how-values-resolve]

Every setting on this page corresponds to an environment variable the API server reads. Saving a value from the dashboard stores it as a **global swarm config** entry (the same `swarm_config` store used by the `get-config` / `set-config` MCP tools), and the server applies it via an automatic config reload shortly after the save.

Precedence rules:

* **At boot**, real environment variables win: a variable set in the server's environment takes precedence over a stored value until the next reload.
* **After a save or reload**, stored values win: the server re-injects global config over the process environment.
* Settings marked with a **Restart required** badge are only read at server startup — saving them stores the value, but it takes effect on the next restart.

Each row shows a source chip when the underlying environment variable is set on the server, so you can tell whether the effective value comes from the environment, the stored config, or both.

Groups [#groups]

| Group                      | Examples                                                                                                                                                                                                                                                                                                                                                                      |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Steering                   | `STEERING_ENABLED`, `SLACK_THREAD_STEERING`, `SLACK_THREAD_STEERING_MODE`                                                                                                                                                                                                                                                                                                     |
| Memory                     | `MEMORY_HYBRID_SEARCH`, `MEMORY_GRAPH_EXPANSION`, `MEMORY_RATERS`, `EMBEDDING_MODEL`                                                                                                                                                                                                                                                                                          |
| Heartbeat & crash recovery | `HEARTBEAT_INTERVAL_MS`, `HEARTBEAT_STALL_THRESHOLD_MIN`, `RUNTIME_STALE_THRESHOLD_MIN`, `HEARTBEAT_PIN_CRASH_RESUME`                                                                                                                                                                                                                                                         |
| Harness & tools            | `SCRIPTS_ONLY_MCP`, `MULTI_RUNTIME_ENABLED`, `CAPABILITIES`, `OPENROUTER_BASE_URL`, `WORKER_API_READY_TIMEOUT_SECONDS`                                                                                                                                                                                                                                                        |
| Database                   | `DB_QUERY_BOUNDED_ENABLED`, `DB_QUERY_HTTP_BUDGET_MS`, `DB_QUERY_HTTP_MAX_ROWS`, `DB_QUERY_MCP_BUDGET_MS`, `DB_QUERY_MCP_MAX_ROWS`, `DB_QUERY_CONCURRENCY_CAP`, `SESSION_LOG_RETENTION_DAYS`, `AGENT_LOG_RETENTION_DAYS`, `EVENTS_RETENTION_DAYS`, `DB_RETENTION_DRY_RUN`, `DB_RETENTION_TICK_BUDGET_MS`, `DB_RETENTION_CATCHUP_INTERVAL_MS`, `DB_RETENTION_MAX_STATEMENT_MS` |
| Integrations               | `SLACK_DISABLE`, `SLACK_ALLOW_DEV_SOCKET_MODE`, `SLACK_RENDER_V2`, `GITHUB_DISABLE`, `LINEAR_DISABLE`, `SLACK_ALERTS_CHANNEL`, `AGENT_FS_REQUEST_TIMEOUT_MS`, `SLACK_REACTION_ACCEPTED`, `SLACK_REACTION_BUFFERED`, `SLACK_REACTION_NOW`, `SLACK_REACTION_STEERED`, `SLACK_REACTION_COMPLETED`, `SLACK_REACTION_FAILED`                                                       |
| Security & access          | `RBAC_ENABLED`, `BUDGET_ADMISSION_DISABLED`, `MCP_OAUTH_ALLOW_PRIVATE_HOSTS`                                                                                                                                                                                                                                                                                                  |
| Workflows & scheduler      | `WORKFLOW_MAX_ITERATIONS`, `SCHEDULER_INTERVAL_MS`                                                                                                                                                                                                                                                                                                                            |
| Telemetry & observability  | `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_SERVICE_NAME`, `ANONYMIZED_TELEMETRY`                                                                                                                                                                                                                                                                                                    |
| Branding & URLs            | `SWARM_ORG_NAME`, `SWARM_BRAND_COLOR`, `DASHBOARD_URL`                                                                                                                                                                                                                                                                                                                        |

Harness **provider and model selection are deliberately absent** from this page — they are configured per agent. A global value would silently become the default for every agent in the swarm, so those knobs live on each agent's own configuration instead. `OPENROUTER_BASE_URL` is a deployment-wide routing endpoint rather than a model choice, which is why it does belong here.

Settings with deeper behavior link out to the relevant guide (for example the [task steering](/docs/guides/task-steering) and [harness providers](/docs/guides/harness-providers) guides) directly from the row.

<Callout type="warn" title="Database retention permanently deletes history">
  Leave `SESSION_LOG_RETENTION_DAYS`, `AGENT_LOG_RETENTION_DAYS`, and `EVENTS_RETENTION_DAYS` unset to disable their individual sweeps. Each value must be between 1 and 1,000,000 whole days. We recommend at least seven days so recent task recovery and memory rating keep their context. Start with `DB_RETENTION_DRY_RUN=true`, verify the exact would-delete counts, then enable one table at a time. Retention deletes session transcripts, task history, or telemetry. Event aggregate totals become retention-window totals after an event sweep.
</Callout>

<img alt="Editing a configuration group" src="__img1" />

Editing [#editing]

Controls match the value type:

* **Toggles** (feature flags) persist immediately when switched.
* **Selects** (enumerated values such as `SLACK_THREAD_STEERING_MODE`) persist on change.
* **Text and number inputs** show a Save button once the value differs from what is stored.

Every row shows its default, and rows with a stored value offer a **Reset** action that deletes the stored entry and returns the setting to its default (or to the environment value, if one is set). The **Reload config** button in the header forces an immediate config reload — useful after changing environment variables outside the dashboard.

<Callout type="info" title="Credentials live elsewhere">
  This page intentionally excludes secrets. Integration credentials (Slack tokens, GitHub app keys, OAuth secrets) are managed on the [Integrations](/docs/integrations/slack) pages, and swarm-wide secret values on the Secrets page — see the [secrets encryption guide](/docs/guides/secrets-encryption).
</Callout>

<Callout type="info" title="Multiple runtimes per agent">
  `MULTI_RUNTIME_ENABLED` is off by default: one worker process serves one agent, and that worker's reported concurrency becomes the agent's task limit. Turning it on lets several processes serve the same agent — each is tracked with its own capacity and liveness, and the agent's limit moves to its agent-scoped `AGENT_MAX_TASKS` setting (seeded from the agent's current limit the first time it registers, so enabling the flag does not change what the swarm enforces).

  Workers must be updated before enabling it: with the flag on, a worker that does not identify its runtime is rejected at registration and shutdown. A worker that stops reporting for `RUNTIME_STALE_THRESHOLD_MIN` (default 5 minutes) stops counting toward its agent, and an agent whose last live worker expires is marked offline. See the [multi-runtime agents guide](/docs/guides/multi-runtime-agents) for the rollout order, shared-workspace Compose example, rollback behavior, and the [heartbeat and crash recovery runbook](https://github.com/desplega-ai/agent-swarm/blob/main/runbooks/heartbeat-crash-recovery.md).

  **Workspace state is not shared for you.** Work for one agent can continue on a different process than the one that started it, so runtimes sharing an `AGENT_ID` need compatible workspace state. In Docker Compose or similar deployments, give them the same persistent workspace/repository volume when continuation depends on local files. If their workspaces are independent, task continuation must rely only on state that can be reconstructed from shared sources — the repository, the control plane, or attachments — because enabling this setting does not synchronize arbitrary local filesystem state between workers.
</Callout>

<Callout type="info" title="OPENROUTER_BASE_URL points the harnesses at a model gateway">
  This is the one exception to the "no model knobs here" rule above, and it is not a model choice: it sets *where* OpenRouter-shaped requests go, not *which* model runs. Point it at any gateway serving OpenRouter-compatible `GET /models` and `POST /chat/completions` — openrouter.ai, OrcaRouter, or a self-hosted proxy — and the OpenCode and pi-mono harnesses, model refreshes, and internal summarizers all route through it. Leave it blank for openrouter.ai.

  A saved value reaches both sides without a restart. The API server re-injects it over its process environment on the debounced config reload. Each worker fetches the resolved config before starting a task and passes it into the harness adapter, so the change lands on that worker's next task; a task already running keeps the gateway it started with.

  The gateway credential is **not** set here — `OPENROUTER_API_KEY` belongs on the Secrets page or in the worker environment. Setup for each harness, model-string rules, and which harnesses this cannot redirect: [Model Gateways](/docs/guides/provider-auth/model-gateways).
</Callout>

<Callout type="warn" title="WORKER_API_READY_TIMEOUT_SECONDS is bootstrap-only">
  Every worker and lead container's `docker-entrypoint.sh` polls the control-plane API's public `GET /health` endpoint before doing anything else that depends on it (fetching config, syncing skills, restoring services). If the API never becomes reachable within `WORKER_API_READY_TIMEOUT_SECONDS` (a positive integer, default `90`), the container logs a stable `[entrypoint] FATAL: API readiness timed out after Ns waiting for <url>; exiting.` line and exits non-zero rather than starting half-provisioned.

  This check runs **before** the container can reach the API at all, so it can only ever read the value from its own process environment — a row saved here documents and validates the setting (and is what the **Restart required** badge refers to), but it cannot reach an already-waiting container. Set it as a real deployment environment variable on the worker/lead container, not just in the dashboard.
</Callout>

Access control [#access-control]

Reading configuration requires dashboard access (API key). Writes go through the config API's RBAC gate (`config.write.any`): operator and user tokens can save, and agent principals are restricted to lead agents.

Adding a new setting [#adding-a-new-setting]

If you are contributing a new operator-facing environment variable to Agent Swarm, register it in the configuration catalog at `apps/ui/src/lib/configuration-catalog.ts` (group, value kind, default, description, and docs link) so it appears on this page. Reserved bootstrap keys (`API_KEY`, `SECRETS_ENCRYPTION_KEY`) and credentials must never be added to the catalog.


# Overview (/docs/ui)



The Agent Swarm **dashboard** is the web UI for operating a swarm: watch agents, tasks, and inter-agent chat in real time, review approvals, manage workflows, scripts, schedules, skills, and memory, and administer settings — secrets, API keys, integrations, and [swarm configuration](/docs/ui/configuration).

Apps [#apps]

The **Apps** surface hosts schema-backed applications created by agents. Use the [Apps guide](/docs/apps) to decide when to build one, understand the definition primitives, and start from a complete example. See the [Apps API reference](/docs/api-reference/apps) for lifecycle endpoints.

Appearance [#appearance]

Settings → Appearance lets each browser choose light, dark, or system mode and select a built-in visual preset. The preference stays local to the browser connection. Apps may request their own canvas theme, while viewers can override it without changing the app definition.

Usage attribution [#usage-attribution]

The Usage page can filter costs by canonical requester and shows **Cost by User** alongside the overall totals. Spend tied to a user is attributed to that identity; background work without a requester is grouped as &#x2A;*Unattributed (autonomous)** so operator-driven and autonomous spend remain distinguishable.

When a dashboard tab authenticates with a user-bound `aswt_` token, the dashboard resolves the principal from `GET /api/whoami` and locks the tab to that user. Identity switching and local identity overrides stay hidden so requester and audit attribution cannot diverge from the bearer token. Operator-key sessions keep the existing user picker.

Task list filters [#task-list-filters]

The Tasks page keeps its filters in the URL so views can be bookmarked and shared. In addition to status, agent, schedule, heartbeat visibility, and text search, the **Requested by** facet can show work attributed to a specific user, **Me** for the current dashboard identity, or **Unattributed** for tasks without a canonical requester. The requester filter is single-select and is never enabled by default.

Hosted dashboard [#hosted-dashboard]

We host the latest build at &#x2A;*[app.agent-swarm.dev](https://app.agent-swarm.dev)**.

It is a **browser-storage-only** client: operator connections you enter (API URL + API key) are kept in browser local storage, and requests go straight from your browser to your swarm's API. Embedded `?apiUrl=...&apiKey=aswt_...` user-token connections are tab-local in session storage, so separate tabs cannot overwrite one another's identity. Nothing is proxied or persisted on our side, and the same hosted dashboard can connect to **any** swarm — a local dev server, your self-hosted deployment, or a cloud swarm. Operator-key sessions can save multiple connections and switch between them from the sidebar.

<Callout type="info" title="Connecting">
  Enter your API URL and key in the in-app connection panel, or open the dashboard with `?apiUrl=...&apiKey=...` query parameters to pre-fill a connection (the `onboard` wizard prints exactly such a URL). Your swarm's API must be reachable from your browser.
</Callout>

Self-hosting [#self-hosting]

The dashboard is a static single-page app (Vite + React) in [`apps/ui/`](https://github.com/desplega-ai/agent-swarm/tree/main/apps/ui) — there is no server component, so it can be served from any static host:

```bash
cd apps/ui
bun install
bun run build   # outputs apps/ui/dist/
```

Deploy the `dist/` directory to your static host of choice (Vercel, Netlify, nginx, S3 + CDN, ...).

Self-hosted builds contain no analytics. The hosted dashboard sets the build-time flag `VITE_PLAUSIBLE_ANALYTICS=1`, which injects a [Plausible](https://plausible.io) snippet; leave it unset to keep your build analytics-free. `VITE_PLAUSIBLE_SCRIPT_ID` selects the Plausible site for a second deployment such as the public demo.

For local development:

```bash
cd apps/ui && bun install && bun run dev
```

This serves the dashboard at `http://localhost:5274` and proxies `/api/*` to a local API server on `http://localhost:3013`.


# Ralph Loop (/docs/receipts/workflows/ralph-loop)



> A reusable agent-swarm workflow that runs an agent in a tight feedback loop with a separate analyst, until the goal is met or a max-iteration safety cap fires. Inspired by [awesomeclaude.ai/ralph-wiggum](https://awesomeclaude.ai/ralph-wiggum).

<Callout type="info" title="Copy for your swarm">
  Paste this prompt into a Claude session connected to your deployed swarm's **lead agent** to auto-configure the Ralph loop workflow:

  ```text
  Hey, please configure the Ralph loop workflow in this swarm. Read the spec at
  https://docs.agent-swarm.dev/docs/receipts/workflows/ralph-loop, download the
  JSON workflow definition from
  https://docs.agent-swarm.dev/receipts/workflows/ralph-loop.json, and create
  the workflow in our swarm via the create-workflow MCP tool.

  Adapt the placeholders before creating it:
  - <your-coder-agent-id>     → an agentId in this swarm that should run the iterator/analyst tasks
  - <your-org-id>             → the agent-fs org/drive ID this swarm uses for shared state
  - <your-slack-channel-id>   → the Slack channel that should receive success / max-reached / validation notifications

  Then trigger it once with a small `goal` and `maxIterations: 3` to confirm it runs end-to-end.
  ```
</Callout>

<Callout type="info" title="Download">
  [**`ralph-loop.json`** — sanitized workflow definition](/receipts/workflows/ralph-loop.json) (placeholders for agent IDs, org IDs, and Slack channels)
</Callout>

TL;DR [#tldr]

The Ralph loop is a 9-node workflow that gives any agent two superpowers:

1. **Persistent scratch space** — every iteration writes to `agent-fs` under `ralph/YYYY-MM-DD/<slug>/` so context survives across iterations.
2. **A second pair of eyes** — after every iteration a separate analyst (different prompt, different lens) independently decides `done | continue | unsure` by reading the artifacts, not by trusting the iterator.

It's generic. The only required trigger params are `goal` (a free-form string) and `maxIterations` (an int). Anything else you put on the trigger is forwarded to every node as `triggerExtras`, so the same workflow handles "increment a counter to 5" and "drain 10 Linear sub-issues into stacked PRs" without code changes.

***

The pattern [#the-pattern]

```
trigger ─▶ validate ─▶ init ─▶ bump ─▶ work ─▶ max-check ─▶ analysis ─▶ route
                                  ▲                                       │
                                  └────────── continue ───────────────────┘
                                                  │
                                                  ├── done   ──▶ 🟢 success
                                                  └── max     ──▶ 🔴 max-reached
```

Each node has one job:

| Node               | Type           | Model  | Job                                                                                                                                                        |
| ------------------ | -------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `validate-trigger` | script (bash)  | —      | Reject if `goal` or `maxIterations` missing/invalid.                                                                                                       |
| `init`             | agent-task     | haiku  | Create `ralph/YYYY-MM-DD/<slug>/` in agent-fs, write `meta.json` with the trigger.                                                                         |
| `bump-iteration`   | agent-task     | haiku  | Find the highest existing `iteration-NNN.md`, write `iteration-(N+1).md` template. Returns the next iteration number.                                      |
| `ralph-iteration`  | agent-task     | sonnet | **The work.** Do exactly ONE step toward the goal. Write artifacts to `basePath/`. Fill in the iteration log (Plan / Work log / Output / Self-assessment). |
| `max-check`        | script (bash)  | —      | If `iteration >= maxIterations`, route to `max-reached`; else continue.                                                                                    |
| `analysis`         | agent-task     | haiku  | Independently read `meta.json` + the iteration log + artifacts. Decide `done \| continue \| unsure`. Write `analysis-NNN.md` with reasoning.               |
| `route`            | property-match | —      | If `analysis.decision == "done"`, exit success. Otherwise loop back to `bump-iteration`.                                                                   |
| `success`          | notify         | —      | 🟢 Slack message with the workdir path.                                                                                                                    |
| `max-reached`      | notify         | —      | 🔴 Slack message with the workdir path so a human can inspect.                                                                                             |

Why two agents (iterator + analyst)? [#why-two-agents-iterator--analyst]

The iterator is biased — it just did the work and wants to be done. The analyst is fresh — it reads the artifacts cold. Splitting the role catches three failure modes that single-agent loops hit constantly:

* **Premature "done"** — iterator says `believeComplete=true`, analyst sees the artifact's actually wrong.
* **Stuck "continue"** — iterator can't make progress, analyst notices the work product is fine and the iterator is just confused about its own state.
* **Drift** — iterator slowly stops checking its own artifacts (it just remembers what it wrote), analyst forces a re-read every iteration.

The analyst is haiku — cheap. The iterator is sonnet — capable. Cost per iteration ends up dominated by the actual work, not the verification loop.

State persistence — `agent-fs` [#state-persistence--agent-fs]

Every iteration's artifacts live in `agent-fs` (the swarm's persistent shared filesystem) under `ralph/YYYY-MM-DD/<slug>/`. Typical layout after a 5-iteration run:

```
ralph/2026-04-30/counter-test/
├── meta.json
├── counter.txt              ← iterator's working artifact
├── iteration-001.md
├── iteration-002.md
├── iteration-003.md
├── iteration-004.md
├── iteration-005.md
├── analysis-001.md
├── analysis-002.md
├── analysis-003.md
├── analysis-004.md
└── analysis-005.md
```

The iteration log is a four-section markdown file: **Plan**, **Work log**, **Output**, **Self-assessment**. `meta.json` carries the original trigger so any iteration can reach back to read goal-specific extras.

<Callout type="warn" title="The agent-fs --org gotcha">
  `agent-fs` has two drives — personal (per-agent) and shared (per-org). Without `--org <orgId>`, every command defaults to the **personal** drive. If the iterator writes to one drive and the analyst reads the other, the analyst sees an empty workdir and falsely concludes "files missing". Every `agent-fs ls/cat/write` call in the workflow's prompts must pass `--org <your-org-id>`. (We hit this once on a counter test; the analyst said "file missing" through 4 iterations even though the file was right there.)
</Callout>

Trigger contract [#trigger-contract]

```jsonc
{
  "goal": "Build a counter that reaches 5",   // required, string
  "maxIterations": 10,                         // required, positive int
  "slug": "counter-test",                      // optional, kebab-case workdir slug
  // ...any other fields are forwarded as `triggerExtras` and visible to every node
  "targetCounter": 5
}
```

Cost characteristics [#cost-characteristics]

From a real production run (10 iterations, complex implementation work — drained 10 Linear sub-issues as stacked PRs):

| node                     | model  | execs  | cost       | avg time              |
| ------------------------ | ------ | ------ | ---------- | --------------------- |
| `ralph-iteration` (work) | sonnet | 14     | $15.94     | 6.8 min               |
| `analysis`               | haiku  | 13     | $1.71      | 1.5 min               |
| `bump-iteration`         | haiku  | 14     | $1.43      | 1.2 min               |
| `init`                   | haiku  | 1      | $0.15      | 1.7 min               |
| **total**                |        | **42** | **$19.22** | **\~1.5h wall-clock** |

(`14 > 10` execs on iteration & bump nodes = \~4 transient retries — the runtime auto-recovered without human intervention.)

The verification cost (analysis + bump + init = $3.29) is \~17% of total. The work itself (sonnet) is the expensive part. Cache hits dominate — 47.2M cache reads vs 2.97M fresh inputs.

Adapting the loop — concrete example [#adapting-the-loop--concrete-example]

We used this same workflow shape (with the iteration prompt swapped out) to drive a "Linear sub-issue drain" — given a parent Linear issue with N sub-issues, ship each as a PR in a stacked chain.

The `ralph-iteration` prompt became:

1. Pick the next un-shipped sub-issue from the `triggerExtras.subIssues` array.
2. Implement its spec on a fresh branch off the previous PR's branch.
3. Run quality gates, commit, push, `gh pr create --base <previousBranch>`.
4. Move the Linear sub-issue to `In Review`.

The analyst prompt verified via `gh pr view` and Linear API that each step actually happened. After 10 iterations: 10 PRs landed, all open, all sub-issues moved. Same workflow, different prompt.

The shape stays. The work changes.

Workflow JSON [#workflow-json]

The full workflow definition is available as a downloadable static asset:

* [`ralph-loop.json`](/receipts/workflows/ralph-loop.json) — sanitized, with `<your-coder-agent-id>`, `<your-org-id>`, and `<your-slack-channel-id>` placeholders.

To install it manually, replace the placeholders with values from your swarm and pass the resulting object as the `definition` field of [`mcp__agent-swarm__create-workflow`](/docs/api-reference/workflows). Or use the "Copy for your swarm" prompt at the top of this page and let your lead agent do it for you.


# Codex OAuth (/docs/guides/provider-auth/codex-oauth)



Codex workers can authenticate in three different ways:

1. `OPENAI_API_KEY`
2. A pre-seeded `~/.codex/auth.json`
3. ChatGPT OAuth stored centrally in the swarm config store as `codex_oauth_0`, `codex_oauth_1`, ... (multi-credential pool)

This page covers the third option: **run the login flow once (or multiple times for a pool), then let Codex workers restore the credential automatically at boot**.

When To Use This [#when-to-use-this]

Use Codex OAuth when you want Codex workers to authenticate with your ChatGPT account instead of a raw OpenAI API key.

This is useful when:

* you already use Codex locally with ChatGPT OAuth
* you do not want to distribute `OPENAI_API_KEY` to every worker
* you want a shared Codex OAuth pool per swarm instead of per-worker credentials

<Callout type="warn">
  `codex_oauth` is currently stored at **global swarm scope**. One successful login applies to all Codex workers that can reach that same swarm API and database.
</Callout>

Worker Requirements [#worker-requirements]

Every worker that should use this path needs:

```bash
HARNESS_PROVIDER=codex
API_KEY=your-shared-swarm-api-key
MCP_BASE_URL=http://api:3013
AGENT_ID=stable-worker-uuid
```

Notes:

* `OPENAI_API_KEY` is **not required** for this flow.
* `AGENT_ID` should stay stable across restarts so task resume works correctly.
* `MCP_BASE_URL` must point to the same swarm API that stores `codex_oauth`.

How The Flow Works [#how-the-flow-works]

The login flow happens on **your machine**, not inside the container:

1. You run `agent-swarm codex-login` from your laptop or local terminal.
2. The command opens a browser and completes the ChatGPT OAuth flow.
3. Agent Swarm stores the resulting credential in the API config store as `codex_oauth`.
4. Codex workers fetch that credential during container boot.
5. The entrypoint converts it into the real `~/.codex/auth.json` format expected by the Codex CLI.

That means the worker never needs a separate OAuth prompt.

Local Setup [#local-setup]

Start the API first, then run the login flow:

```bash
bun run start:http
```

In another terminal:

```bash
bun run src/cli.tsx codex-login
```

The command prompts for:

* swarm API URL
* swarm API key

If your terminal supports raw-mode input, the API key is masked.

For a default local setup:

* API URL: `http://localhost:3013`
* API key: `123123`

After the login completes, restart any local Codex worker containers so they restore the stored credential on boot.

Remote Docker Compose Setup [#remote-docker-compose-setup]

For a remote swarm, keep two URLs in mind:

* **Laptop-facing API URL**: the public URL you use when running `codex-login`
* **Worker-facing API URL**: the internal URL used by containers in `MCP_BASE_URL`

These can be different as long as they reach the **same** swarm API and database.

Example:

```bash
# run from your laptop
bun run src/cli.tsx codex-login \
  --api-url https://swarm.example.com \
  --api-key YOUR_API_KEY
```

Worker config inside Docker Compose might still be:

```bash
HARNESS_PROVIDER=codex
API_KEY=YOUR_API_KEY
MCP_BASE_URL=http://api:3013
AGENT_ID=c3b4d5e6-7890-12fa-bcde-345678901bcd
```

After the login completes, restart Codex workers:

```bash
docker compose restart worker-codex
```

If the remote API is not publicly reachable, use an SSH tunnel:

```bash
ssh -L 3013:localhost:3013 your-server
bun run src/cli.tsx codex-login --api-url http://localhost:3013 --api-key YOUR_API_KEY
```

Verification [#verification]

Verify the credential was stored [#verify-the-credential-was-stored]

```bash
curl -H "Authorization: Bearer YOUR_API_KEY" \
  "http://localhost:3013/api/config/resolved?includeSecrets=true&key=codex_oauth"
```

Verify the worker restored it [#verify-the-worker-restored-it]

```bash
docker logs <codex-worker-container> 2>&1 | grep "Restored codex OAuth credentials"
```

Verify a task uses Codex OAuth [#verify-a-task-uses-codex-oauth]

Run a trivial task, then inspect the task record:

```bash
curl -H "Authorization: Bearer YOUR_API_KEY" \
  "http://localhost:3013/api/tasks/<task-id>"
```

You should see:

* `credentialKeyType: "CODEX_OAUTH"`
* `credentialKeySuffix: "..."`

The API Keys dashboard should also show a `codex-oauth` credential entry.

Troubleshooting [#troubleshooting]

Worker says `Token data is not available` [#worker-says-token-data-is-not-available]

This usually means the worker did not get a valid Codex `auth.json`.

Check:

* `codex_oauth` exists in the config store
* the worker was restarted after login
* the worker can reach `MCP_BASE_URL`

`codex-login` worked locally but workers still do not pick it up [#codex-login-worked-locally-but-workers-still-do-not-pick-it-up]

Check that your laptop and the workers are talking to the same swarm API/database. A common mistake is logging into staging while the worker points at production, or vice versa.

I want different credentials for different workers [#i-want-different-credentials-for-different-workers]

The Codex OAuth pool (see [Multi-Credential Pool](#multi-credential-pool) above) runs at **global swarm scope** — all workers share the same pool, and the runner selects a slot per task. There is no per-worker or per-agent slot affinity by design.

If you need true per-worker credential isolation, use separate swarm deployments or `OPENAI_API_KEY` instead.

Multi-Credential Pool [#multi-credential-pool]

Migration 071 (`src/be/migrations/071_codex_oauth_pool.sql`) added `api_key_status` rows for `CODEX_OAUTH` keys, enabling the same rate-limit-aware pool logic already used for Claude OAuth tokens.

Provisioning slots [#provisioning-slots]

Run `codex-login` once per account you want to add to the pool:

```bash
# First account → stored as codex_oauth_0
bun run src/cli.tsx codex-login --api-url http://localhost:3013 --api-key YOUR_API_KEY

# Second account → stored as codex_oauth_1
bun run src/cli.tsx codex-login --api-url http://localhost:3013 --api-key YOUR_API_KEY
```

Each successive `codex-login` call increments the slot index automatically (`codex_oauth_0`, `codex_oauth_1`, `codex_oauth_2`, …). You can provision as many slots as you have ChatGPT accounts.

If you need to target a specific slot, pass `--slot <n>`:

```bash
# Store credentials directly in codex_oauth_42
bun run src/cli.tsx codex-login --api-url http://localhost:3013 --api-key YOUR_API_KEY --slot 42
```

Explicit slots accept integers from `0` through `100`. Without `--slot`, `codex-login` still picks the next free slot automatically.

How slot selection works [#how-slot-selection-works]

At task-spawn time the runner:

1. Calls `loadAllCodexOAuthSlots` to enumerate all `codex_oauth_N` keys from the config store.
2. Queries `GET /api/keys/available?keyType=CODEX_OAUTH&totalKeys=<N>` to get the list of non-rate-limited slot indices.
3. Picks **randomly** from the available slots. If the availability endpoint is unreachable, falls back to random pick across all slots (best-effort).
4. Calls `materializeCodexAuthJson` to write the selected slot's credentials into `~/.codex/auth.json` atomically (tmp → rename).
5. Passes `codexSlot` through to the adapter so token refreshes write back to the correct slot key.

Selection is **global** — there is no per-agent or per-task affinity. Any worker can pick any slot.

Token-refresh write-back [#token-refresh-write-back]

When the Codex CLI refreshes an OAuth token mid-session, the adapter writes the new credentials back to `codex_oauth_<picked-slot>` via `persistCodexOAuth`. The legacy `codex_oauth` key and other slots are never touched by the refresh path.

Rate-limit detection [#rate-limit-detection]

The adapter's `formatTerminalError` method matches incoming Codex error messages against two patterns:

* `[rate-limit]` — HTTP 429 / per-minute / per-hour API rate limit. Prefix added by the `rate limit`, `rate_limit`, `too many requests`, or similar patterns.
* `[usage-limit]` — Monthly quota exhausted (`usage limit`, `upgrade to pro`, `usagelimitexceeded`).

When the runner sees either prefix in a task's `failureReason`, it calls `POST /api/keys/report-rate-limit` to mark the used slot as unavailable. The `rateLimitedUntil` timestamp is:

* Parsed from the error message if it contains an ISO 8601 or Unix-epoch reset time.
* Set to &#x2A;*now + `CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS`** (default 2 hours) when the error is a workspace-credits-exhausted message ("Your workspace is out of credits…"). See [Workspace credits exhausted](#workspace-credits-exhausted) below.
* Otherwise set to **now + 5 minutes** as a conservative fallback for other unparseable rate-limit errors.

The slot is excluded from future `availableIndices` responses until `rateLimitedUntil` passes.

Workspace credits exhausted [#workspace-credits-exhausted]

Codex returns a distinct error when a workspace's credit balance is depleted:

> "Your workspace is out of credits. Ask your workspace owner to refill in order to continue."

This error does not match standard rate-limit patterns, so the runner applies a dedicated cooldown rather than the 5-minute fallback. The default cooldown is **2 hours** — workspace credits typically refill on a weekly cadence, so a short fallback would keep re-handing the dead slot every 5 minutes and burn retries until the next refill.

You can tune this cooldown via the swarm config store:

| Config key                            | Type                  | Default         | Valid range    | Description                                                                                 |
| ------------------------------------- | --------------------- | --------------- | -------------- | ------------------------------------------------------------------------------------------- |
| `CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS` | positive integer (ms) | `7200000` (2 h) | 5 min – 7 days | How long a credits-exhausted Codex OAuth slot is held out of the pool before being retried. |

Set it via the API:

```bash
curl -X PUT http://localhost:3013/api/config \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"scope":"global","key":"CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS","value":"14400000"}'
# Sets cooldown to 4 hours
```

Or via the `set-config` MCP tool:

```
set-config key=CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS value=14400000 scope=global
```

The value must be a **positive integer of milliseconds** (e.g. `"7200000"` — no units suffix, no decimal point). The runner clamps the configured value to the `[5m, 7d]` range; values outside that range are silently brought to the nearest bound at runtime, but the write-time validator at `PUT /api/config` rejects clearly invalid inputs immediately.

Backwards compatibility [#backwards-compatibility]

Single-credential deploys keep working unchanged:

* The Docker entrypoint seeds `codex_oauth_0` from the legacy `codex_oauth` config key at boot (if `codex_oauth_0` does not yet exist). Workers never see the legacy key name at runtime.
* If only one slot is provisioned, the pool still runs correctly — slot 0 is always selected and the availability filter is a no-op.

Pool health & keep-warm [#pool-health--keep-warm]

The pool now has one locked refresh path for both task-time revalidation and background hygiene:

* **Fail-fast refresh rejection** — if OpenAI rejects a slot refresh (revoked credential, invalid refresh token, lock-wait timeout), Codex workers fail with an actionable auth error that includes the upstream status/body instead of silently launching on a stale pooled `auth.json`.
* **Worker-disk auth files stay refresh-token-blanked** — boot-seeded and per-task `~/.codex/auth.json` materialization both strip live pool refresh tokens, so a stray local Codex process cannot rotate a shared token family outside the refresh lock.
* **Locked keep-warm sweep** — `POST /api/oauth/keep-warm/codex` enumerates all `codex_oauth_*` slots and refreshes any slot older than roughly 7 days through the same locked `getValidCodexOAuth(...)` path. Benched slots (`codex-auth-watch`) are skipped automatically.

This keeps rarely-used slots healthy without reintroducing the pre-lock refresh race.

Known limitations [#known-limitations]

* **Global pool only** — slot selection is swarm-global. There is still no per-worker or per-agent slot affinity by design; if you need strict credential isolation, use separate swarms or `OPENAI_API_KEY`.

Verification [#verification-1]

```bash
# List all provisioned slots
curl -H "Authorization: Bearer YOUR_API_KEY" \
  "http://localhost:3013/api/config/resolved?includeSecrets=false" \
  | jq '[.configs[] | select(.key | test("^codex_oauth_\\d+$")) | {slot: .key, id: .id}]'

# Check rate-limit status for CODEX_OAUTH slots
curl -H "Authorization: Bearer YOUR_API_KEY" \
  "http://localhost:3013/api/keys/available?keyType=CODEX_OAUTH&totalKeys=3"
```

Related [#related]

* [Harness Configuration](/docs/guides/harness-configuration) — Configure which harness type (Claude Code, Codex, pi) workers use
* [Environment Variables](/docs/reference/environment-variables) — Full reference for `OPENAI_API_KEY` and all other configuration variables
* [Deployment Guide](/docs/guides/deployment) — Deploy agents to production with Docker Compose, including credential setup
* [Getting Started](/docs/getting-started) — Initial setup for the Agent Swarm platform


# Model Gateways (/docs/guides/provider-auth/model-gateways)



Agent Swarm's harness and its model gateway are separate choices. `HARNESS_PROVIDER` selects the coding agent that runs the task. Gateway settings tell that harness where to send model requests.

This page covers the stock worker image. A gateway that an upstream CLI supports is not necessarily supported by Agent Swarm until its credentials and configuration reach that CLI inside the worker.

Support matrix [#support-matrix]

| Gateway                       | Claude Code                                                                            | Codex                                                                | OpenCode                                                      | pi-mono                                                       | Devin            | Claude Managed   |
| ----------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- | ---------------- | ---------------- |
| **OpenRouter**                | Not yet supported: the worker rejects `ANTHROPIC_AUTH_TOKEN` before Claude Code starts | Custom image/config and a swarm-recognized Codex credential required | **Supported** with environment variables                      | **Supported** with environment variables                      | Not configurable | Not configurable |
| **Ramp Router**               | Not yet supported: same `ANTHROPIC_AUTH_TOKEN` credential gap                          | Custom image/config and a swarm-recognized Codex credential required | Not yet supported: Ramp's provider plugin is not in the image | Not yet supported: Ramp's provider plugin is not in the image | Not configurable | Not configurable |
| **OrcaRouter**                | Not supported by `OPENROUTER_BASE_URL`                                                 | Not supported by `OPENROUTER_BASE_URL`                               | **Supported**                                                 | **Supported**                                                 | Not configurable | Not configurable |
| **OpenAI-compatible gateway** | Not supported by `OPENROUTER_BASE_URL`                                                 | Not supported by `OPENROUTER_BASE_URL`                               | **Supported**                                                 | **Supported**                                                 | Not configurable | Not configurable |

**Supported** means the stock image works with environment or swarm-config values. “Custom image” means the upstream harness can use the gateway, but the stock Agent Swarm image does not install the required provider configuration or plugin.

There is no per-vendor gateway adapter in Agent Swarm, and adding one is not required. OpenCode and pi-mono reach every gateway through the same seam: `OPENROUTER_BASE_URL` redirects the built-in OpenRouter provider at an arbitrary chat-completions endpoint. OrcaRouter is documented below as a worked example of that generic path, not as a special case.

<Callout type="warn">
  `OPENROUTER_BASE_URL` is not a universal OpenAI base-URL switch. It only
  redirects the swarm's existing OpenRouter consumers. The target must provide
  the OpenRouter-compatible model-list and chat-completions routes described
  below. It does not make Ramp Router work with OpenCode or pi-mono.
</Callout>

For the standard harness credentials, model defaults, and Docker examples, see [Harness Configuration](/docs/guides/harness-configuration). For the contributor-facing adapter contract, see [Harness Providers](/docs/guides/harness-providers).

OpenRouter [#openrouter]

OpenCode and pi-mono have first-class OpenRouter support in the stock image. Both use the same key and the same model slug after the harness-specific prefix.

OpenCode [#opencode]

```bash
# .env.docker
HARNESS_PROVIDER=opencode
OPENROUTER_API_KEY=sk-or-...
MODEL_OVERRIDE=openrouter/qwen/qwen3-coder-flash

API_KEY=your-swarm-api-key
MCP_BASE_URL=http://host.docker.internal:3013
AGENT_ID=your-worker-uuid
```

* **Base URL:** `https://openrouter.ai/api/v1` (built in)
* **Credential:** `OPENROUTER_API_KEY`
* **Model string:** `openrouter/<openrouter-model-id>`. For example, OpenRouter model `qwen/qwen3-coder-flash` becomes `openrouter/qwen/qwen3-coder-flash`.
* **Config file:** none. The adapter writes a per-task OpenCode config and injects the swarm plugin automatically.

Choose another ID from the [OpenRouter model catalog](https://openrouter.ai/models), keeping the `openrouter/` harness prefix.

pi-mono [#pi-mono]

```bash
# .env.docker
HARNESS_PROVIDER=pi
OPENROUTER_API_KEY=sk-or-...
MODEL_OVERRIDE=openrouter/moonshotai/kimi-k2.5

API_KEY=your-swarm-api-key
MCP_BASE_URL=http://host.docker.internal:3013
AGENT_ID=your-worker-uuid
```

* **Base URL:** `https://openrouter.ai/api/v1` (built in)
* **Credential:** `OPENROUTER_API_KEY`
* **Model string:** `openrouter/<openrouter-model-id>`
* **Config file:** none for direct OpenRouter use. The adapter supplies the key through pi-mono's per-session `ModelRuntime`.

Do not add `CLAUDE_CODE_OAUTH_TOKEN` to a pi worker. Keep only credentials used by the selected pi provider.

Claude Code: upstream-compatible, blocked in Agent Swarm [#claude-code-upstream-compatible-blocked-in-agent-swarm]

OpenRouter documents an Anthropic-compatible endpoint for Claude Code:

```bash
ANTHROPIC_BASE_URL=https://openrouter.ai/api
ANTHROPIC_AUTH_TOKEN=sk-or-...
ANTHROPIC_API_KEY=
ANTHROPIC_DEFAULT_SONNET_MODEL=~anthropic/claude-sonnet-latest
```

The base URL intentionally has no `/v1`; Claude Code appends it. OpenRouter also requires `ANTHROPIC_API_KEY` to be explicitly empty so it does not compete with the bearer token. See OpenRouter's [Claude Code integration guide](https://openrouter.ai/docs/guides/coding-agents/claude-code-integration).

This setup does **not** work in Agent Swarm today. Both the boot credential check and the session validator accept only `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`, so the worker parks or fails before Claude Code sees `ANTHROPIC_AUTH_TOKEN`. Setting `SWARM_USE_CLAUDE_BRIDGE=true` is not a workaround: the bridge requires Claude OAuth and deliberately removes gateway-related `ANTHROPIC_*` variables from its child process.

Codex: upstream-compatible, not in the stock image [#codex-upstream-compatible-not-in-the-stock-image]

OpenRouter's Codex setup uses the user-level `~/.codex/config.toml`:

```toml
model = "openai/gpt-5.3-codex"
model_provider = "openrouter"

[model_providers.openrouter]
name = "OpenRouter"
base_url = "https://openrouter.ai/api/v1"
env_key = "OPENROUTER_API_KEY"
wire_api = "responses"
```

The stock worker image bakes its own Codex config with the native provider and model, and Agent Swarm has no environment variable that adds this provider block. Its credential gate also does not recognize `OPENROUTER_API_KEY`; it waits for `OPENAI_API_KEY`, Codex OAuth, or an existing `~/.codex/auth.json` before starting work.

An experimental derivative image therefore needs both the complete provider config and a credential state the swarm accepts, in addition to `OPENROUTER_API_KEY` in the worker process environment. Merely setting `OPENROUTER_API_KEY`, mounting the TOML block, or setting `OPENROUTER_BASE_URL` is not sufficient in the stock deployment. Do not disable the credential gate to hide this mismatch.

See OpenRouter's [Codex CLI setup](https://openrouter.ai/blog/tutorials/codex-cli-openrouter/) for the upstream configuration. This path has not been smoke-tested with the stock Agent Swarm worker.

OpenAI-compatible gateways [#openai-compatible-gateways]

`OPENROUTER_BASE_URL` redirects the OpenRouter provider used by OpenCode and pi-mono. It also redirects other OpenRouter consumers in the deployment, including model refreshes and internal summarizers. The variable name says OpenRouter, but the target does not have to be openrouter.ai — any gateway meeting the contract below works, and no swarm code path is vendor-specific.

The target must accept `OPENROUTER_API_KEY` as a bearer credential and provide, at minimum:

* `GET <base-url>/models` for model discovery
* `POST <base-url>/chat/completions` for OpenRouter-compatible inference and swarm summarizers
* Model IDs the gateway resolves for the value you select through `MODEL_OVERRIDE`

Set the variable on both API and worker processes if every OpenRouter call must use the gateway.

Setting it from the dashboard [#setting-it-from-the-dashboard]

`OPENROUTER_BASE_URL` is on Settings → Configuration under **Harness & tools**. A saved value is a global `swarm_config` row and reaches both sides without a container restart:

* **API server** — the debounced global-config reload re-injects the row over the server process environment, so model refreshes and internal summarizers pick it up within seconds.
* **Workers** — each worker fetches `GET /api/config/resolved` before it starts a task and passes the merged environment into the harness adapter, so the next task on that worker uses the new gateway.

A task already running keeps the gateway it started with. The gateway credential itself stays out of this page: put `OPENROUTER_API_KEY` on the Secrets/Integrations pages or in the worker environment.

OpenCode through a gateway [#opencode-through-a-gateway]

```bash
HARNESS_PROVIDER=opencode
OPENROUTER_API_KEY=your-gateway-credential
OPENROUTER_BASE_URL=https://gateway.example.com/v1
MODEL_OVERRIDE=openrouter/qwen/qwen3-coder-flash
```

The adapter writes `provider.openrouter.options.baseURL` into its per-task OpenCode config. It also mirrors the resolved value into the spawned OpenCode process environment so the bundled summarize plugin uses the same gateway instead of calling openrouter.ai directly.

pi-mono through a gateway [#pi-mono-through-a-gateway]

```bash
HARNESS_PROVIDER=pi
OPENROUTER_API_KEY=your-gateway-credential
OPENROUTER_BASE_URL=https://gateway.example.com/v1
MODEL_OVERRIDE=openrouter/moonshotai/kimi-k2.5
```

The adapter writes the override to pi-mono's `~/.pi/agent/models.json` before its per-session model runtime loads. Clearing the variable reverts that file to whatever it held before the swarm wrote the override.

`OPENROUTER_BASE_URL` does not affect Claude Code, Codex, Devin, or Claude Managed Agents. It also cannot adapt a Responses-only gateway into the chat-completions shape expected by these OpenRouter-backed paths.

OrcaRouter (api.orcarouter.ai) [#orcarouter-apiorcarouterai]

OrcaRouter is an OpenAI-compatible gateway, so it needs no new provider, adapter, or code path in Agent Swarm. It is configured exactly like any other gateway in the previous section: point `OPENROUTER_BASE_URL` at it and use an OrcaRouter key as `OPENROUTER_API_KEY`.

Its published contract matches the requirements above. Per OrcaRouter's [documentation](https://docs.orcarouter.ai/): the base URL is `https://api.orcarouter.ai/v1` and must include `/v1`; authentication is a bearer API key (`sk-orca-…`); [`GET /v1/models`](https://docs.orcarouter.ai/getting-started/models) returns the catalog for the authenticated key; and [`POST /v1/chat/completions`](https://docs.orcarouter.ai/native-formats/openai-compat) serves OpenAI-compatible Chat Completions. Model IDs are provider-prefixed (`openai/`, `anthropic/`, `google/`, `deepseek/`, `grok/`, and others).

OpenCode through OrcaRouter [#opencode-through-orcarouter]

```bash
HARNESS_PROVIDER=opencode
OPENROUTER_API_KEY=sk-orca-...
OPENROUTER_BASE_URL=https://api.orcarouter.ai/v1
MODEL_OVERRIDE=openrouter/anthropic/claude-opus-4.7
```

pi-mono through OrcaRouter [#pi-mono-through-orcarouter]

```bash
HARNESS_PROVIDER=pi
OPENROUTER_API_KEY=sk-orca-...
OPENROUTER_BASE_URL=https://api.orcarouter.ai/v1
MODEL_OVERRIDE=openrouter/anthropic/claude-opus-4.7
```

Two details differ from OrcaRouter's own OpenCode and pi guides, which configure a dedicated `orcarouter` provider entry in `opencode.json` / `models.json`:

* **Keep the `openrouter/` harness prefix.** Agent Swarm reuses the harness's built-in OpenRouter provider and redirects it, rather than registering a second provider. `MODEL_OVERRIDE` is therefore `openrouter/` followed by the full OrcaRouter model ID — including that ID's own provider prefix. OrcaRouter's routing alias becomes `openrouter/orcarouter/auto`.
* **Do not hand-edit the harness config files.** Both adapters generate their gateway configuration per task from `OPENROUTER_BASE_URL`; a manual `opencode.json` or `~/.pi/agent/models.json` provider block is not the supported path here and the pi-mono adapter actively manages its own override in that file.

<Callout type="info">
  These OrcaRouter values are taken from its public documentation
  ([docs.orcarouter.ai](https://docs.orcarouter.ai/), read 2026-09-08). They have
  not been authenticated end to end from an Agent Swarm worker, because no
  OrcaRouter key was available during verification. What is verified is the swarm
  side: `OPENROUTER_BASE_URL` reaches both the OpenCode and pi-mono adapters, and
  a chat-completions gateway is the shape they expect.
</Callout>

Ramp Router (router.com) [#ramp-router-routercom]

Ramp Router is not supported by the stock Agent Swarm worker image today. The current Ramp CLI has dedicated integrations for Claude Code, Codex, OpenCode, and Pi, but each path hits a swarm-side gap.

Ramp's [public API endpoint documentation](https://docs.router.com/docs/api/endpoint) names `https://api.router.com/v1`. Its current CLI instead configures coding agents against `https://router-api.ramp.com/v1`; the Claude Code integration removes `/v1` because Claude Code appends it. Follow the [Ramp CLI source and installer](https://github.com/ramp-public/ramp-cli) for coding-agent configuration rather than substituting `router.com` as the model API host.

In a custom image that installs Ramp's CLI, the upstream configuration starts with:

```bash
RAMP_ROUTER_CONFIGURE_API_KEY='<router-key>' ramp router configure
```

That command writes client-specific files on a normal workstation. It does not resolve the Agent Swarm gaps in the table below.

No fixed model string is safe to copy into a Router deployment. Router returns the models available to the authenticated key from `GET /v1/models`, and `ramp router configure` writes the selected ID in the shape required by each client.

| Harness     | Ramp configuration                                                                                                                                                                                                                  | Agent Swarm gap                                                                                                                                                                         |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Claude Code | `ANTHROPIC_BASE_URL=https://router-api.ramp.com`, `ANTHROPIC_AUTH_TOKEN=<router-key>`, `ANTHROPIC_CUSTOM_HEADERS='X-Gateway-Client: claude-code'`, `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`, and an empty `ANTHROPIC_API_KEY` | Agent Swarm does not accept `ANTHROPIC_AUTH_TOKEN` as a Claude credential                                                                                                               |
| Codex       | `[model_providers.ramp-router]` in `~/.codex/config.toml`, `base_url = "https://router-api.ramp.com/v1"`, `wire_api = "responses"`, plus command-based auth and `X-Gateway-Client = "codex"`                                        | The stock image does not contain this provider block, key file, generated model catalog, or Router hooks; the swarm credential gate also does not recognize the provider's command auth |
| OpenCode    | Ramp's bundled local provider plugin in both `opencode.json` and `tui.json`; model string `ramp-router/<discovered-model-id>`                                                                                                       | The plugin is bundled inside `ramp-cli`, is not published as an independently installable npm package, and is not in the worker image                                                   |
| pi-mono     | Ramp's bundled local provider package, Router auth entry, and `ramp-router-config.json`; provider `ramp-router` with a discovered model ID                                                                                          | The package and configuration are not in the worker image, and the swarm adapter has no Ramp provider path                                                                              |

Do not point `OPENROUTER_BASE_URL` at Ramp Router. Ramp's OpenCode and Pi integrations use the OpenAI Responses API through dedicated plugins, while the swarm's OpenRouter override also needs chat-completions compatibility.

<Callout type="info">
  These Ramp paths were verified from `ramp-public/ramp-cli` at commit
  [`b71f7f12`](https://github.com/ramp-public/ramp-cli/tree/b71f7f124668a557e4ab8352078107ac85d9e302).
  They have not been authenticated end to end from an Agent Swarm worker because
  no Router key was available during verification.
</Callout>

Managed harnesses [#managed-harnesses]

Devin and Claude Managed Agents execute sessions in their vendors' clouds. Their adapters call vendor-managed session APIs directly and expose no model base-URL override. `OPENROUTER_BASE_URL`, `ANTHROPIC_BASE_URL`, and Codex provider files do not redirect them.

If gateway routing is mandatory, choose a supported local harness. Today that means OpenCode or pi-mono with OpenRouter or another OpenAI-compatible gateway.

Troubleshooting [#troubleshooting]

The worker is waiting for credentials [#the-worker-is-waiting-for-credentials]

The selected harness did not find a credential type it recognizes. Check the support matrix before adding another variable. In particular, `ANTHROPIC_AUTH_TOKEN` alone is not a valid Agent Swarm Claude credential today.

The gateway receives model-list requests but no inference [#the-gateway-receives-model-list-requests-but-no-inference]

Confirm the gateway supports `POST /chat/completions`, not only `POST /responses`. `OPENROUTER_BASE_URL` routes more than the harness session itself, so internal summarizers may also call the same base URL.

The model is not found [#the-model-is-not-found]

OpenCode and pi-mono require the `openrouter/` harness prefix before the gateway's catalog ID, whichever gateway is behind `OPENROUTER_BASE_URL`. The prefix selects the harness provider; everything after it is passed through. For example:

```
OpenRouter ID:   qwen/qwen3-coder-flash
MODEL_OVERRIDE:  openrouter/qwen/qwen3-coder-flash

OrcaRouter ID:   anthropic/claude-opus-4.7
MODEL_OVERRIDE:  openrouter/anthropic/claude-opus-4.7
```

Do not strip a gateway ID's own provider prefix to avoid the doubled-looking path — the gateway receives only the part after `openrouter/`, so removing it sends an ID the gateway does not know.

Ramp model IDs are key-specific. Discover them with Ramp's configuration flow instead of reusing an OpenRouter slug.

Related [#related]

* [Harness Configuration](/docs/guides/harness-configuration) — standard credentials, model selection, and worker examples
* [Harness Providers](/docs/guides/harness-providers) — adapter architecture and contributor requirements
* [Environment Variables](/docs/reference/environment-variables) — full variable reference
* [Codex OAuth](/docs/guides/provider-auth/codex-oauth) — native Codex authentication without a gateway


# Pattern: Drain Loops (Stacked PRs + Merge Loop) (/docs/playbooks/patterns/drain-loops)



Turn one big parent issue into a chain of small, individually-reviewable PRs — then review-and-merge them bottom-up, halting on the first failure.

<Mermaid
  chart="`flowchart TB
P[&#x22;Parent issue<br/>(N sub-issues)&#x22;] --> D[&#x22;Drain loop&#x22;]
D --> B[&#x22;Pick next Backlog/Todo sub-issue&#x22;]
B --> BR[&#x22;Branch off previous PR's branch<br/>(stacked)&#x22;]
BR --> IM[&#x22;Implement + push + open PR&#x22;]
IM --> TR[&#x22;Linear → In Review&#x22;]
TR --> MORE{&#x22;More<br/>sub-issues?&#x22;}
MORE -- yes --> B
MORE -- no --> ML[&#x22;Merge loop&#x22;]
ML --> RV{&#x22;Review bottom-most PR<br/>(base = main)&#x22;}
RV -- &#x22;approved + CI green&#x22; --> MG[&#x22;Squash-merge<br/>Linear → Done&#x22;]
MG --> ML
RV -- &#x22;changes requested / CI red&#x22; --> HALT[&#x22;Halt&#x22;]
`"
/>

What it is [#what-it-is]

Two complementary loops:

* **Drain loop** — iterate the sub-issues of a parent. For each: branch off the *previous* PR's branch (so PRs stack), implement, push, open a PR, move the sub-issue to In Review. Repeat until drained.
* **Merge loop** — review the bottom-most open PR with `base=main`. If the reviewer approves and CI is green, squash-merge it, move the sub-issue to Done, and loop up the stack. **Halt on the first request-changes or red CI** so a bad change doesn't cascade.

Where we use it [#where-we-use-it]

* **[Feature development](/docs/playbooks/feature-development)** — `linear-drain-loop` + `linear-merge-loop` drive a [Linear](https://linear.app) epic from sub-issues to merged PRs.
* **Weekly code-health** — top-N concerns become one PR each, drained with an internal reviewer (capped review rounds).
* **UX audits** — an umbrella's sub-issues can feed a drain loop for the low-risk fixes.

Why it works [#why-it-works]

* Small PRs review faster and roll back cleaner than one mega-PR.
* Stacking keeps the chain coherent while each piece stays independently reviewable.
* Halting on first failure prevents a broken base from poisoning everything above it.

How to apply [#how-to-apply]

* Each sub-issue must be independently mergeable — if they're entangled, the stack breaks.
* Cap iterations (`maxIterations`) so a runaway loop can't churn forever.
* Keep tracker state in lockstep: In Review on PR open, Done on merge. Stale tracker state is the usual failure mode.

Used in [#used-in]

* [Feature Development](/docs/playbooks/feature-development)
* [UX Command Center](/docs/playbooks/ux-command-center)
* [Code Health & Alert Management](/docs/playbooks/code-health-alert-management)


# Pattern: HITL Gates (Human-in-the-Loop Approval) (/docs/playbooks/patterns/hitl-gates)



Pause a workflow mid-flight until a human approves in Slack. The agent does the work; the human owns the irreversible decision.

<Mermaid
  chart="`flowchart LR
W[&#x22;Agent does the work<br/>(draft / audit / plan)&#x22;] --> G{{&#x22;request-human-input<br/>(workflow pauses)&#x22;}}
G -- &#x22;approve&#x22; --> GO[&#x22;Proceed<br/>(send / merge / prune)&#x22;]
G -- &#x22;reject&#x22; --> STOP[&#x22;Archive / abort&#x22;]
`"
/>

What it is [#what-it-is]

A workflow node (`request-human-input`) that blocks execution and posts to Slack, then resumes only when a human reacts. The agent prepares everything up to the point of no return; the human makes the call on the irreversible step.

Where we use it [#where-we-use-it]

* **[Lead prospecting](/docs/playbooks/lead-prospecting)** — cold outreach is drafted and queued, but never sends without a human approval.
* **[Customer support](/docs/playbooks/proactive-customer-support)** — value-showcase reports are drafted, but a human signs off before any email reaches a customer.
* **[Destructive infra ops](/docs/playbooks/code-health-alert-management)** — the monthly disk-cleanup audits and recommends, then waits for approval before any prune. Never auto-prunes.
* **[Social posting](/docs/playbooks/content-generation)** — every piece of content (memes, posts, blog drafts) gets human craft, tailoring, or feedback before release.

Why it works [#why-it-works]

* It keeps agents fast on the 95% (research, drafting, scheduling) while keeping humans on the 5% that's hard to undo (sending, merging, deleting).
* It's an explicit, auditable approval — not a silent autonomous action you discover later.

How to apply [#how-to-apply]

* Gate the **irreversible** steps only: sending email, merging to main, deleting data, posting publicly. Don't gate reversible work — that just adds latency.
* Give the human enough context *in the gate message* to decide without digging: a summary + a link to the full artifact in [agent-fs](https://github.com/desplega-ai/agent-fs).
* Define both branches: what happens on approve **and** on reject (archive, retry, escalate).
* Set a timeout/fallback so a forgotten gate doesn't wedge the workflow forever.

Used in [#used-in]

* [Lead Prospecting](/docs/playbooks/lead-prospecting)
* [Content Generation](/docs/playbooks/content-generation)
* [Proactive Customer Support](/docs/playbooks/proactive-customer-support)
* [Code Health & Alert Management](/docs/playbooks/code-health-alert-management)


# Hot Patterns (/docs/playbooks/patterns)



These are the patterns that show up in nearly every playbook. Treat them as recipes — short, focused, opinionated — that you can pull into any new flow you build on top of the swarm.

* [Litmus Tests](/docs/playbooks/patterns/litmus-tests) — LLM-as-judge quality gates that hard-reject sub-bar output, using a different model family from the generator.
* [Drain Loops](/docs/playbooks/patterns/drain-loops) — turn one big ticket into a chain of stacked, individually-reviewable PRs, then a merge loop that halts on the first failure.
* [HITL Gates](/docs/playbooks/patterns/hitl-gates) — pause a workflow mid-flight until a human approves in Slack. The agent does the work; the human owns the irreversible step.
* [Per-Customer Working Directories](/docs/playbooks/patterns/per-customer-working-directories) — give each top account a persistent folder in [agent-fs](https://github.com/desplega-ai/agent-fs) so context compounds across months.
* [No-op When Nothing Changed](/docs/playbooks/patterns/no-op-workflows) — detect "did anything actually happen?" and skip silently when not. Default for any scheduled job.


# Pattern: Litmus Tests (LLM-as-Judge Quality Gates) (/docs/playbooks/patterns/litmus-tests)



A quality gate where one agent hard-rejects another agent's output against explicit criteria — using a *different model family* so the judgment is genuinely independent.

<Mermaid
  chart="`flowchart LR
W[&#x22;Writer / Generator<br/>(model family A)&#x22;] --> O[&#x22;Output&#x22;]
O --> J{&#x22;Litmus judge<br/>(model family B)&#x22;}
J -- &#x22;fails criteria&#x22; --> W
J -- &#x22;passes&#x22; --> SHIP[&#x22;Ship / publish&#x22;]
`"
/>

What it is [#what-it-is]

Instead of trusting a generator's self-assessment, you run its output through a separate "judge" agent that scores it against a fixed rubric and **hard-rejects** anything below bar. The rejection loops back to the generator with the specific failures. Critically, the judge runs on a *different model family* than the generator — same-family review tends to rubber-stamp.

Where we use it [#where-we-use-it]

* **[Content generation](/docs/playbooks/content-generation)** — the Content Reviewer scores blog drafts across Depth, Code Quality, Structure, SEO, Voice & Tone, Readability/AEO. Sub-bar drafts never publish.
* **How-to / competitor page generators** — litmus hard-rejects pages with missing/empty schema markup or a wrong canonical URL, because that's exactly what kills the SEO value.
* **Topic mining** — a litmus gate filters low-intent or duplicate topic proposals before they enter the supply table.

Why it works [#why-it-works]

* A fixed rubric makes "good" objective and reviewable, not vibes.
* Hard-reject (not "suggest improvements") forces the generator to actually clear the bar.
* Cross-family judging catches blind spots one model shares with itself.

How to apply [#how-to-apply]

* Write the rubric as explicit, checkable criteria — "has 4–8 schema steps", not "is well-structured".
* Pick a judge model from a different family than the generator (e.g. [Claude](https://www.anthropic.com/claude) writes, [Gemini](https://deepmind.google/technologies/gemini/) judges, or vice versa).
* Make the gate **blocking**: a fail returns to the generator, it doesn't warn-and-continue.
* Log scores over time so you can calibrate thresholds (too strict = nothing ships; too loose = the gate is theater).

Used in [#used-in]

* [Content Generation](/docs/playbooks/content-generation)


# Pattern: No-op When Nothing Changed (/docs/playbooks/patterns/no-op-workflows)



A scheduled workflow that first asks "did anything actually happen?" and **skips silently** if not — so it never produces empty reports or noise commits.

<Mermaid
  chart="`flowchart LR
C[&#x22;Cron fires&#x22;] --> Q{&#x22;Did anything<br/>change?&#x22;}
Q -- no --> NO[&#x22;No-op: exit quietly<br/>(no post, no commit)&#x22;]
Q -- yes --> WORK[&#x22;Do the work<br/>(update / report / notify)&#x22;]
`"
/>

What it is [#what-it-is]

The first node of a recurring workflow is a cheap check: were there merged PRs, new alerts, fresh data, anything worth acting on? If not, the workflow exits without posting, committing, or notifying. The default for a scheduled job is **silence**, not output.

Where we use it [#where-we-use-it]

* **[Daily docs update](/docs/playbooks/self-documenting-release-reports)** — if no PRs merged in the last 24h, it does nothing. No empty changelog entry, no "no changes today" commit.
* **[Health/blocker digests](/docs/playbooks/code-health-alert-management)** — a quiet day produces no digest rather than a "nothing to report" message.
* **[Release-notes generators](/docs/playbooks/self-documenting-release-reports)** — skip a week with no significant commits instead of manufacturing filler.

Why it works [#why-it-works]

* Agents that always emit something train your team to ignore them. Silence-by-default keeps every notification meaningful.
* It prevents commit/changelog pollution — your history reflects real changes, not the scheduler's heartbeat.
* It saves tokens and run time on quiet days.

How to apply [#how-to-apply]

* Put the "did anything change?" check **first**, before any expensive work.
* Make the no-op path genuinely silent — no Slack post, no empty PR, no timestamp-only commit.
* Distinguish "nothing changed" (no-op) from "I failed to check" (real failure that *should* alert).
* When something *does* change, make the output proportional — a one-line change gets a one-line note.

Used in [#used-in]

* [Code Health & Alert Management](/docs/playbooks/code-health-alert-management)
* [Reports from Multiple Sources](/docs/playbooks/reports-multiple-sources)
* [Self-Documenting & Release Reports](/docs/playbooks/self-documenting-release-reports)


# Pattern: Per-Customer Working Directories (/docs/playbooks/patterns/per-customer-working-directories)



Give each top account a persistent folder in the shared filesystem ([agent-fs](https://github.com/desplega-ai/agent-fs)) where agents accumulate context across months — not just within a single session.

<Mermaid
  chart="`flowchart LR
M[&#x22;Meeting transcript&#x22;] --> WD[(&#x22;agent-fs:<br/>customers/&lt;account&gt;/&#x22;)]
U[&#x22;Usage / behavior data&#x22;] --> WD
N[&#x22;Running notes +<br/>integration history&#x22;] --> WD
WD --> R[&#x22;Any agent, any session<br/>pulls full context&#x22;]
R --> OUT[&#x22;Personalized report /<br/>'what changed?' briefing&#x22;]
`"
/>

What it is [#what-it-is]

A durable directory per account (e.g. `customers/<account>/`) in [agent-fs](https://github.com/desplega-ai/agent-fs), holding running notes, integration history, open questions, recent activity, and meeting transcripts. Any agent in any future session can read it to answer "what's the state of this account?" without re-deriving everything.

Where we use it [#where-we-use-it]

* **[Proactive customer support](/docs/playbooks/proactive-customer-support)** — each top account has a directory. A post-call [Granola](https://www.granola.ai/) transcript appends automatically; the next report-generation task reads months of accumulated context.

Why it works [#why-it-works]

* Agent *memory* is good for swarm-wide learnings; a *working directory* is better for account-specific, document-shaped context (notes, transcripts, history) that you want to read and edit as files.
* It turns "what changed since their last touchpoint?" from an impossible question into a diff against the directory.
* It survives session resets, model changes, and agent hand-offs — the context lives in files, not in one agent's head.

How to apply [#how-to-apply]

* One directory per account, predictable path. Keep a short `README`/`index` at the root summarizing current state.
* Append, don't overwrite — transcripts and notes are an audit trail.
* Separate internal notes from customer-facing drafts so a redaction pass is easy.
* Pair with the [HITL gate](/docs/playbooks/patterns/hitl-gates): the directory feeds the draft; a human approves the send.

Used in [#used-in]

* [Proactive Customer Support](/docs/playbooks/proactive-customer-support)
