swarm

module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 15, 2026 License: MIT

README

swarm

CI

emmanueldeloget.com/swarm — what it looks like, in fewer words than this page.

Run a fleet of terminal agents — claude, codex, anything with a CLI — each in its own virtual terminal, and drive them all from one place: a TUI, a web page, or the swarm command itself. The agents get that same command, so they can talk to each other without you relaying messages.

swarm knows nothing about any particular agent. An agent is a command line.

swarm init          # write a starter swarm.yaml, and offer to .gitignore .swarm/
$EDITOR swarm.yaml  # list your agents
swarm run           # start the fleet + the TUI + the web remote

What it does

  • One window for the whole fleet. A list with live state, the selected agent's terminal beside it, and a mosaic view showing every agent at once.
  • A state per agent, derived from what it prints: working, idle — quiet long enough that it is probably waiting for input — plus whatever the configured regexps name, such as approval or error. The header counts them by state. An agent that owes an answer and has gone quiet is stalled, and swarm why says what it owes, to whom, since when, and the command that ends it — which the agent itself has usually forgotten by then.
  • Input from anywhere. Type into an agent from the TUI, from swarm inject, or from a browser. Send key presses (esc, ctrl+c, arrows). Stage a file or an image and inject its path.
  • A message bus. swarm send dev-3 "..." reaches an agent whether you type it or another agent does. Three modes: push types it into the recipient's prompt, pull leaves it for swarm inbox, defer holds it until the agent falls quiet. What a fleet says to itself can also be bounded — kinds, can_send, a turn budget per conversation, and a pause switch.
  • A fleet that keeps running. An agent whose command cannot start is relaunched with a doubling wait and given up on after restart_max tries, rather than every two seconds for ever; keys sent together are spaced by key_delay, so an agent whose UI changes state on one does not drop the rest.
  • A view of the talking. swarm bus tail and swarm bus stats show what the fleet says to itself — busiest pairs, threads, who is sending and who is only receiving — and the TUI marks an agent putting a lot on the bus. Agents that coordinate instead of working is the failure mode of a fleet, and it is invisible from the terminals.
  • Agents for one task. A template rather than an agent: swarm spawn worker "take ticket 219" makes worker-1, which is created owing that task and collected when it says the task is done. With workspace: worktree it gets its own directory and branch, taken back when it goes — and never taken with work still in it.
  • Incoming webhooks. Declarative rules turn an HTTP delivery into a bus message, so the fleet reacts to a pull request or a ticket without you relaying it. The listener is signature-checked and lives on its own port.
  • Outgoing webhooks, the same rules read backwards: an agent that finished, died or needs you becomes a signed POST to your endpoint. The fleet can say so without anyone watching it.
  • Remote control over HTTP, token-protected, with no JavaScript terminal library: swarm already emulates the terminals and sends ready-made HTML.

Install

go install github.com/emmanuel-deloget/swarm/cmd/swarm@latest

Or from a checkout:

go build -o swarm ./cmd/swarm

Requires Go 1.25+. Linux and macOS are the tested platforms; Windows works and is newer — see Windows.

On Windows, install it this way rather than downloading a binary. Nothing is published as a signed .exe yet, and an unsigned Go binary is regularly taken for malware by heuristics — go install compiles on your machine, so the question does not arise.

Configuration

swarm init writes a starter swarm.yaml: one agent, nothing listening on a port, and every other setting present as a commented example. Uncomment what you need — each block is written so it loads as it stands.

docs/configuration.md is the exhaustive reference: every key, its default, and what it does. swarm.example.yaml in this repository is the starter file, if you would rather read it before installing anything.

The short version:

session: default          # picks the control socket; two swarms can coexist
workdir: .                # default working directory for agents
state_dir: .swarm         # everything swarm writes; `swarm init` gitignores it

defaults:                 # inherited by every agent
  cols: 200               # geometry before anyone looks at the agent
  rows: 50
  follow_window: true     # resize the displayed agent to its pane
  idle_after: 3s          # quiet for this long → "idle"
  delivery: push          # bus messages are typed into the prompt
  submit_delay: 120ms     # pause between pasting and pressing Enter
  workspace: shared       # everyone works in workdir; see "Where an agent works"

web:
  enabled: true
  addr: 127.0.0.1:7777
  token: ""               # empty → a fresh one at every start, shown in the TUI

hooks:                    # incoming webhooks — see "Webhooks" below
  enabled: false
  addr: 127.0.0.1:7778

groups:                   # usable as @dev anywhere a target is expected
  dev: [dev-1, dev-2]

agents:
  - name: dev-1
    role: dev             # a role is a target too: @dev
    command: [claude]     # any argv; this is the only required field
    patterns:
      - match: "(?i)\\b(do you want|proceed\\?|\\[y/n\\])"
        state: approval   # shows up as a badge, and in the event log
        notify: true
  - name: dev-2
    role: dev
    command: [codex]
    workspace: clone      # its own copy of the repository
  - name: review-1
    role: review
    command: [codex]
    delivery: pull        # do not interrupt; it will run `swarm inbox`
    message: |            # its standing brief, typed once when it starts
      You review pull requests. Start with `swarm inbox`.

