New here and not sure where to look?
docs/README.mdis the map of all the documentation, organized by who you are.
The payoff is driving your whole fleet from a chat channel — you talk to the XO in Discord and it runs the rest. This is the cold start that gets you there: from nothing to (1) sending a message into another agent's terminal, (2) running the self-continuing XO clock, and (3) wiring the Discord mirror + inbound relay (§4 and §6) that turn the channel into your day-to-day cockpit. Every command below is runnable as written — no prior flotilla knowledge assumed.
- Go 1.26+ (to build the binary; matches the module's
godirective). - tmux — every coordinated agent runs in a tmux pane; flotilla delivers by typing into that pane.
- A supported agent you can run in a tmux pane — Claude Code, Codex, or
Grok. flotilla does not launch agents; it talks to ones you
already run. This walkthrough uses Claude Code (the default surface).
sendconfirms a real turn started, so its target must be a live agent: a pane that has dropped to a bare shell is treated as a crashed agent and the delivery is refused (you can watch that refusal with zero setup — see §3 — but seeing a successful delivery needs a running agent). - (Optional) a Discord bot + channel — only if you want the audit mirror or
the inbound relay. The clock and
sendwork fully without Discord.
git clone https://github.com/jim80net/flotilla.git
cd flotilla
go install ./cmd/flotilla # puts `flotilla` in $(go env GOPATH)/bin
flotilla versionMake sure $(go env GOPATH)/bin is on your PATH (e.g.
export PATH="$PATH:$(go env GOPATH)/bin").
The roster is a small JSON file describing your fleet. It carries no
secrets — it is safe to commit. Create flotilla.json:
{
"agents": [
{ "name": "infra" },
{ "name": "research" }
]
}name is both the command-line identifier and the tmux pane title flotilla
matches on. Matching accepts the bare name or a single status-glyph prefix —
so a Claude Code pane that renames itself to ✳ infra still matches infra. If
an agent's pane title differs from its name, set it explicitly:
{ "name": "research", "tmux_title": "deep-research" }Resolution is strict: flotilla errors if no pane or more than one pane matches a name, so it never silently mis-delivers.
Set a default once so you can omit
--rostereverywhere:export FLOTILLA_ROSTER=$PWD/flotilla.json
send delivers an instruction into a live agent's pane and confirms the turn
actually started — it idle-gates, submits, then verifies, rather than typing
blindly and assuming success. So the target must be a running agent; a pane
sitting at a bare shell is reported as a crashed agent and the delivery is
refused.
A TUI agent renames its own pane title every turn — a pane launched as
infra becomes ✳ Refactor the auth module once it starts working — so
title-based resolution drifts the moment the agent gets to work. The fix is a
stable, drift-immune marker set with flotilla register. Set it in the same
line that launches the agent, so the pane is tagged before the agent takes over:
tmux new-session -d -s demo
tmux send-keys -t demo 'flotilla register infra && exec claude' Enterflotilla register infra, run inside the pane, reads $TMUX_PANE and tags it:
registered infra → pane demo:0.0 (marker @flotilla_agent=infra); title drift no longer breaks resolution
exec claude then starts your agent (any supported surface). The marker is a
per-pane tmux @flotilla_agent user-option, so it survives the agent taking
over the pane and every title change after — send resolves by it regardless
of how the title drifts. Putting flotilla register <name> in each desk's launch
line is the standard pattern; it falls back to title matching for any untagged
pane, so it is purely additive.
Already started an agent untagged? Tag it from anywhere with an explicit target — no need to interrupt it:
flotilla register infra --pane demo:0.0(or a pane id like%4).
flotilla send --from me infra "pull origin main and run the tests"delivered to infra (pane demo:0.0) — turn confirmed
The instruction is typed into the pane (a bracketed paste plus a single Enter, so
multi-line bodies arrive as one submission, not many) and lands as the agent's
next turn. send reports a typed failure instead of a false success when it
cannot confirm a turn — the message is never silently dropped:
| What you see | Means |
|---|---|
delivered to … — turn confirmed |
the turn started ✓ |
is at a shell (crashed) — NOT delivered |
the pane is a bare shell (the agent exited) — flotilla refuses to type into a dead pane |
is busy (mid-turn) — NOT delivered; retry when it is idle |
the agent is mid-turn; resend when it is idle |
See the crash-detection guard with zero setup: point
sendat a plain shell pane (no agent running) and it reportsis at a shell (crashed) — NOT delivered. That refusal is the feature — flotilla never types into a pane whose agent has died.
Long or multi-line bodies are easier from a file or stdin (no shell quoting):
flotilla send --from me --file ./instructions.txt infra
echo "deploy when green" | flotilla send --from me --file - infraInter-agent mirroring is default-off, so by default a send just delivers to
the pane (no Discord post). See §4 to enable it; --no-mirror also forces it off.
When a desk's process dies — or the whole tmux server dies — resume
deterministically restarts it from a host-local launch recipe: the desk's
launch command and working directory (plus an optional tmux target and a state
pointer). The recipes live in a separate, gitignored, host-local file — a
sibling of your secrets file — because a worktree's absolute path is specific to
this host and must not land in the committable roster. The file is trusted at
the secrets level: recipes are shell-run, so anyone who can write it can
already write flotilla-secrets.env.
// flotilla-launch.json (host-local; gitignored)
{
"agents": {
"infra": {
"launch": "claude -w infra",
"cwd": "/home/operator/work/infra-worktree",
"tmux": "flotilla:infra",
"state": ".claude/handoffs/latest.md"
},
"research": {
"launch": "cd /home/operator/work/research && claude --continue",
"cwd": "/home/operator/work/research"
}
}
}launch(required) — the shell command that (re)starts the desk; it is the pane's foreground process (so a compoundcd x && claude --continueworks).cwd(required, absolute) — the working directory / worktree to launch in.tmux(optional) — thesession:windowto create the pane in; defaultflotilla-<name>:desk(one detached session per desk). Legacy recipes may use the sharedflotilla:<name>session.state(optional) — a pointer to the desk's handoff/context doc, printed for you to drive/takeover(resume does not auto-restore context).
flotilla resume infra # default launch file: <roster-dir>/flotilla-launch.json
flotilla resume infra --force # resume even if the pane is a LIVE session (kills it first)Seats carrying an e2e-only conservation policy in their fleet-ops-owned
capacity-hold.json remain on their current fallback even after capacity is
marked CLEARED. A primary/Codex resume or switch fails before pane or overlay
mutation. Only a scheduled e2e or canary run should use the explicit
--scheduled-e2e authorization; it does not override an active capacity hold.
resume resolves the desk by its stable marker first: an existing pane is
respawned in place (and refuses a live session unless --force —
restart is not resume-and-act); a mis-tagged (ambiguous) fleet is refused;
with no pane it cold-creates the desk's window — cold-starting the tmux
server if the whole server died — and tags it. Load is fail-closed: a single
malformed recipe blocks the whole file, so fix the bad entry before any desk can
be resumed. The launch file matches the default .gitignore's
/flotilla-launch.json line; if you point --launch at a non-default path, you
own keeping it out of version control.
Launch recipes live in one fleet-wide flotilla-launch.json next to the roster
(one agents.<name> entry per desk: launch command, worktree cwd, optional tmux).
Each desk also gets a git worktree and a host workspace ~/.flotilla/<agent>/
for heartbeat prompt (HEARTBEAT.md), working tracker (state.md), and skills —
not for launch recipes. Provision one — --repo is required:
flotilla workspace init infra --repo /abs/path/to/your/repo
flotilla workspace path infra # prints ~/.flotilla/infraThis creates a worktree on a new branch named after the agent, upserts the desk's
entry in flotilla-launch.json when absent, and seeds constitutional doctrine into
the worktree's native identity file (CLAUDE.md for Claude, AGENTS.md for Grok or
Codex). Edit flotilla-launch.json for launch commands, models, and failover chains —
that file is what flotilla resume and flotilla recycle read.
The workspace lives under $HOME (the daemon must run as the same user — the shipped
flotilla-watch is a --user service).
To get a durable, phone-readable transcript of inter-agent coordination, mirror sends to a Discord channel under per-agent webhook identities. Inter-agent mirroring is default-off — it clutters the operator's channel with intra-fleet chatter that already lives in the tmux panes — so you opt in per-roster or per-call.
If you're standing up a whole flotilla's Discord presence, there's a mechanical
alternative to the manual steps below: flotilla provision-discord <flotilla-key> creates the category, the XO command channel, the product hub,
both roster bindings, and the XO webhook in one idempotent command (preview it
credential-free with --dry-run) — see the "Spawn layout" section of
docs/federation.md. The manual per-agent webhook steps below remain valid and
are all the audit mirror itself needs.
-
Create one webhook per agent in your channel (Channel → Edit → Integrations → Webhooks). Name each after the agent.
-
Put the urls in a secrets file — never commit this (
chmod 600):# flotilla-secrets.env FLOTILLA_WEBHOOK_INFRA=https://discord.com/api/webhooks/... FLOTILLA_WEBHOOK_RESEARCH=https://discord.com/api/webhooks/...The env-var name is
FLOTILLA_WEBHOOK_+ the agent name upper-cased with-→_(soresearch→FLOTILLA_WEBHOOK_RESEARCH). -
Point sends at it:
# mirroring is default-off, so enable it — per roster (always) or per call: flotilla send --from me --secrets ./flotilla-secrets.env --mirror infra "rebuilding now" # or set "mirror_inter_agent": true in the roster to mirror every send
Precedence:
--no-mirror(off) →--mirror(on) → rostermirror_inter_agent→ off.--mirrorand--no-mirrortogether is an error. The operator-facingflotilla notify(below) always posts, regardless of this setting.
The audit mirror above is for coordination traffic and is opt-in (default-off).
When an agent (typically the XO) wants to
message you, the operator, on Discord — with no tmux pane involved — use
notify. It posts the message body straight to that agent's own webhook and
does nothing else:
# under the agent's own webhook identity, straight to the channel — no tmux:
flotilla notify --from xo --secrets ./flotilla-secrets.env "release is green, your call"
# multi-line bodies come from a file or stdin, like send:
flotilla notify --from xo --secrets ./flotilla-secrets.env --file ./status.md
echo "deploy finished" | flotilla notify --from xo --secrets ./flotilla-secrets.env --file -It reuses the same per-agent webhooks (FLOTILLA_WEBHOOK_<AGENT>) and
--secrets source as the audit mirror. The message must be ≤ 2000 characters
(Discord's hard limit); a longer one is rejected cleanly and nothing is
posted — shorten or split it. Unlike send's best-effort mirror, a notify
failure is a command failure (exit non-zero), because the post is the point.
When should the XO use
notify? As a standing convention: the XO replies to genuine operator messages on Discord withnotify, and stays quiet on heartbeat acks and routine inter-agent traffic — so you can follow the operator ↔ XO conversation from the channel. That doctrine, and how to wire it into your XO, is docs/xo-doctrine.md.
The watch daemon heartbeats one agent — the XO — on an inactivity
interval, so a turn-based agent keeps advancing already-authorized work without
you prompting it, and it watches liveness so a dead or stuck XO is surfaced.
Add three fields to the roster:
{
"agents": [
{ "name": "infra" },
{ "name": "research" }
],
"xo_agent": "infra",
"heartbeat_interval": "20m"
}xo_agent— which agent receives the heartbeat (must be one ofagents).heartbeat_interval— a Go duration ("20m","45s"); empty or"0"disables the heartbeat.heartbeat_message(optional) — override the default tick wording (e.g. to name your project's concrete work sources). A sensible default is used when omitted.mirror_inter_agent(optional, defaultfalse) — whentrue, everyflotilla sendmirrors to the Discord audit channel; default-off keeps inter-agent coordination in the panes (onlyflotilla notifyposts). A per-call--mirror/--no-mirroroverrides it.
Run it (clock-only — no Discord needed):
flotilla watch --roster ./flotilla.json --ack-file ./flotilla-xo-aliveYou should see clock running — XO=infra interval=20m0s. On each tick the XO is
asked to ack by touching the ack file; the daemon watches that file's mtime and
raises a down-alert after --max-missed-acks (default 3) consecutive misses.
The timer resets on every real delivery, so a synthetic tick fires only after a
true inactivity gap.
The XO pane must be a live agent, not a bare shell. The watchdog treats a pane that has dropped to a shell as a crashed agent: it raises a down-alert (
XO unresponsive) and suppresses the tick — it will not wind a dead clock. So pointxo_agentat a pane actually running your agent. (To try the clock without a real agent, run any foreground process in the pane first, e.g.tmux send-keys -t <pane> 'sleep 600' Enter, so it isn't detected as a bare shell.)
watch flags:
| flag | default | meaning |
|---|---|---|
--roster |
./flotilla.json or $FLOTILLA_ROSTER |
roster path |
--ack-file |
$FLOTILLA_ACK_FILE, else <roster-dir>/flotilla-xo-alive |
XO liveness ack file |
--secrets |
$FLOTILLA_SECRETS |
secrets env file (down-alert webhook + relay bot token) |
--max-missed-acks |
3 |
missed-ack window K (intervals) before a down-alert |
Run it under a process manager (systemd, etc.) so it stays up; see
watch-runbook.md for the systemd unit template +
deploy/flotilla-watch-install.sh installer.
The default clock wakes the XO every interval. To wake it only when something
material changes — a desk finishes or crashes, or the state tracker changes —
set change_detector: true:
{
"agents": [{ "name": "infra" }, { "name": "research" }],
"xo_agent": "infra",
"heartbeat_interval": "20m",
"change_detector": true,
"liveness_ping_mode": "none"
}Now an idle fleet costs nothing: the XO sleeps until a desk transition or a
tracker change wakes it with a targeted prompt, and its context is rotated after
each settled handling. liveness_ping_mode trades idle cost against the
wedge-detection window — none (default, true $0-idle), interval (strict
window), or consecutive (middle ground). The XO maintains a settle marker
(to say "idle") and an awaiting marker (to protect an outstanding operator
question from a context rotate) — wire the
XO doctrine
into the XO's standing instructions. See the
runbook for the full
flag + file surface (--snapshot-file, --awaiting-file, --settled-file,
--tracker-file, --max-quiet-intervals, --max-self-continuations).
Once the change-detector is running, flotilla status prints one line per desk —
its last-known pane state, its loop posture (#524), and for the XO its
last-ack age and whether it has settled — without attaching to any pane:
flotilla status
flotilla status --json # machine-readable: agents[].state + agents[].loop_postureflotilla status — states as of 12s ago (./flotilla-detector-state.json)
XO research · last ack 7s ago · active
infra working composing
research idle available (XO)
data crashed crashed
feature unknown unknown
It is read-only: it reads the snapshot the detector already writes
(--snapshot-file) plus the XO ack file (--ack-file) and per-agent backlog /
settle markers for loop_posture — no daemon, no pane probing, no new state.
The states are the detector's view as of its last tick, so the header always
reports the snapshot's age; a desk the detector hasn't seen yet (or with no
snapshot at all) shows unknown. crashed means the desk dropped to a bare
shell (its agent process is gone). Pane idle is not enough: loop_posture
distinguishes available / parked / drifted / awaiting-authority (parked
requires a known-empty unblocked backlog — strict default). Run it from the same
directory as your roster, or point it with --roster / --snapshot-file /
--ack-file (it honors the same $FLOTILLA_* env vars as watch).
The relay streams your Discord coordination channel and injects your messages
into the right pane: @infra do X goes to infra; a bare message goes to the
XO. It needs, in addition to step 4's bot:
guild_id,channel_id, andoperator_user_id(your Discord user id) in the roster — the operator id is the allow-list; flotilla acts only on your messages and ignores the channel's own webhook posts (so the bus can't feed back on itself).FLOTILLA_BOT_TOKENin the secrets file.- The bot's Message Content privileged intent enabled (Discord developer portal), and the bot added to the channel.
Then run watch with --secrets; it logs relay active instead of
clock-only. Because the channel becomes a command surface, enable 2FA on
your Discord account.
Running several flotillas as a fleet of fleets — per-project Discord
channels, a #fleet-command channel, the meta-XO, and the chief-of-staff
context ledger — is its own topic. When one project outgrows a single XO, or
you want one place to steer many projects, see
federation.md.
Coordinators who use the dash Goals view maintain fleet-goals.yaml beside
the roster and compile it to fleet-goals.json for the dash to read. The schema
is documented in fleet-goals.example.yaml at the repo root (generic examples
only).
flotilla goals validate --roster ./flotilla.json # fail-closed after edits
flotilla goals compile --roster ./flotilla.json # yaml → json for the dash
flotilla goals link --goal <id> --issue owner/repo#N # attach work to a goalAn operator-gated goal or work item may set paper_id to a publication-relative
research document ID such as authorization/design.md. The compiler rejects
absolute, hidden, traversal, backslash, and non-Markdown IDs. R&D uses this field
as the canonical paper deep-link; a safe /research/... link in brief remains
a compatibility fallback.
A coding agent walking a user through setup should follow llm.md §7 for the
full validate / compile / link flow with examples.
no tmux pane titled "X"— the pane title doesn't match the agent name. Checktmux list-panes -a -F '#{pane_title}'; settmux_titlein the roster if the pane title differs.ambiguous: N tmux panes titled "X"— two panes share the title; rename one or give the agent a uniquetmux_title.- Message pasted but not submitted — a non-Claude/modal target may not have bracketed-paste mode; multi-line delivery is validated for Claude Code's TUI specifically.
- Clock never ticks —
heartbeat_intervalis empty or"0"(disabled); the XO pane keeps looking busy (the tick is idle-gated); or the XO pane is a bare shell, which the watchdog treats as a crashed agent (you'll seeXO unresponsive) — pointxo_agentat a pane running a live agent.