Swarm Swarm

Configuration

Status: current

Three TOML layers, deep-merged over built-in defaults — later wins:

  1. ~/.swarm/policy.toml (or the file SWARM_POLICY points at) — org policy, optional (M8.1)
  2. ~/.swarm/config.toml — global, per-machine
  3. <repo>/.swarm.toml — per-repo (commit it; it's how a repo declares its rules)

The policy layer is the only one that may carry locked: a list of dotted keys (or whole subtrees) that the layers below cannot change. A locked key keeps the policy's value — or the built-in default when the policy sets none — and every attempt to override it is reported (loadConfigDetailed().overridden) so doctor and the daemon can treat it as a tamper signal. Everything else in the policy file is ordinary config that global and repo layers override.

Tamper detection (M8.1b): on every SessionStart the daemon records an incident.opened with rule = "policy" — once per finding per daemon lifetime — for a locked key a lower layer tried to set, for a hook event whose swarm-hook entry was removed from ~/.claude/settings.json or given a timeout under 5 s, and for SWARM_GUARD=off while the policy locks any rule (the variable is then ignored). swarm doctor prints the same findings; GET /v1/policy?project= exposes them.

Fail-closed (M8.1c): while the policy locks any rule the daemon maintains ~/.swarm/policy.cache.json — the locked rule modes plus a snapshot of live sessions and held worktrees, with a sha256 integrity hash. If the daemon is unreachable on PreToolUse the hook shim evaluates exactly those locked rules from the cache and returns their ask/deny; unlocked rules keep failing open (OQ-3). A cache that fails its hash is ignored.

# ~/.swarm/policy.toml — what an org pins on every machine
locked = ["rules.destructive_git", "rules.protected", "tasks.source"]

[rules]
destructive_git = "deny"
[rules.protected]
ports = [5432]

Invalid values fall back to defaults with a warning; invalid TOML is ignored — configuration can never take the daemon down. The daemon re-reads repo config within ~30s of a change; global config is read at daemon start.

Port precedence: SWARM_PORT env > [daemon].port > 7777. If the chosen port is already taken the daemon binds an OS-assigned free port instead and records it in ~/.swarm/daemon.json (clients read that file, so nothing else needs to change); set SWARM_STRICT_PORT=1 to make it fail instead of falling back.

Reference (defaults shown)

[daemon]
# Preferred port. SWARM_PORT env still wins; if the port is taken the daemon
# falls back to a free one and records it in ~/.swarm/daemon.json.
port = 7777
# Daemon token (M8.2b). The daemon writes a secret to ~/.swarm/token (0600) on first start; every
# Swarm client on the machine sends it as `Authorization: Bearer`. "loopback-optional" (default)
# lets local callers — a browser tab, curl — omit it; "required" makes every /v1 call carry it
# (open the dashboard via `swarm ui`, which passes it along). Non-loopback callers always need it.
auth = "loopback-optional"

[events]
# Days of chatter (tool calls, deltas, prompts) kept in ~/.swarm/swarm.db. Global only.
retain_days = 30

[audit]
# Days of audit records kept — ledger changes, rule decisions, human answers, incidents — with
# who did it. 0 = forever (the default). Export with `swarm audit export` (M8.2c).
retain_days = 0

[privacy]
# What is stored at all. Global only; an org policy can lock these.
store_prompts = true      # false: prompt text is replaced by "[not stored]" (the event is kept)
store_reasoning = true    # false: assistant text from transcripts is not stored (token counts are)
redact = []               # regexes replaced by "[redacted]" in every stored string, e.g. ["ACME-[0-9]+"]
                          # long API-key-looking tokens and Bearer credentials are always redacted

[notify]
# Incident webhook (M8.5): every incident.opened is POSTed as Slack-compatible {text} JSON —
# a Slack incoming-webhook URL works as-is; Jira/PagerDuty via their webhook bridges.
# Fire-and-forget, never on the hook path. Global only.
# webhook = "https://hooks.slack.com/services/…"

[otel]
# OTLP export (M12.1, OQ-20): every session as an OpenTelemetry trace plus token and cost metrics,
# OTLP/HTTP JSON to /v1/traces and /v1/metrics under `endpoint`. Off unless endpoint is set. Global only.
# endpoint = "http://localhost:4318"        # an OpenTelemetry Collector, Grafana Alloy, a vendor's OTLP intake…
# headers = { Authorization = "Basic …" }   # whatever your backend asks for
# compat = "genai"          # "genai": OpenTelemetry GenAI conventions (invoke_agent / execute_tool spans,
#                           #   gen_ai.client.token.usage) — every agent, one vocabulary.
#                           # "claude-code": Claude Code's own names (claude_code.token.usage, claude_code.cost.usage,
#                           #   claude_code.tool spans, session.id), for dashboards built on Claude Code's telemetry.
# include_content = false   # true adds commands and file paths to tool spans
# interval = 30             # seconds between exports (5–3600)

[messages]
# M13.4: a message (swarm msg send / swarm_send) or an answer to a session's question wakes that
# session while it sits idle at its prompt — a background hook armed after every turn exits with
# the text and Claude Code shows it as a system reminder. Off: delivery waits for the next hook.
wake = true

[codify]
# M13.6: what Codify → Apply writes by default — "both" (CLAUDE.md lesson + .swarm.toml rule),
# "claude-md", or "swarm-toml". The card can override per click. Always on a branch, never main.
target = "both"

[broker]
# M13.2: an interactive session's permission prompt becomes a card on the dashboard (session page,
# Fleet, desktop notification) while a dashboard is open. Claude Code holds its terminal dialog
# while the hook waits; after this many seconds — or "Answer in terminal" on the card — the dialog
# appears unchanged. 0 = never wait (no card). Max 120. Nothing waits when no dashboard is watching.
interactive_wait = 30
# M12.7 reviewer on ask (OQ-22): a read-only `claude -p` (Read / Grep / Glob only) looks at each
# ask — a spawned run's card or an interactive session's PermissionRequest — and says allow or
# deny with a reason, recorded as `permission.reviewed`.
#   "off"    — default.
#   "advise" — the verdict sits on the card; a person answers. Not run when nobody is watching.
#   "decide" — the verdict answers the ask, even with no dashboard open; a person who answers the
#              card first wins. Honoured ONLY in a repo's .swarm.toml: here (or in a policy) it
#              means "advise". A rule the org policy locks is never decided, only advised on.
# While it runs the card waits up to reviewer_timeout (max 120) before the terminal asks.
reviewer = "off"
reviewer_model = ""       # empty = Claude Code's default model
reviewer_timeout = 60

[models]
# Model allow-list (M8.4): globs, empty = every model allowed. An org policy can lock this.
# Spawned runs / dispatch refuse a disallowed model; an interactive session on one opens an
# incident (observation only — Swarm never kills a session).
allow = []                # e.g. ["claude-*"]

[team]
# Forward to a self-hosted team daemon (M8.3b, packages/team). Global only — a repo cannot
# repoint a machine. Absent url = no forwarding, nothing changes.
# url = "https://swarm.example.internal"
forward = ["ledger", "cost"]   # "ledger" = audit events, "cost" = daily spend rollups;
                               # "transcripts" (titles + last text) is strictly opt-in, still redacted
interval = 5                   # flush seconds; batched, at-least-once, never on the hook path

[rules]
# Each rule: "ask" (agent must confirm), "deny" (blocked), or "off".
shared_tree     = "ask"   # broad `git add -A` / `git commit -a` while another live session shares the checkout
destructive_git = "ask"   # `git reset --hard`, `checkout .`, `clean -f`, `stash drop`, … same condition
pattern_kill    = "ask"   # `pkill -f` and friends — pattern kills hit other agents' processes too
protected_ports = "ask"   # kill/free of a port listed below
no_foreign_worktree = "ask"      # a file write (or Bash cwd) inside a worktree another claim holds
claim_required_to_write = "off"  # opt-in: writes to the shared checkout need a claim (work in its worktree)
# Rewrite rules (M13.5): "rewrite" fixes the call and lets it run — the agent is told what changed,
# the incident feed shows before / after — "ask" / "deny" refuse it, "off" (default) ignores it.
no_verify     = "off"     # `git commit/push --no-verify` / `--no-gpg-sign` → the flags are dropped
dry_run_first = "off"     # the first terraform apply / kubectl delete / helm uninstall per session runs as its dry-run form; the next one is allowed
# Destructive, secrets and tamper families (M12.5): "ask" / "deny" / "off". They shipped "off" in
# 0.15 and ask from 0.16 (M12.13); the Security view counts their hits whatever the mode.
destructive_fs    = "ask" # rm -rf on /, ~, .., a path from a maybe-unset variable, or outside the repo; mkfs; dd to a device
destructive_infra = "ask" # terraform destroy, kubectl delete ns / --all, helm uninstall, cloud deletes, SQL DROP / TRUNCATE
pipe_to_shell     = "ask" # curl … | sh, bash <(curl …), sh -c "$(curl …)"
secrets           = "ask" # reading / printing / copying credential files (Read included); writing .env* or key files
config_tamper     = "ask" # changing Claude Code settings, ~/.swarm, .swarm.toml, other agents' hook config; swarm uninstall

# Your own rules (M13.5): tested in order against every Bash command, after the rules above (a deny
# above is never softened here). `match` is a regex over the whole command; with action = "rewrite"
# every match becomes `replace` ($1 works). A regex that does not compile drops the rule, not the daemon.
# [[rules.custom]]
# name = "no-force-push"
# match = "git push .*--force"
# action = "deny"                 # ask | deny | rewrite | off
# reason = "force pushes go through a PR"
# [[rules.custom]]
# name = "pnpm"
# match = "\\bnpm (install|i)\\b"
# action = "rewrite"
# replace = "pnpm add"

# Live collision context (M13.3): after a file edit, the session is told when another live session
# edited the same file within the window — once per pair per window, as context, never a refusal.
collision_context = true
collision_window = 15     # minutes

[rules.protected]
# Ports agents must not kill/free (dev servers, databases, the daemon itself).
ports = []                # e.g. [3000, 5432, 7777]

[gates]
# Verification gates every task must pass before it counts as done. Recorded with
# `swarm gate record <task> <gate> pass|fail --rubric "…"` (or the swarm_gate_record MCP tool);
# the latest run per gate decides, a run without a rubric is rejected, a fail opens an incident.
# required = ["review", "tests"]
# When the daemon runs the executable required gates on its own inside a held worktree (M7.4):
# "session-end" (default), "stop" (after every turn, at most every 2 min per task), or "off".
# auto = "session-end"
# Repair loop (M13.1): with "block", a Stop inside a held worktree runs the executable required
# gates first and is refused while one fails — the agent reads the failing gate's output and keeps
# working. At most `max_blocks` refusals per session (then a gate_failed incident opens and the stop
# goes through); a Stop waits at most `stop_timeout` seconds for the gates. Never on subagents.
# on_stop = "record"      # "record" (default) | "block"
# max_blocks = 3
# stop_timeout = 300

# Executable gates (M7.4): any `[gates.<name>]` with a `cmd` can be run by `swarm gate run <task>`,
# the swarm_gate_run MCP tool, or the Board. Exit 0 = pass; the rubric becomes the command and the
# evidence the tail of its output. Runs in the task's held worktree (`cwd` is relative to it),
# registered in the process registry (kind `gate`), killed after `timeout` seconds (default 900).
# [gates.tests]
# cmd = "bun test"
# timeout = 600
# cwd = "packages"
# Review as a gate (M7.9): a read-only `claude -p` over the worktree's diff with a fixed rubric
# decides; findings become the evidence; any blocker/major finding fails; no JSON answer = fail.
# [gates.review]
# builtin = "review"
# model = "sonnet"      # optional
# timeout = 600         # default for builtins

# Workflows (M7.8): a declared step sequence the daemon advances per task. Each step is a spawned
# run (its name may have a prompt template — {task}/{title} substituted; otherwise a default that
# tells the agent which steps the workflow itself runs), an executed gate (`gate:<name>`), or the
# built-in `pr` (push + open the PR prefilled from the ledger). A failed step stops the workflow
# with a workflow_failed incident. Start: `swarm workflow ship <task>`; watch: Board → Workflows.
# [[workflows]]
# name = "ship"
# steps = ["implement", "gate:tests", "gate:review", "pr"]
# prompts = { implement = "Task {task}: {title}. Work only in this worktree; commit as you go." }
#
# Two more built-in steps need no claimed worktree (M13.10): "gates" runs the required gates on the
# default branch's head in a scratch worktree; "review-prs" runs the builtin review gate over each
# open GitHub PR from this repo (not forks), once per head.
# [[workflows]]
# name = "nightly"
# steps = ["gates", "review-prs"]

# Schedules (M13.10, OQ-30): start a workflow on a cron (5 fields, local time, or @hourly / @daily /
# @nightly / @weekly). Inert until armed — `swarm schedule arm nightly`; any edit disarms it.
# `post = true` lets review-prs post its findings as a plain COMMENT review (never approve or
# request changes). `task` defaults to "schedule-<name>".
# [[schedules]]
# name = "nightly"
# cron = "0 3 * * *"
# workflow = "nightly"
# post = false

[tasks]
# Optional backlog: a markdown file (relative to the repo root) whose `ID | Task | Depends | Status`
# tables are the task list. Feeds the Board's Tasks section, `swarm tasks` and `swarm_next_task`.
# source = "docs/plan.md"
# …or an issue tracker (M4.8), read-only, through the same Task shape:
# source = "github"      # `gh issue list` in the repo (gh must be logged in); ids GH-<n>
# labels = ["swarm"]     # GitHub only: issues carrying every listed label
# source = "linear"      # Linear GraphQL with LINEAR_API_KEY from the daemon's environment (never stored)
# team = "ENG"           # Linear only: team key; all teams when unset

[budget]
# Spend ceiling per project (0.7.0), judged against what the transcripts say was spent. At `warn_at`
# a `budget` incident opens (once per day); past 100% another does, and `on_exceed` decides what
# else: "warn" (nothing), "ask" (every Bash/Edit/Write asks first, in every session of the repo),
# "stop" (spawned runs stopped, the dispatch queue cleared). Ceilings are USD; unset = none.
# daily = 25
# weekly = 100
warn_at = 0.8
on_exceed = "warn"
# M12.3: the plan's 5-hour / 7-day windows (from Claude Code's status line, Pro / Max only). A
# `budget` incident opens once per window per reset period at this fraction, and again at 100%;
# false = never warn. Global in effect — a plan belongs to the user, not to a repo.
window_warn_at = 0.8

[dispatch]
# `swarm dispatch` (M7.5): autonomous runs per project at once; the rest queue. Defaults for those
# runs; `require_pr` = a dispatched task counts as done only once a PR is open for its branch.
max_parallel = 2
# permission_mode = "acceptEdits"
# model = "claude-sonnet-5"
# max_turns = 60
require_pr = true
# Permission profile for dispatched runs: "full" (default) | "no-edits" (commands, no file edits)
# | "read-only" (read and search only). Also `swarm run --profile` and the Run drawer.
# profile = "full"
# M12.6: wrap every spawned run (dispatch, `swarm run`, workflows, A/B arms) in Anthropic's
# sandbox-runtime (`srt`, optional: npm install -g @anthropic-ai/sandbox-runtime). A run that wants
# the sandbox and cannot get it is refused, never started unsandboxed. `swarm run --no-sandbox`
# turns it off for one run.
sandbox = false

[sandbox]
# What a sandboxed run may reach, besides the model API (api.anthropic.com, console.anthropic.com,
# claude.ai) and loopback. Hostnames or `*.` wildcards, optional `:port`; anything else is dropped.
# Writes are fixed: the claimed worktree, the repo's git dir, Claude Code's state, the temp dirs.
allowed_domains = []          # e.g. ["registry.npmjs.org", "*.github.com"]
# Also allow every remote host this project's agents reached in the last 14 days (the Security view).
seed = true

[worktree]
# Bootstrap every new worktree (claims and `swarm run` alike) so it starts warm (M7.1).
# `copy`: untracked files copied from the main checkout before setup — repo-relative only,
# a missing source is skipped. `setup`: one shell command run inside the worktree, in the
# background, output in ~/.swarm/logs/<project>/bootstrap-<task>.log; a non-zero exit opens a
# `bootstrap_failed` incident but the claim stays held. `swarm run` waits for it before spawning.
# copy  = [".env.local"]
# setup = "bun install"
# `open`: command for `swarm wt open` / the Board's Open action, `{path}` substituted (shell-quoted);
# default is the platform opener (`open` / `xdg-open` / `explorer`).
# open  = "code {path}"

How rules are enforced

The Claude Code PreToolUse hook posts every Bash command to the daemon, which evaluates it against the rules for that session's repo. ask/deny decisions are returned to Claude Code as permission decisions and recorded as incidents — visible on the Board view ("Incidents") and in the event stream (incident.opened).

The hook fails open when no daemon is reachable (a hook must never block work), but it now tries the default port when ~/.swarm/daemon.json points at a dead daemon, so a crashed or force-killed daemon doesn't silently disable the guard.

Source: docs/13-config.md · v0.16.0.