Add as many agents as you have work for; nothing in swarm assumes a number.

Where the config is looked up

swarm run uses -c <path> if you pass one. Otherwise it walks up from the working directory, and in each directory tries swarm.yaml, swarm.yml, .swarm.yaml, .swarm.yml, in that order — so any subdirectory of your project works. There is no global config; a swarm belongs to a project.

Relative paths inside the file (workdir, shared, tls_cert) resolve against the directory holding the file, never against your working directory.

The other commands do not really need the config — they only use it to find the control socket, in this order:

  1. -socket <path>
  2. $SWARM_SOCKET — already set inside every agent, which is why an agent can just run swarm send with no arguments of its own
  3. the config found as above → <config dir>/.swarm/<session>.sock, or wherever <session>.socketpath points if the socket had to be relocated
  4. ./.swarm/default.sock, if it exists

A target is an agent name, @group, @role, all, or a comma-separated list of those. Every command that acts on agents accepts one.

Patterns

A pattern is a regexp matched against the tail of the agent's screen. When it matches, the agent gets a state badge, optionally raises an event, and can even be answered automatically:

    patterns:
      - match: "Run this command\\? \\(y/n\\)"
        state: approval
        notify: true
        reply: "y"        # auto-answer — only for prompts you trust

The TUI

swarm run
key
j k , tab select an agent
19 jump to an agent
attach: your keys go to that agent, the detach key comes back
A attach full screen, with a byte-perfect keyboard
pgup pgdn scroll back through the agent's output — or page the agent itself, when it has taken the whole screen and keeps no scrollback
m mosaic: every agent at once
l show/hide the event log
M mouse reporting on/off (see below)
i s b inject / send a bus message / broadcast
f stage a file and inject its path
K send key presses
S x r start / stop / restart
d dialogue lock, on by default: typing talks to the agent (see below)
esc in dialogue: one shortcut · esc esc leaves the lock
: command line
ctrl+r on the command line: history, and search through it
q quit and stop every agent

Command line: :inject, :type (no Enter), :keys, :send, :broadcast, :file, :start, :stop, :restart, :resize, :web, :q. Omit the target and the command applies to the selected agent:send how is it going? reaches the agent you are looking at.

Tab completes commands, targets (agents, @group, @role, all), key names after :keys, and file paths after :file. One match is filled in; several extend as far as they agree, then tab cycles through them and the candidates are listed under the line.

The dialogue lock is on by default: you are here to talk to the agent on screen, so typing does that. A printable key opens the inject line carrying that key — you just type, no i first. esc reaches a shortcut for one key, the way a prefix does; esc esc leaves the lock for good, and d brings it back.

Whether the lock is on is remembered between runs, so a fleet comes back the way you left it. Each bar names the door to the other: with the lock off it carries d dialogue, with it on ↵ attach, since attaching goes on working — is not text, so the lock never sees it.

It matters because eighteen letters are shortcuts otherwise, three of them acting on an agent's life: typing merci pour la relecture at a fleet would cycle the mosaic, restart an agent and open an inject line. Arrows and tab still select an agent, since they are not text, and ctrl+ combinations are untouched. The status bar always says which mode you are in, and leaving the lock is remembered between runs.

and walk back through what you typed before, and ctrl+r searches it — type to narrow, ctrl+r again for an older match, esc to put back the line you were writing. The history is kept per command: opening the line with s offers what you sent, not the last file you staged, because the two share no shape and neither can be reused where the other was typed. The bare : line sees everything, since anything can be typed there.

It is written to <state_dir>/history, 0600 — it holds what you typed — and bounded at 500 lines, since older entries name agents that no longer exist.

The agent on display is resized to the pane it occupies and follows the window as it changes, so its own layout adapts instead of being cropped on the right. An agent nobody is looking at keeps its configured geometry. Set follow_window: false to pin that geometry instead — :resize then sets it by hand.

pgup scrolls back into the agent's scrollback (scrollback: lines per agent) and stops at the start of the session; the pane header shows how far back you are. pgdn returns to the live output.

In front of an agent that has taken over the screen there is nothing to scroll: a full-screen application keeps no scrollback and remembers what came before in its own way. The key is sent to the agent instead, and pages that — which used to mean attaching, pressing it, and detaching again.

The CLI

Every command talks to a running swarm over a Unix socket, so you can drive the fleet from any other terminal — or from a script.

swarm ls                            # the fleet and its state
swarm status @dev                   # more detail
swarm screen dev-1                  # what that terminal shows right now
swarm attach dev-1                  # take it over in this window
swarm logs dev-1 -f                 # recorded output, escape sequences stripped

swarm inject dev-1 "run the tests"  # type and submit
swarm inject dev-1 -submit=false "typed but not submitted"
swarm keys dev-1 esc ctrl+c         # key presses
swarm keys -list                    # the key names swarm understands
swarm keys -read                    # what this terminal sends for a key
swarm inject dev-1 -file shot.png "what is wrong here?"

