baton

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 on 127.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

PathWhat it holds
.baton/tasks.jsontask metadata — slug, branch, worktree path, base commit
.baton/wt/<slug>/one git worktree per task (the isolation boundary)
.baton/wt/<slug>/HANDOFF.mdthe session brief — plain markdown
.baton/ SQLiteedit signals, per-file history, completion reports
.baton/reports/what each finished task shipped and cost
CODEBASE.md + graph outputthe 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.

On this page