Architecture
How Baton is built — the zero-dependency daemon, the SSE event bus, the SQLite signal store, and where every byte of state lives.
Baton is deliberately boring under the hood: one small daemon, one event bus, files and SQLite inside your repo. This page is for the reader who wants to know exactly what runs, what it writes, and why it doesn't fall over with six agents attached. (Core concepts is the gentler tour; this is the engine room.)
Process model
There are only two kinds of process:
- The CLI (
baton …) — short-lived commands. Each one reads state, does its job, writes state, exits. No background magic. - The daemon (
baton serve) — a single long-lived Node process that serves the JSON API, the SSE stream, and the built dashboard on127.0.0.1:7077.
The HTTP layer is raw node:http — no Express, no framework, zero runtime
dependencies. That is an explicit design rule, not an accident: the daemon is
the trusted component with read access to your repo, so its supply chain is
kept as close to nothing as possible.
The event bus
Everything realtime flows through one in-process bus. File watchers publish
file.edited, the task layer publishes task.created, the signal store
publishes signal.overlap — and every subscriber (the SSE fan-out, timers,
housekeeping) attaches at that single choke point.
Two properties matter for stability:
- Subscriber isolation. Each callback is wrapped: one throwing subscriber loses its own event, never the process. A bad edit-signal row can't kill the daemon that five other agents rely on.
- One writer path. New event types are added to the bus first, so there is exactly one place to look when asking "what can happen live?"
Reads are shared, not multiplied
A status poller diffs git state on a short interval only while a dashboard is
connected, and both the SSE stream and plain API reads ride that same
snapshot. Idle daemon ≈ zero git activity; a busy dashboard doesn't multiply
into dozens of git spawns per second. Slow SSE consumers are cut off past a
buffer cap — a frozen browser tab can't grow the daemon's heap.
Where state lives
| Path | What it holds |
|---|---|
.baton/tasks.json | task metadata — slug, branch, worktree path, base commit |
.baton/wt/<slug>/ | one git worktree per task (the isolation boundary) |
.baton/wt/<slug>/HANDOFF.md | the session brief — plain markdown |
.baton/ SQLite | edit signals, per-file history, completion reports |
.baton/reports/ | what each finished task shipped and cost |
CODEBASE.md + graph output | the knowledge-base map (rebuilt on commit) |
No external database, no cloud state. Everything is a file or a SQLite database
inside the repo — back up the repo and you've backed up Baton;
rm -rf .baton/ and it never existed.
The signal store
Edit signals are written by agent hooks on every file touch and read constantly
(check_files, the Conflicts screen, baton signals). The store is
node:sqlite in WAL mode with a busy-timeout, tuned for exactly that shape:
many small concurrent writers, frequent readers, no daemon required for
correctness. Signals expire on a rolling window and are reconciled against
git status — a holder that is provably gone (no task, no session, no checkout
on disk) is reclaimed, so a deleted task can't pin "343 files being edited"
forever.
Git, hardened
Every git call goes through one shell-free exec wrapper: arguments are argv
arrays, never interpolated strings, so a branch named ; rm -rf ~ is just a
weird branch name. Merge, worktree, and history operations all share that single
audited path.
The MCP server
baton mcp speaks Model Context Protocol over stdio, and baton connect
wires it into each agent's own config. That's what turns coordination from
documentation into an API — an agent asks check_files before editing,
who_touched before refactoring, get_report before redoing finished
work, all inside its own loop.