swarm send @review "PR 42 is ready" # bus message
swarm send dev-1 -kind blocked "…"  # say what it is for
swarm send dev-1 -final "we ship A" # settled: nobody may answer
swarm broadcast "stopping in 5 min"
swarm inbox dev-1                   # read a mailbox
swarm stage diff.patch              # copy a file where every agent can read it

swarm bus tail -f                   # the messages agents send each other
swarm bus tail -n 0 -f              # ... only what happens from now on
swarm bus stats -since 30m          # how much of the fleet's time went into talking
swarm bus threads                   # the open conversations
swarm bus pause "shipping"          # hold every delivery; the agents keep working
swarm bus resume -flush             # let them through again
swarm done "nothing to change"      # settle what an agent was asked
swarm events -f                     # live event log
swarm start dev-3                   # start / stop / restart one or a group
swarm stop dev-3
swarm restart dev-3
swarm spawn worker "take rq-219"    # an agent for one task, collected when it is done
swarm why dev-22                    # why it is stalled, and how it gets out
swarm info                          # session, socket, web URL and token
swarm shutdown
swarm version                       # which build this is
swarm licenses                      # the terms of everything bundled in the binary
swarm config check [-fix]           # a config that has gone stale

swarm hook test delivery.json       # what the rules would send, offline
swarm hook post delivery.json       # sign a payload and post it for real
swarm hook sign delivery.json       # the digest the listener expects

While attached, the bottom row of the window is a status bar showing the agent name and the detach key; the agent gets the rows above it. swarm attach -no-status gives the whole window to the agent instead.

The mouse

Mouse reporting is off by default, and that is deliberate: a terminal that reports mouse events to an application stops selecting text itself, so turning it on costs you copy-paste of an agent's output. With it on, the wheel scrolls the pane and a click picks an agent.

M toggles it while running, and mouse: true starts with it on. Many terminals also let you select with shift held down while reporting is on.

Clicks reach the agent as well, when it tracks the mouse and the pane is showing its live screen — not while you are scrolled back, where a position would name a cell that has moved. Drags travel with them if the agent asked for movements, which is what selecting text inside an agent is made of.

None of that happens on Windows, and not for want of trying: a pseudoconsole does not pass an application's private modes back out, so swarm cannot see whether the agent wants the mouse — and an agent that is sent mouse reports it never asked for reads them as text. pgup and pgdn still reach it.

In front of an agent that has taken over the screen the wheel goes to the agent, for the same reason the page keys do: there is no scrollback here to move through. What it sends depends on what the agent asked for — a mouse report if it tracks the mouse, arrow keys if it does not, which is what a terminal sends in their place and what makes a wheel scroll a pager at all.

Key names

swarm keys -list prints every name, the bytes it sends, and the patterns that cover the rest: ctrl+<char>, alt+<char>, ^<char>, and a modifier on a navigation key — ctrl+left, shift+home, ctrl+shift+pgup. Several keys in one call are fine: swarm keys dev-1 esc ctrl+c enter.

swarm keys -read answers the other direction: press a key, and it prints the bytes your terminal actually sent and which name swarm would give them. It asks for mouse reporting too, so "the wheel does nothing" gets the same answer: a terminal that does not report the wheel sends up and down arrows instead, and you can see which one you have. That question is not rhetorical — a Windows console sends a plain backslash for ctrl+\, so the key that detached everywhere else typed into the agent instead. Where a binding does not fire, this says whether the key ever arrived.

A few names are sendable but not bindable, and the listing marks them: ctrl+enter and shift+enter send bytes an agent may well act on, but a terminal produces nothing distinct when you press them, so a key bound to one would be advertised and never fire. Binding one is refused, with the reason.

Detaching

ctrl+\ leaves an attached agent, in the TUI and in swarm attach alike — ctrl+g on Windows, whose console cannot produce ctrl+\. It is also what tmux, screen and asciinema like to grab, so it is configurable:

detach_key: 'ctrl+g'      # any bindable name: ctrl+], f12, esc esc

swarm run -detach-key ctrl+g and swarm attach -detach-key ctrl+g override it for one session — handy while recording. Whatever key it is no longer reaches the agent; the one it replaced does.

swarm run --no-tui runs it headless if you would rather drive it entirely from the CLI or the web.

Flags are accepted after the target, so swarm inject dev-1 -file shot.png "..." works. Free text is taken literally from the first plain word onwards, so a message can contain -json without it being parsed as a flag; use -- for a message that starts with a dash.

Injecting text, files and images

Text is sanitised (control characters that would drive the terminal are dropped) and wrapped in bracketed paste only when the agent's UI asked for it, the way a real terminal behaves — so a multi-line prompt arrives as one message, and an agent that does not support it never sees stray ^[[200~.

Images and other files travel as paths: -file copies the file into the shared directory and injects its absolute path, which is what agent CLIs read. The same happens when you drop a file in the web UI.

What was sent to an agent

swarm logs shows what an agent printed. With log_input: true, swarm also records what it sent, in .swarm/logs/<agent>.input.log:

2026-08-07T00:20:27+02:00	inject	"run the tests"
2026-08-07T00:20:27+02:00	submit	"\r"
2026-08-07T00:20:31+02:00	keys	"\f"
2026-08-07T00:20:33+02:00	terminal-reply	"\x1b[?62;c"

Each line says when, where it came from, and the exact bytes — including the answers the emulator gives to the agent's own queries. It settles "did swarm type that, or did the agent print it itself?" in one grep. Off by default, and written 0600: it holds what you typed.

Leaving an attach puts the terminal back the way it was found: alternate screen, cursor, scrolling region, and every mode the agent may have switched on — mouse reporting above all. An agent is never told the connection ended, so it never turns those off itself, and a terminal left reporting mouse events stops selecting text on its own. M in the TUI cannot help there: swarm did not turn it on, so it has nothing to turn off.

Agents for one task

An entry with ephemeral: true is not an agent but the shape of one. Nothing is started for it; swarm spawn makes instances from it that run one task and are collected when they say they have finished.

agents:
  - name: triage
    command: [claude]
    can_spawn: [worker]      # who may launch them

  - name: worker
    ephemeral: true
    command: [claude]
    max_alive: 3
swarm spawn worker "take ticket 219"      # prints: worker-1
swarm spawn worker -f brief.md            # or - for standard input

Spawning opens a debt, and that is the whole design. The task arrives as a bus request, so everything already built for debts applies to it: swarm why worker-1 says what it is on and since when, on_stalled asks it where it is when it goes quiet, the debt survives a restart of the hub, and swarm done — the agent saying the work is finished — is what collects it. Its task is its life.

A template's name is a group of whatever is alive, @worker, which is what you write into another agent's can_send. Launched by an agent, an instance gets SWARM_PARENT and the two can reach each other; when it dies, its parent is told in a message that stands on its own, since a parent that restarted may never have known it existed.

Refused, each because it fails later and less clearly: restarting an instance, which would return knowing nothing of the task it still owes; spawning without a task, which would make an agent nothing could ever collect; spawning past max_alive; and spawning at all without can_spawn.

workspace: worktree gives each instance its own directory and branch, sharing the repository's object store — no clone, no fetch, and two instances cannot edit the same file. It is the ephemeral agent that makes this workable: a worktree belonging to an agent that never ends is a branch nobody ever merges.

When an instance is collected, swarm takes its worktree back — and it has to, since a branch cannot be checked out in two worktrees at once, so leaving the directory would stop anyone picking the work up. What it will not do is take work with it. The refusal is git's own: git worktree remove declines a directory holding modified or untracked files, and swarm never passes --force, so a worktree with anything uncommitted in it is kept and its path printed. Committed work survives either way — removing a worktree keeps the branch — and the branch itself is only deleted once the remote has every commit on it.

swarm manages only the worktrees it made, under <state_dir>/worktrees/. An agent that opens one for itself is doing its job, and swarm does not look. What it cannot do is both: giving an agent a worktree and letting it create its own inside it puts two managers on one tree, which is the one arrangement to avoid — swarm will not detect it, because knowing that --worktree means something to claude and something else to another CLI is exactly the knowledge it refuses to have.

The configuration reference has the rest.

Where an agent works

Six agents on one checkout take turns at the index rather than working at once. workspace: says what swarm does about that, per agent:

mode
shared everyone works in workdir. The default, and right for agents that only read.
clone its own clone under <state_dir>/workspaces/<agent>, made once and kept between runs.
worktree its own git worktree and branch under <state_dir>/worktrees/<agent>, collected when an ephemeral instance ends.
none swarm provisions nothing and reads the directory the process is actually in — for a worktree you manage yourself.

A clone rather than a worktree for a durable workspace, and for a hard reason: two worktrees cannot have the same branch checked out, and several agents sitting on main between tasks is the normal case. An agent made for one task is never between tasks, which is why worktree exists for those and not for the rest. origin, user.* and gpg.* are carried over, or an agent commits unsigned under the wrong name. A directory that is already a checkout is left alone.

No fetch, no rebase, no merge. swarm reports where each agent works and how far its base has drifted — main* 3↑ 12↓ in swarm ls and in the pane header — and never acts on it. Making and collecting a worktree for an ephemeral agent is the one thing it does to a repository, and it neither integrates nor reviews what comes out of one. Telling agents to catch up is a webhook rule or a message, not swarm running git behind their backs.

The rest of what a working copy needs is not swarm's business either, so it hands it to you:

defaults:
  on_start: ["./scripts/prepare-agent.sh"]   # before the process is launched
  on_exit:  ["./scripts/cleanup-agent.sh"]   # after it has gone

env:                        # top level, or per agent
  PORT: "{alloc_port}"      # a free port, one per agent, stable across restarts

Each is an argv run in the agent's working directory with the agent's environment, so a script needs to know nothing about swarm. A failing on_start stops the agent instead of launching it into a half-prepared directory, and a stop waits for on_exit within the grace period. {alloc_port} exists because two dev servers both want 3000, and no amount of talking to each other settles that.

Agents talking to each other

Every agent gets swarm on its PATH, already pointed at the running session:

variable
$SWARM_AGENT its own name
$SWARM_ROLE its role
$SWARM_PEERS the other agents
$SWARM_ROOT the directory holding the config file
$SWARM_SHARED a directory every agent can read and write
$SWARM_SESSION the session name
$SWARM_SOCKET the control socket (used automatically)
$SWARM_STATE_DIR where swarm keeps its state

$SWARM_ROOT matters to an agent working in its own clone: it is the way back to the project the fleet was started for. The rest of the paths are absolute.

So an agent can do this on its own:

swarm ls                                  # the other agents and their state
swarm send review-2 "please review PR 42"
swarm send @dev -file report.md "findings"
swarm inbox -wait 30s                     # block until something arrives
swarm done "nothing to change"            # settle what you were asked

message: is what an agent is told at launch — its standing brief, written inline as a block scalar or kept in message_file:. It is typed when the agent first falls quiet rather than when its process starts: a CLI still drawing its banner would swallow it.

swarm run writes .swarm/AGENTS.md describing the fleet and these commands — point your agents' instructions at it and they can coordinate without you. It is generated from your configuration, so it only describes what you switched on: message kinds, a turn budget, who may reach whom. agents_template replaces it with your own.

Push recipients get messages typed into their prompt; pull recipients keep them queued until they ask; defer recipients get theirs when they next fall quiet, several at once if several arrived. Pull suits an agent that must not be interrupted at all, push one that is waiting for work, and defer most of the rest — it is push without cutting into what the agent was doing.

A question, a request or a blocked addressed to an agent leaves something outstanding until it answers or runs swarm done — and the message says which, so nobody has to guess. An agent that has owed something for bus.stalled_after and is idle is reported as stalled — in the agent list, the pane header and swarm ls, with its own glyph, as well as in the event log and to an outgoing webhook. Both halves matter: an agent with nothing to do is quiet and that is normal. swarm never restarts, kills or reassigns anything because of it, since the guess can be wrong and asking costs less.

Asking is the one thing it will do, and only if you ask it to:

bus:
  stalled_after: 15m
  on_stalled:
    - to: self          # ask the agent itself, three times, quarter-hour apart
      every: 15m
      max: 3
    - to: myself        # still stuck two hours later? triage should know
      after: 2h
      kind: question

With no rules, stalled stays what it was: something shown and something sent, and nothing else.

The message swarm writes is swarm why, addressed — who is waiting, since when, the question itself, and the commands that end it. That is the point of sending anything at all: an agent stalled long enough to be asked has been compacted, so what are you doing? gets an honest shrug, while the bus still has everything the agent lost.

Two shapes are refused, both learned the hard way. A question to the stalled agent itself is rejected at load: it opens a second debt on top of the one the agent is stuck on, and answering it settles neither, so the rule fires for ever. When a debt is what you want opened, open it from an agent that knows the work — to: <triage> with kind: question — and let it ask properly. And a debt swarm opened itself never starts the rules again, or telling triage about a stalled agent makes triage stalled, and swarm ends up chasing its own notices. That one was seen on a real fleet within a minute of the feature working.

Reminders are counted per debt and bounded by max; when a rule has used its last one it says so in the event log rather than going quiet as though it had worked. Messages are typed into the recipient's terminal whatever its delivery is — an agent that is not reading its mailbox is exactly the one this is for — which push: false turns off.

swarm why turns that signal into something anyone can act on:

swarm why dev-22      # or just `swarm why` inside an agent

It names who is waiting, what they asked, on which thread and since when, shows the message that opened the debt, and ends with the command that closes it.

That last part is the reason it exists. An agent stalled for two days has been through several context compactions by then, so the message that put it there is gone from the one place a reader would think to look — the agent's own memory, which is also why asking the agent gets you nowhere. The bus still has it: a debt lives until it is settled, so who asked and when survive anything that happens inside the agent. swarm is the fleet's external memory, and this is the command that reads it back.

What is outstanding survives a restart, in owed.json inside the state directory. It is the only thing swarm writes down that way, and the reason is circular: an agent stuck for days is itself why someone restarts the fleet — to upgrade the binary, to change the config, to try anything — and the restart used to take the explanation with it. The agents keep their own sessions across a restart; it was swarm that forgot, which is backwards for the part whose job is to remember what the agents cannot.

Each debt carries the question that opened it, so what comes back is the whole answer rather than its metadata. Restoring is reported and never silent: how many came back, how old the oldest is, and any debt belonging to an agent the fleet no longer has, which is dropped because nobody could settle it. A debt that is no longer true is cleared with swarm done.

Messages themselves are not persisted — they are bounded anyway, and swarm bus tail already answers for them. So a question can still outlive the history it was carried in, and swarm why says so in as many words rather than printing a blank where the question should be.

Beyond that, the configuration can bound the talking rather than hope for the best: delivery_by_kind lets the fleet defer while blocked still gets through, can_send says who may reach whom, bus.max_turns gives a conversation an end, and swarm bus pause stops all of it without stopping the fleet. The configuration reference has the detail.

Webhooks

With hooks.enabled, swarm listens for HTTP deliveries and turns them into bus messages, so the fleet reacts to something happening elsewhere instead of waiting for you to relay it.

It knows nothing about GitHub or any other sender. A rule names conditions on paths into the delivery and renders a message from the same paths:

hooks:
  enabled: true
  addr: 127.0.0.1:7778
  secret_path: .swarm/hook-secret     # or secret_env: HOOK_SECRET
  signature_header: X-Hub-Signature-256
  rules:
    - name: review-requested
      when:
        event: pull_request.review_requested   # a path into the JSON body
        data.member_id: "6aa593d4-…"           # …and another
      to: review-1
      message: "a review was asked of you on {data.repository}#{data.pull_request}"

    - name: merged-with-loose-issues
      when:
        header.X-Hub-Event: pull_request.merged  # a header, not the body
        data.mentioned_issues_left_open: '~\[.+\]'
      to: triage-1
      message: "{data.repository}#{data.number} was merged leaving issues open"

  unmatched:                          # only when no rule matched
    to: triage-1
    message: "unhandled event ({event}) — worth a rule?"

A path addresses the decoded JSON body, or a header when it starts with header.. It walks objects and arrays: data.commits.0.message. A value is matched exactly, or "*" for mere presence, or ~ followed by a regexp. Conditions are ANDed; every matching rule fires.

to: is deliberately not templated. A payload must never choose which agent it wakes up — route by member with one rule per member, so the config decides what an identifier means and an unknown one wakes nobody.

Signatures

The digest covers the raw body, so it is checked before the payload is even decoded, and a delivery without a valid one is refused: an endpoint that accepts unsigned payloads once accepts them always.

Senders disagree on the encoding, and guessing wrong looks exactly like a wrong secret — so hex and base64 are both accepted, with or without a sha256= label. swarm hook sign prints both forms; compare them against a real delivery and whichever matches tells you the convention. If neither does, the secret is wrong.

The secret comes from exactly one of secret_path, secret_env or secret. Naming two is an error rather than a precedence rule. A file must not be readable by group or others, and trailing newlines are stripped — the one openssl rand -hex 32 > file leaves behind is invisible and changes the digest completely.

The listener has its own address rather than a route on the web remote control. That one is guarded by a token which travels in URLs and can type into every terminal; a webhook endpoint has to be reachable by whatever sends the events. Those two exposures have no business sharing a socket.

Working out why nothing happened

A webhook that does nothing looks the same from the outside whether it never arrived, was refused, matched no rule, or reached a stopped agent. Every delivery is recorded in full in .swarm/logs/webhooks.log:

=== 2026-08-07T16:44:58+02:00  delivery #3  accepted, 1 delivery(ies) ===
  header   X-Hub-Signature-256: sha256=89dc3a50…
  body     111 bytes
  {"data":{"mentioned_issues_left_open":[448],"number":294},"event":"pull_request.merged"}
  rule     review-requested       no     event is "pull_request.merged", want "pull_request.review_requested"
  rule     merged-loose-issues    MATCH
  send     merged-loose-issues → triage-1: reqwire#294 was merged leaving…
  answered 202

--- 2026-08-07T16:44:59+02:00  delivered: merged-loose-issues → triage-1

Each rule says which condition failed and what was there instead. The body is on one line so it can be pasted into a file and replayed offline with swarm hook test, which goes through the same matching code the listener uses — a simulation, not a second implementation that could drift.

The log is on by default (hooks.log: false turns it off) and written 0600: a payload carries whatever the sender put in it. Credentials in headers are redacted; the signature is not, since comparing it is what settles a rejection.

Writing the message

The message ends up in an agent's prompt, and a title or a branch name is written by whoever opened the pull request — on a public repository, by anyone. Prefer structural fields (a number, a URL, a repository) and let the agent fetch the rest itself; values are truncated, but truncation is not a defence against text that reads as an instruction.

swarm run -no-hooks disables the listener for one run.

Remote control

With web.enabled, swarm run prints a URL carrying a token:

http://127.0.0.1:7777?t=1a2b3c...

The page shows the agent list, the selected terminal (live, and you can type into it), a grid view, the event log, and a composer that either types into the prompt or sends a bus message. Uploading a file stages it and injects its path.

Past localhost, treat that URL as a shell on your machine:

  • set web.token to something you chose, or keep the generated one;
  • set web.read_only: true if you only want to watch;
  • put it behind TLS (web.tls_cert / web.tls_key) or a tunnel (ssh -R, cloudflared) rather than binding 0.0.0.0 in the open.

How it works

                        ┌──────────────┐
  swarm run ───────────►│     hub      │  fleet, events, message bus
                        └──┬────────┬──┘
             ┌─────────────┘        └──────────────┐
      ┌──────▼──────┐                       ┌──────▼──────┐
      │ agent dev-1 │  pty + VT emulator    │ agent rev-1 │
      └──────┬──────┘                       └─────────────┘
             │ argv: claude, codex, ...
             ▼
   ┌───────────────────┬────────────────────┬──────────────────┐
   │ TUI (bubbletea)   │ unix socket (IPC)  │ HTTP + WebSocket │
   │                   │  ← swarm CLI       │  ← browser       │
   │                   │  ← agents          │                  │
   └───────────────────┴────────────────────┴──────────────────┘

Each agent runs in a real pty, so it behaves exactly as it would in your terminal — job control, ^C, terminal queries, alternate screen. swarm keeps a virtual terminal emulator in sync with each pty, which is what makes it possible to render a snapshot of any agent at any time instead of replaying a byte stream that may start mid-sequence. The TUI renders that snapshot as ANSI, the web server renders it as HTML lines and sends only the ones that changed.

The control socket lives in .swarm/<session>.sock, or in the runtime directory with a pointer file when the project path is too long for a Unix socket.

Layout

cmd/swarm the CLI, including run
internal/vterm pty + terminal emulator, injection primitives
internal/agent one supervised agent: lifecycle, state, patterns
internal/hub the fleet, the environment agents get, routing
internal/bus mailboxes, threads, what the fleet said
internal/workspace provisions an agent's clone, and reads where it stands
internal/guide the AGENTS.md a fleet generates for itself
internal/hook inbound webhooks: rules, signatures, the delivery log
internal/ipc the Unix socket protocol
internal/ui the TUI
internal/web the remote control

Windows

swarm runs on Windows 10 build 17763 (1809) or later, which is what CreatePseudoConsole requires. Everything the tour above describes works there: the TUI, attaching, the bus, the control socket, swarm send from inside an agent. Continuous integration runs it on a Windows runner alongside Linux and macOS: the terminal, the fleet, the bus, the control socket and the end-to-end tests that drive the real binary.

It is the youngest of the three, and these are its differences. None is a surprise waiting to be found — they are here because they are what a first day on Windows runs into.

detach_key ctrl+g, not ctrl+\. A Windows console translates keys itself and its support for ctrl with punctuation is incomplete: ctrl+\ and ctrl+] arrive as a plain backslash and bracket, so neither can be a shortcut. Everything else came through — the arrows with ctrl, shift and alt included.
alt+enter The console's own full-screen toggle. It never reaches swarm, so it cannot be bound.
swarm attach No status bar on the last row: holding one needs a scrolling region the console does not honour, and the bar ends up stacked across the screen. The reminder goes in the window title instead, until an agent sets a title of its own.
secret_path Not checked. Windows has no POSIX modes — every readable file reports 0666 — and who may open a file is its ACL, which mode bits cannot express. On a shared machine, put the secret somewhere your account alone can read.
workspace: none Reports the branch of the directory an agent started in, even after it has moved. Following a process needs /proc, which only Linux has; macOS is in the same position.
The mouse Clicks, drags and the wheel are not passed to agents. A pseudoconsole does not carry an application's private modes back out, so swarm cannot tell whether an agent wants them — and sending them regardless would be read as text. Mouse mode still works for swarm's own interface.
Fonts The shortcut bar writes enter where it writes elsewhere: the raster fonts the older console offers have no glyph for it. Windows Terminal does.

Two things are worth knowing about the console you run it in. The older conhost (the plain "Command Prompt" window) works, and swarm asks it to interpret escape sequences at startup — but its font may lack the symbols above. Windows Terminal has them, and is the default on Windows 11.

If a key does not do what you expect, swarm keys -read prints the bytes your terminal actually sent for it, and the name swarm gives them. That is how the list above was established rather than guessed.

Limits

  • Windows is supported and newer than the rest; its differences are listed above.
  • Attaching from the TUI () reconstructs key bytes from parsed events, which covers text, control keys, arrows and arrows held with ctrl/shift/alt, but not exotic sequences or mouse input. A runs the real swarm attach instead, which passes bytes through unchanged.
  • reply: in a pattern answers a prompt on your behalf. Use it only for prompts you would always answer the same way.
  • The webhook listener holds one secret, so it trusts one sender: giving a second source the same secret means either can impersonate the other. It also does not deduplicate retries — a sender that resends a delivery it thinks failed will produce a second message.

Development

go test ./...                  # add -race; CI runs -race -shuffle=on
golangci-lint run ./...        # config in .golangci.yml
govulncheck ./...              # run it with the latest Go, as CI does

Adding or upgrading a dependency changes what the binary contains, so the notices behind swarm licenses have to be regenerated:

go generate ./internal/licenses    # collects each module's licence from the module cache

You are unlikely to have to remember that. Nothing about a missing notice fails to build, so the check is a test instead: it asks the toolchain what is actually linked — once per operating system, since conpty is only linked on Windows and termios only away from it — and fails both ways, on a module with no notice and on a notice for a module that is gone. The failure names the module and the command to run.

A module that ships no licence file at all stops the generator, on purpose. Write down what its terms actually are in internal/licenses/data/manual/, under the module's path; quoting what upstream declares is better than synthesising a licence text in someone else's name.

CI runs the suite on Linux and macOS at the Go version in go.mod, plus the current Go release, and separately checks go vet, gofmt, go mod tidy, golangci-lint and govulncheck. It also runs weekly, so an advisory published without a commit still shows up. A Windows runner covers the terminal, the fleet, the bus, the control socket and the end-to-end tests; three packages still drive their children through a shell and stay out of it.

Commits are signed off and signed. Two hooks check it, in hooks/, which git uses once you point it there:

git config core.hooksPath hooks

commit-msg refuses a message with no Signed-off-by — checked rather than added, since a sign-off a tool writes on your behalf attests to nothing. pre-push refuses to send a commit that is not signed off, or not signed with the key in user.signingkey; the signature can only be checked once the commit exists, and a push is the last moment before it leaves the machine.

govulncheck deliberately runs with the latest Go rather than the version in go.mod: it reports standard-library advisories for the toolchain it runs with, and those are fixed by the newest patch release.

Licence

MIT — see LICENSE.

swarm carries other people's work inside its binary: the Go modules it links against, and JuliaMono, the font the web UI draws a terminal with. Their terms travel with it, and any copy can be asked for them:

swarm licenses                      # what is in this binary, and under what terms
swarm licenses juliamono            # one of them in full
swarm licenses -all > NOTICES.txt   # every text, for an audit or a release

The same list is a page in the web UI, linked from the header. Neither fetches anything from the network.

The font is bundled rather than named in a CSS font stack because a stack can only ask for what the machine already has: a terminal draws its frames out of box-drawing characters, and a machine without them borrows them from a proportional font, which pulls every frame apart. It is the reason the binary is about two megabytes larger than it would otherwise be.

Directories

Path Synopsis
cmd
swarm command
Command swarm runs a fleet of terminal agents and lets you drive them from one place: a TUI, a web page, or the swarm CLI itself — which is also how the agents talk to each other.
Command swarm runs a fleet of terminal agents and lets you drive them from one place: a TUI, a web page, or the swarm CLI itself — which is also how the agents talk to each other.
internal
agent
Package agent turns one configured command into a supervised agent running in its own virtual terminal, with a state derived from what it prints.
Package agent turns one configured command into a supervised agent running in its own virtual terminal, with a state derived from what it prints.
bus
Package bus stores the messages agents send each other.
Package bus stores the messages agents send each other.
config
Package config loads and validates the swarm fleet description.
Package config loads and validates the swarm fleet description.
event
Package event carries what happens in the swarm to whoever is watching: the TUI, the web clients, the log.
Package event carries what happens in the swarm to whoever is watching: the TUI, the web clients, the log.
guide
Package guide writes the file agents read to learn how to talk to each other.
Package guide writes the file agents read to learn how to talk to each other.
hook
Package hook turns incoming webhooks into bus messages.
Package hook turns incoming webhooks into bus messages.
hub
Package hub owns the fleet: it creates the agents, wires their environment so they can talk back to swarm, and routes every command to them.
Package hub owns the fleet: it creates the agents, wires their environment so they can talk back to swarm, and routes every command to them.
ipc
Package ipc is the control channel between the running swarm and every `swarm <command>` invocation, including the ones agents make themselves.
Package ipc is the control channel between the running swarm and every `swarm <command>` invocation, including the ones agents make themselves.
licenses
Package licenses carries the terms of everything swarm ships inside its binary, so that a copy of swarm can always answer for what is in it.
Package licenses carries the terms of everything swarm ships inside its binary, so that a copy of swarm can always answer for what is in it.
licenses/gen command
Command gen collects the licence of every module swarm links against and writes it into ../data, where the licences package embeds it.
Command gen collects the licence of every module swarm links against and writes it into ../data, where the licences package embeds it.
probe
Package probe is the child process the tests drive instead of a shell.
Package probe is the child process the tests drive instead of a shell.
probe/cmd/probe command
Command probe is the child process the end-to-end tests drive instead of a shell.
Command probe is the child process the end-to-end tests drive instead of a shell.
sockpath
Package sockpath decides where a session's control socket lives.
Package sockpath decides where a session's control socket lives.
ui
Package ui is the terminal interface: one place to watch every agent, jump into one, and drive the fleet without leaving the keyboard.
Package ui is the terminal interface: one place to watch every agent, jump into one, and drive the fleet without leaving the keyboard.
version
Package version reports which swarm this is.
Package version reports which swarm this is.
vterm
Package vterm runs a child process inside a pseudo-terminal and keeps a virtual terminal emulator in sync with its output.
Package vterm runs a child process inside a pseudo-terminal and keeps a virtual terminal emulator in sync with its output.
web
Package web serves the remote control: a single page that shows every agent's terminal and lets you type into it from another machine — or from a phone.
Package web serves the remote control: a single page that shows every agent's terminal and lets you type into it from another machine — or from a phone.
workspace
Package workspace provisions a durable working copy for an agent.
Package workspace provisions a durable working copy for an agent.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL