Every agent session, on every machine, one roster.

rosterd is one daemon per machine. It knows every coding agent session there, whoever started it, drives headless ones over ACP, and joins a swarm over Tailscale so any node, or the phone in your pocket, answers for all of them.

rosterd 3 nodes 7 sessions 1 needs attention live
gibson this node
api: swarm changesclaude, headless, ~/Code/rosterdneeds attention, permission
Bash git push origin main Allow Allow always Deny
board: pullerclaude, tmux 2:1, ~/Code/workspaceactive, tool_call
[finance]codex, interactive, ~/Code/financeidle, 4m
mbp peer, 1s
docs: pages siteclaude, headless, ~/Code/rosterdactive, message
migrate: holdersclaude, headless, session keptsuspended
arch peer, unreachable 41s
[pi]pi, tmux pane, ~/src/piidle, stale
[scratch]claude, no claim yetunknown

What it is

A roster, not a scheduler

rosterd keeps one table per machine: every harness process (Claude Code, Codex, pi), what it is doing right now, and where a human can reach it. Activity comes from what the harness says through hooks or ACP. rosterd never guesses from time, CPU or output.

A runner for headless work

Start a session over the API and rosterd owns it through a holder: prompt it, watch its plan and context fill, answer its permissions and questions from any surface, suspend it when idle and resume it with the same harness session on the next prompt.

A swarm, not a server

Nodes find each other on the tailnet and exchange full snapshots. No leader, no database. Ask any node and you get every machine; act on a session and the request is proxied to its owner. A peer that drops keeps its last snapshot, dimmed, with its age.

It is built to be built on. One machine or ten, a tool above it sees one roster, one address space and one set of actions; the network is rosterd's problem. The vision.

Three surfaces, one truth

The roster page: two headless sessions waiting on a human, one with a Bash permission and Allow, Reject buttons, one with a form question and its three choices, then interactive Claude and Codex sessions with their CPU, memory and uptime
The roster page every node serves at /ui. Same rows on the tray and the phone: name, harness, state and its age, the folder, the load of the process tree, and the pending permission or question with its buttons.
The same roster on a phone width, with the permission and question buttons stacked
The phone: paired with a QR from the page, over Tailscale.

Menu bar

rosterd-tray on macOS and Linux. Sessions grouped by state, the attention count beside the icon, Allow, Allow always and Deny on the row, a click jumps to the tmux pane or the conversation view.

Page and phone

A single HTML page served by every node, over loopback or the Tailscale IP. The SwiftUI app is the same page with a scanner for the pairing QR. Buttons only: no prompt box, no free text.

Shell and agents

One binary. rosterd list --swarm, watch, changes, allow, prompt, every command with --json. Agents get the same over an MCP server: list, spawn, prompt, read a recap.

What it refuses to do

The boundaries are the product. rosterd never:

Anything that keeps a record over sessions, a project board, a task runner, a workspace, is a client of the API and only a client. It pulls the roster, subscribes to changes, answers through the action endpoints, and maps sessions to its own ids on its side.

Install

# macOS: the release bottle, a LaunchAgent and the tray
brew tap sbusso/rosterd https://github.com/sbusso/rosterd && brew trust sbusso/rosterd
brew install rosterd && brew services start rosterd

# Linux: the release tarball, a systemd user unit
tar xzf rosterd-<ver>-x86_64-unknown-linux-gnu.tar.gz
rosterd-<ver>/packaging/install.sh --from rosterd-<ver>/dist/x86_64-unknown-linux-gnu

# Arch, from source
makepkg -si -p packaging/arch/PKGBUILD

# then, on every machine: hooks, ACP adapters, the tray at login, a swarm to join
rosterd setup

rosterd setup is one row per thing the machine needs: binaries on PATH, config, the service, per harness its binary, adapter and hooks, the tray, and optionally a swarm. Space picks, enter runs, --yes runs it headless. It is the only step that edits ~/.claude and ~/.codex.

The brick and the connector.

rosterd is deliberately small: one daemon per machine that tells the truth about agent sessions, can drive them, and hides the network. Anything built on top sees one roster, one address space and one set of actions, whether there is one machine or ten.

What you build a project board a task runner an approval workflow a coordinator agent a bot, a bar, a dashboard One contract, any node GET /swarm/snapshot · GET /swarm/changes · GET /sessions/{key} · POST prompt, permission, answer, spawn, name · MCP rosterd on gibsonthe brick: roster, runner, holders,scanner, intake, apithe truth about this machine rosterd on mbpsame binary, same modules,its own key and rosterthe truth about this machine rosterd on archsame binary, same modules,its own key and rosterthe truth about this machine the connectordiscovery, membership, signed snapshots, proxied actions, peer loss: handled below this line, never seen above it underneath: Claude Code, Codex, pi, in a terminal or under a holder, on whichever machine they happen to run

Independent by design

Nothing points up. rosterd holds no URL, token or id of anything that uses it, never dials out, and keeps no history a client could depend on. A client is anything with an HTTP client and a bearer: it pulls a snapshot, follows the change stream, and answers through a handful of POSTs. Replacing a client costs rosterd nothing; replacing rosterd under a client costs the client nothing, because the record is versioned, additive, and unknown fields are ignored.

That independence is the point, not a constraint. A daemon that reads no transcript can run on every machine, including the one with the client secrets. A daemon that schedules nothing lets the tool above own timing and policy. A daemon with no database has nothing to migrate, back up or reconcile. A daemon that never dials out needs no per-client configuration and cannot leak to the wrong place.

The mesh, abstracted

A tool never addresses a machine. It addresses a session, by key or by name, and asks any node. Which machine owns it, whether that machine is reachable, how the request gets there and how the answer comes back, is rosterd's business: discovery over the tailnet, membership by invite and gossip, one signed snapshot stream per peer, actions proxied to the owner with the sender's signature, a lost peer served dimmed from its last snapshot. From above, a swarm of ten looks exactly like one node with more rows.

The same abstraction runs in the other direction. A harness does not know it is on a mesh either. Hooks post to a local socket; ACP flows through a local holder; the MCP server is a local socket. Nothing in a harness's configuration mentions another machine.

Control workflows live on top

rosterd ships four activity words and five actions, on purpose. Every richer behaviour is composed above it, by a tool that owns its own rules, and each tool can be different. An approval workflow that only allows git push after the test session reports idle with a green recap is thirty lines against the contract, and nothing inside rosterd had to learn what a test is:

# any language with an HTTP client; here, the shape of it
for event in sse("/swarm/changes"):
    if event.name != "attention" or event.record.activity_event != "permission": continue
    key, pending = event.record.session_key, get(f"/sessions/{key}").pending
    if not (pending.tool == "Bash" and "git push" in pending.summary):
        continue                                        # a human answers everything else
    tests = get(f"/sessions/tests:{event.record.name}")  # the sibling by name, wherever it runs
    ok = tests.activity == "idle" and "passed" in tests.recap
    post(f"/sessions/{key}/permission", option="allow" if ok else "reject")

The same shape gives a board its attempts, a runner its queue, a bot its notifications, a coordinator its delegation, a dashboard its spend. Each keeps its own state, on its own side, in its own ids. rosterd keeps the roster.

Where it goes

More bricks

The same binary on Windows. A node on a machine with a GPU, a node in a container on a server, a node on a laptop that comes and goes. Each is the same shape; the swarm is the sum.

More connectors

Every harness with an ACP adapter or a hook is a lane; every surface with an HTTP client is a client. The list of both grows without the contract changing.

Bigger tools

Placement, budgets, fleet policies, a control plane for agents the way Kubernetes is for containers: built on the contract, by whoever needs them, with rosterd unchanged underneath.

Architecture

One binary, six modules, and a small holder process per headless session. Everything a node knows is in memory and rebuilt in under a second from holders, hook claims and a process scan.

Inside a node

Clients tray, page, phone CLI, scripts agents over MCP a board, a task runner rosterd, one node api unix socketloopback + bearertailscale + httpsMCP server/ui page roster one record per sessionclaims with a sequencesource precedencefull snapshots, /eventsnever blocks on runner meshdiscovery, membershipsnapshots in, proxy out scanner/proc, libproc, 2 stmux handles runnerACP client: start, prompt, cancel, permission,question, login; suspend, resume, crash-resume intake/register, /claim, /namehooks and launchers holders, one process per headless session owns the adapter's stdio, listens on a unix socket, relays JSON-RPC unchanged, keeps the last 256 notifications for a reconnecting daemon, writes a state file, exits when the adapter exits. Restarting rosterd does not restart holders. Outside the node peersother nodes on the tailnet,each serving its own rosterhttps, signed, swarm key interactive harnessesclaude, codex, pi in aterminal, tmux ACP adaptersclaude-agent-acp,codex-acp, pi-acp: oneheadless harness each stdio /events hooks scan

The roster answers even when the runner is wedged: rosterd list and watch read the roster module only. The mesh reads the roster to serve peers and writes peer snapshots beside it, never into it. Every node is the only writer of its own roster, which is why the swarm needs no merge.

Process model

rosterd owns no harness process directly. A headless session is holder → ACP adapter → harness. The holder is a separate small binary shipped with rosterd; it starts the adapter with stdio pipes, listens on a per-session socket under the runtime directory, relays JSON-RPC both ways unchanged, keeps a replay buffer of the last 256 notifications, and writes a state file with the session id, harness, cwd, adapter PID and start time. On start rosterd lists the holder directory, reconnects to every live holder, replays the buffer and rebuilds those sessions.

This is what makes the daemon disposable. Kill it during a turn, restart it: the session is still live, the turn completes, no claim is lost.

The swarm

tailnetTailscale ACLs are the network boundary, the swarm key the application boundary gibsondesktop, Linux own roster: 3 sessions mbp snapshot, 1 s old arch snapshot, 41 s, unreachable :8791 https, node key mbplaptop, macOS own roster: 2 sessions gibson snapshot, 1 s old arch snapshot, 41 s, unreachable :8791 https, node key archserver, off the tailnet own roster: 2 sessions last seen 41 s ago; its snapshot is kept and served dimmed, 24 h SSE, both ways no answer phone, on mbp POST /sessions/{key}/permission for a session gibson owns, sent to mbp, proxied to gibson with mbp's signature, answered on gibson's ACP

Identity

An Ed25519 keypair per node, generated at first start; node_id is the public key fingerprint. The name is pinned in config. A renamed machine is a new name, the id stays.

Discovery and membership

tailscale status lists the online peers; each is probed at /node/hello, a signed answer with capabilities. The first rosterd invite creates the swarm: a single-use, one-hour token. join presents it; membership gossips from there, newest signed record per node wins. revoke is signed and gossiped too.

Snapshot exchange

Each node keeps one SSE connection to every peer's /events and stores the latest full snapshot with the time it arrived. No diff format, no vector clock, no merge: every node is the only writer of its own roster. A lost peer is served with a growing peer_age_ms and peer_state: unreachable, dropped after 24 hours.

Trust

Node to node is HTTPS on the Tailscale interface, a self-signed certificate pinned to the node key, requests signed by the sender, the swarm key in a header. Loopback uses a bearer token. A node refuses to start with a listener anywhere else.

The record

One record per session, schema rosterd.snapshot.v1. Consumers ignore unknown fields. Nested processes of one session collapse into the root; a harness whose parent is a different session is its own record with parent_session_key.

fieldwhat it is
node, node_idthe owning machine, name and key fingerprint
session_keynode_id:pid:start_ticks, stable for the life of the process
harness, session_idclaude, codex, pi or free text; the harness's own session id when known
laneheadless (rosterd started it over ACP) or interactive (a human or a terminal manager owns it)
sourcessubset of launcher, acp, hook, files, scan
activity, activity_event, activity_at, activity_seqone of active, idle, needs_attention, unknown; the event that set it, when, and a sequence clients dedupe on
name, cwd, parent_session_keydisplay name from launcher, hook or the agent itself; the folder; the spawning session
tmux, holder, ttyruntime handles: where a human can reach the session
liveness, ended_at, ended_reasonlive, suspended, ended; exit, crash, reboot, killed, expired
usagetokens, cost, context fill as the harness reported them over ACP. Never estimated.
mode, plan, loadthe harness mode (plan, code…), its plan for the turn, and the CPU share and RSS of the process tree from the last scan

Cross platform

Linux reads /proc and runs as a systemd user unit; macOS reads libproc and runs as a LaunchAgent in the GUI session so open reaches the display and the tmux server; Windows uses toolhelp, named pipes and a service, with an interactive lane of hooks only. The holder, the hook script and the opener are the only platform-conditional code.

Data and event flows

A session's state is a series of claims. Each claim names a source, an activity word, an event and a sequence; the roster accepts or rejects it by precedence and publishes the whole table. Everything downstream is derived from that.

Sources and precedence

highestlowest launchera script started it andregistered pid, name, cwd,the tmux handle acpeverything the runner seesfrom a session it drives:turns, tools, permissions hookSessionStart, UserPrompt,PreToolUse, Permission,Notification, Stop filesopt-in, off by default:session ids and timestampsfrom the harness directories scanprocess enumeration every2 s; strays become unknown,liveness of every pid

A lower source never overwrites a value a higher one set, but every source can fill a null. A headless session has both acp and hook; when they disagree, ACP wins. rosterd explain KEY shows which source set each field and which claims were rejected and why: the truthfulness view.

ACP traffic to activity

ACPactivityevent
session/prompt sent by the runneractiveprompt
agent message chunk, tool call, tool call in progressactivemessage, tool_call
session/request_permissionneeds_attentionpermission
elicitation/create (a form question)needs_attentionquestion
authentication required on session/newneeds_attentionlogin
permission or question answeredactivepermission_answered
prompt response returnedidleturn_end
session/cancel sentidlecancelled
adapter exitendedexit or crash

Plan, mode and context fill ride along: plan replaces the whole list on every notification, current_mode_update sets mode, usage_update fills usage. The final assistant message of a turn is kept as the recap when the session was started with recap: true; nothing else of the transcript is stored.

A permission, answered from the phone

harnessholderrosterd gibsonrosterd mbpphone via its adapterownerany peer session/request_permission relayed unchanged over the socket claim: needs_attention, permission, seq 41;the request is kept pending /events: the full gibson snapshot diff against the last frame:/swarm/changes → attention SSE, the record with the pending request Bash · Allow · Deny POST /sessions/{key}/permission proxied to the owner, signed by mbp response to the pending request claim: active, permission_answered, seq 42

Every client sees the same thing because every client reads the same record. A board that keeps its own decision log reads the pending request from GET /sessions/{key} and answers it the same way; the tray answers it with a click; the CLI with rosterd allow KEY. Sound, badges and urgency stay the client's job.

The change stream

GET /swarm/events is one full swarm snapshot per change anywhere: simplest to consume, heavy to notify on. GET /swarm/changes is the diff, computed by the node between consecutive frames, so a client learns what happened instead of re-reading the roster:

eventwhen
snapshotfirst, and the resync point for a client that missed events
session_started, session_ended, session_suspendeda key seen for the first time; liveness became ended, ended_reason says why; a holder stopped on purpose
attentionan accepted claim left the session needing a human; sent again for every new claim while it waits; record.activity_event is permission, question or login
attention_clearedthe session no longer needs a human and has not ended
activity, renamedany other accepted claim; a name change
node, node_lefta node appeared, went unreachable or came back; a node was revoked or left

Pure function of two frames, derived and never stored. rosterd changes prints it one line per change; attention is the one to notify on.

A client that would rather be called than hold the stream open registers a webhook: POST /hooks {url, events, token}. Every matching event then arrives as one POST, {event, data}, with the name in X-Rosterd-Event and the token as a bearer. rosterd keeps the URL and nothing else about the caller. A failed delivery is dropped, and the client catches up from the last seq it saw with /swarm/changes?since=.

Session lifecycle

liveholder running, harness alive active idle needs_attention unknown suspended holder stopped on purpose,no process, session_id kept,the record stays with everythingneeded to come back ended exit, crash, killed,reboot, expired, suspendedkept in the snapshotfor a while idle_timeout_s, or rosterd suspend a prompt or rosterd resume:session/load, new key, same session_id adapter exit, holder crash, rosterd stop KEY expired crash with a stored session_id: resumed once within 5 minutes

The runner never resumes on a timer. There is no wake-up schedule; a suspended session comes back only when something addresses it: a prompt from the CLI, the MCP tool or a client, or rosterd resume. On reboot every headless session is restored as suspended, none is resumed until addressed. Resumes are counted per hour; past max_resumes_per_hour a session stays suspended with a warning.

Restart, crash, reboot

Use cases

What people actually do with it, and the parts of rosterd each one leans on.

Approve from anywhere

A developer with agents on a desktop, a laptop and a server, and a phone.

Agents run under policy attention. When one wants to push, delete or spend, the request lands on the roster, the tray badge counts it, the phone shows the tool and its argument summary with Allow, Allow always and Deny. The answer goes to whichever node the phone is paired with and is proxied to the owner. Questions from the agent (a form with choices) and a harness that needs a login show up the same way.

Leans on: attention policy, /swarm/changes attention events, the proxied permission and answer endpoints, the QR pairing over Tailscale.

One board for every machine

A project board or task runner that tracks tasks and attempts.

The board is a client. It subscribes to /swarm/changes on any node, or registers a webhook and is called, maps session keys to its own attempt ids, dedupes on activity_seq, and shows who is working on what across the fleet. When an attempt needs a human it reads the pending request from GET /sessions/{key} and answers it. rosterd carries none of the board's ids and never dials it.

Leans on: the client contract, /swarm/snapshot, /swarm/changes or /hooks, the session action endpoints.

Headless workers on the big machine

A runner that starts agents on a server and reads results.

POST /sessions with a harness, a cwd, a name and a policy starts a session under a holder; prompt with wait_until: idle returns with the recap. Idle sessions are suspended after the timeout and resume on the next prompt with the harness's own session id, so a worker keeps its context across days without holding a process.

Leans on: the runner, holders, suspend and resume, the recap.

A coordinator that delegates

An agent that breaks work into parts for other agents.

Every session started by rosterd or the launcher has the rosterd MCP server in its config. The coordinator calls session.spawn to create children under itself, session.prompt to hand them work, session.read_state for activity and the last recap, and session.send to address a named session anywhere in the swarm. No transcript ever crosses; a recap and a state word do.

Leans on: the MCP server, parent_session_key, cross-node prompts.

Take a session with you

Started on the laptop, finished on the desktop.

A headless session is its harness session id plus a cwd. rosterd handoff KEY --to gibson suspends it here and resumes it there, same session id, new key, on a repo that exists on both sides. The record moves in the swarm roster; clients see session_suspended on one node and session_started on the other.

Leans on: suspend and resume, holder state, the mesh.

Know what the fleet burns

Anyone paying for several harnesses across several machines.

Every ACP usage_update lands on the record: tokens, cost, context fill. Rolled up per node, harness and day it answers where the spend goes and which subscription window is near its limit, in the spirit of OpenUsage but for what actually ran, never estimated.

Leans on: usage on the record, the roll-up endpoint and the journal.

Everything still in tmux

A developer who runs agents by hand.

Nothing changes in the terminal. The hook posts claims, the scanner fills the tmux handle, the roster shows the pane; a click on the tray, the page or the phone jumps to it. Sessions nobody registered still appear as unknown with their folder and load.

Leans on: hooks, the scanner, the opener.

What it can do, how, and what comes next

One page, top to bottom: the capabilities of a node, the way each one is built, and the directions the swarm opens. Sections marked new are landing now.

See

Every session, whoever started it

Interactive sessions in a terminal or tmux; headless ones under a holder; strays nobody registered. The scanner walks the process table every two seconds, confirms liveness of every known pid and adds new harness processes as unknown.

Four words, never a guess

active, idle, needs_attention, unknown, from the last accepted claim. The age shown is the age of that claim, never "time spent working". A session only the scanner knows is unknown until a hook or ACP speaks.

Where to reach it

The tmux pane or the conversation view at /ui/sessions/{key}. Load from the process tree, the harness mode, the plan for the turn, the context fill, the pending request.

$ rosterd list --swarm
gibson   3 active  1 idle  1 needs attention        reachable
mbp      1 active  1 suspended                      reachable
arch     2 idle                                     stale 41s

api: swarm changes   claude  needs_attention  4s   headless     gibson  84560
board: puller        claude  active           58s  interactive  gibson  71022
[finance]            codex   idle             4m   interactive  gibson  61840
docs: pages site     claude  active           1s   headless     mbp     3341
migrate: holders     claude  suspended        2h   headless     mbp     -

Act

Answer

A permission gets Allow, Allow always (when the harness offers it) and Deny. A form question gets its choices. A harness that needs a login gets the login. From the tray, the page, the phone, the CLI or a client's own UI, proxied to the owner.

Drive

prompt sends a turn, optionally waiting for idle, attention or end. cancel stops the turn and keeps the session; stop ends the holder. name labels it; agents label their own session through MCP.

Open

open resolves the runtime handle: the opener script for tmux, the conversation view for headless. The page renders the ACP stream live with the last recap; it stores none of it.

Run

  1. POST /sessions (or rosterd start) with harness, cwd, name, model, effort, policy and env. The runner starts a holder, the holder starts the adapter, ACP initialize and session/new run, the session id is stored, the record registers with sources acp and launcher.
  2. The launch environment carries ROSTERD_SOCKET, so hooks inside the harness post claims too; ACP wins when they disagree. The rosterd MCP server is in the harness's config under the name rosterd.
  3. Turns flow: prompt, message chunks, tool calls, permission requests, plan and mode updates, usage, turn end. Each becomes a claim with a sequence.
  4. Idle past idle_timeout_s: the holder is stopped, the record stays suspended with its session id. A pending permission keeps the session live because it cannot survive the harness.
  5. The next prompt starts a new holder, session/load with the stored id, waits for it to settle, delivers the prompt. The caller sees one call that takes a little longer.

Policy auto approves every permission and stays active; attention leaves it pending. A child spawned through session.spawn inherits the parent's policy unless the call overrides it. Codex and pi join through their own adapters; pi's extension supplies the claims and the gate the adapter lacks.

Federate

A swarm is nodes that answer for each other. Join with an invite, then rosterd list --swarm on any machine is the union roster and any action on a session owned elsewhere is proxied to its owner with the sender's signature. Membership and revocations gossip in every hello. A peer off the tailnet is served from its last snapshot, dimmed, with its age; back on, the stale snapshot is replaced. Nothing else changes when a peer is unreachable, and nothing waits on one.

Learn new

Node-level events

/swarm/changes turns the snapshot stream into named events: sessions starting, ending, suspending; attention raised and cleared; nodes joining, dropping, coming back. attention is the one every client notifies on, and it carries the record with the pending request so the notification can act. A missed stretch resyncs from the next snapshot.

Attention as the first-class thing

Every surface leads with what needs a human: the tray badge, the page's first section, the phone's first card, rosterd changes. A permission, a question and a login are the three shapes; each names the tool, the question or the harness, and offers only the buttons the harness offered. No free text anywhere, so nothing a client shows can be turned into a prompt.

Model and credential health

Per node, per harness: logged in or not, rate limited until when, last successful session/new. It rides in the hello capabilities so rosterd nodes shows it and a client can pick a node whose harness can actually start. A harness that refuses a session for want of a login raises attention with activity_event: login.

Usage across the fleet

The usage a harness reports over ACP (input and output tokens, cost, context fill) is rolled up by node, harness, session and day: rosterd usage --swarm --since 7d, and the same frame over the API. What OpenUsage does for a subscription on one Mac, this does for what actually ran on every node, from the harness's own numbers. Never estimated; a harness that reports nothing shows nothing.

Journal

An append-only, per-node log of the change events: one line per start, end, suspension, attention raised and answered, node change. It is agent logging at the level a human reads, without a transcript. rosterd journal --since replays it, and a client that was down catches up from it instead of re-deriving from snapshots. Rotated by size; never a decision record.

Hand off new

A headless session is portable: its harness session id, cwd, name and policy. rosterd handoff KEY --to NODE suspends it on the owner, ships the holder state to the target over the mesh, and resumes it there under a new key with the same session id. The repo has to exist on both sides; that is the caller's business, rosterd checks the cwd and refuses otherwise. Laptop to desktop before a long run, desktop to server before closing the lid.

mbp migrate: holders, live, key mbp:3341session_id 9f2c…, cwd ~/Code/rosterd→ suspended, then ended: handoff holder state over the meshsession_id, harness, cwd, name, policy gibson migrate: holders, live, key gibson:9027same session_id, session/loadthe next prompt lands here

Agents talking to agents new

The MCP server already lets a session spawn children and prompt them. session.send completes the loop: address any session in the swarm by name, wait until it is idle, read its recap. Two agents on two machines can hand each other work with the roster as the address book, and rosterd still schedules nothing: every prompt is sent by someone, a human or an agent, never by a timer.

# from inside a session, through the rosterd MCP server
session.spawn      { harness: "claude", cwd: "~/Code/rosterd", name: "tests: holder" }
session.send       { name: "tests: holder", prompt: "run the relay tests", wait_until: "idle" }
session.read_state { name: "tests: holder" }      → { activity: "idle", recap: "3 passed…" }

Opportunities

A control plane for agents, the way Kubernetes is for containers

Today a node is a machine and a client picks it. The same primitives, capabilities in the hello, load on the record, handoff between nodes, are what placement needs: POST /swarm/sessions that picks the node with the harness, the repo and the headroom. Not built, deliberately; the roster has to be trustworthy first.

A third operating system

Windows is specified: toolhelp, named pipes, a service, hooks only in the interactive lane. A swarm that spans all three is where the mesh stops being a convenience.

Clients that are not ours

The client contract is small: two SSE streams, one GET, a handful of POSTs. An Omarchy bar module, a Raycast extension, a Slack bot answering permissions, a CI job that waits on attention. Each is an afternoon.

More harnesses

Anything with an ACP adapter is a headless lane; anything with hooks or an extension point is an interactive lane. pi showed the pattern: an adapter for the runner, an extension for claims and the gate.

Usage that drives choices

Once spend and window limits are on every node, a client can prefer the harness with headroom, or warn before a long run starts on a nearly spent subscription. The data is there; the policy stays in the client.

What stays out

Terminal rendering, decision history, rules over agent behaviour, credential management, cross-swarm federation, relays for machines that cannot reach each other, multi-user nodes. Saying no is what keeps a node small enough to trust.

API, MCP, CLI

All JSON, all on the local socket and the loopback listener with a bearer token; the swarm paths also on the Tailscale listener. Every CLI command with --json prints the API frame for its resource byte for byte.

Endpoints

pathwhat
GET /snapshotthe complete node roster
GET /eventsSSE, the complete node roster on every change
GET /statusnode, listeners, swarm, counts, sources
POST /register, /claim, /nameintake from launchers and hooks: a session identity, an activity claim, a display name
POST /gatethe pi extension's gate: waits for allow or deny on a tool call
POST /sessionsstart a headless session: harness, cwd, name, model, effort, permission_policy, env, recap
GET /sessions/{key}one record in full: the pending permission, question or login, the last recap, the children
PATCH, DELETE /sessions/{key}change the policy or the name mid-life; stop the holder
POST /sessions/{key}/prompt{ prompt, wait_until?: idle|needs_attention|ended, timeout_s? }
POST /sessions/{key}/permission, /answeranswer the pending permission with an option id; answer the pending question with the form values
POST /sessions/{key}/cancel, /suspend, /resume, /open, /namethe session actions
POST /sessions/{key}/spawna child session under this one: harness, cwd, name, model, permission_policy; returns its key and record
GET /sessions/{key}/streamthe raw ACP notification stream for a client that renders a conversation; not stored
GET /sessions/{key}/explainwhich source set each field and when, which claims were rejected and why
GET /swarm/snapshotthe union roster, each node tagged with peer_age_ms and peer_state
GET /swarm/eventsSSE, the full swarm snapshot on any change anywhere
GET /swarm/changesSSE, snapshot once, then one event per change
GET /swarm/nodesmembership with health and capabilities
/swarm/{node_id}/sessions/…any session path, proxied to that node
GET /pair, GET /ui, /ui/sessions/{key}the phone pairing payload; the page and the conversation view
GET /node/hello, POST /node/joinnode to node: the signed hello, the join with an invite

The session paths are the same under the loopback listener and, prefixed with the owning node, over the swarm. Any node accepts an action for any session and proxies it; the response travels back.

MCP tools

Served on the same socket under the name rosterd, in the config of every harness started by the runner or the launcher. The caller is identified by its PID from the socket peer credentials.

toolwhat
roster.list, roster.watchthe roster, node or swarm scope
session.namerename the caller's own session
session.spawna child under the caller; returns its key and record
session.prompt, session.senda turn for a session by key, or by name anywhere in the swarm, with an optional wait
session.read_stateactivity and the last recap, never the transcript
swarm.nodesmembership and health

CLI

rosterd list|watch [--swarm|--node N]      one row per session; watch reprints on every change
rosterd changes                            one line per change across the swarm
rosterd status | nodes                     this node; swarm membership and health
rosterd read KEY | explain KEY             one session in full; which source set each field
rosterd start --harness H --cwd DIR [--name L] [--policy auto|attention] [--model M] [--effort E] [--env K=V]
rosterd prompt KEY TEXT [--wait idle|needs_attention|ended] [--timeout SECONDS]
rosterd cancel|stop|suspend|resume|open KEY
rosterd name KEY LABEL | name KEY --clear
rosterd allow KEY [--always] | deny KEY [--reason TEXT]
rosterd spawn KEY --harness H --cwd DIR [--name L]
rosterd handoff KEY --to NODE
rosterd usage [--swarm] [--since 7d] | journal [--since T]
rosterd invite [--ttl MINUTES] | join ADDRESS --token T | revoke NODE_ID | leave
rosterd integrate install|uninstall claude|codex|pi | integrate status
rosterd ui | daemon [--config PATH] | setup [--yes] | doctor | version

KEY is an exact session key, a PID on this node, or a unique display name; an ambiguous name lists the candidates and never guesses. Exit codes: 0 ok, 1 user error, 2 daemon unreachable, 3 a peer was needed and is unreachable. The agent-facing summary is skill/SKILL.md; its one rule: never parse the human tables.

Config

# ~/.config/rosterd/rosterd.toml on Linux, ~/Library/Application Support/rosterd/ on macOS
[node]
name = "gibson"              # pinned; a renamed machine is a new name, the id stays
port = 8791                  # the swarm listener, on the Tailscale interface
listen = "tailscale"
loopback_port = 8790         # the page and local tools, bearer in loopback.token

[swarm]
static_peers = ["100.64.0.12:8791"]   # the fallback without Tailscale

[runner]
default_permission_policy = "attention"
idle_timeout_s = 1800
suspend_on_needs_attention = false
resume_on_crash = true
max_resumes_per_hour = 6
recap = true

[sources]
files = false
scan_interval_ms = 2000

[harness.claude]
adapter = "claude-agent-acp"
[harness.codex]
adapter = "codex-acp"
[harness.pi]
adapter = "pi-acp"
extension = true
gate_timeout_s = 3600

Keys and tokens live next to it with mode 0600: node.key, loopback.token, swarm.json. Adapters are pinned by version, pi's by commit, in a lock file the node writes on first install.

Hooks

rosterd setup or rosterd integrate install claude writes these; only marked entries are ours, everything else in the file is kept. The hook reads session_id, hook_event_name, cwd and notification_type, nothing else, posts the claim to the socket, fails open in 800 ms and always exits 0.

{ "hooks": {
  "SessionStart":      [{ "hooks": [{ "type": "command", "command": "rosterd-hook" }] }],
  "UserPromptSubmit":  [{ "hooks": [{ "type": "command", "command": "rosterd-hook" }] }],
  "PreToolUse":        [{ "matcher": "", "hooks": [{ "type": "command", "command": "rosterd-hook" }] }],
  "PermissionRequest": [{ "hooks": [{ "type": "command", "command": "rosterd-hook" }] }],
  "Notification":      [{ "hooks": [{ "type": "command", "command": "rosterd-hook" }] }],
  "Stop":              [{ "hooks": [{ "type": "command", "command": "rosterd-hook" }] }]
} }

Environment

variableread bymeaning
ROSTERD_SOCKETdaemon, CLI, hook, launcherthe daemon socket; $XDG_RUNTIME_DIR/rosterd.sock on Linux, $TMPDIR/rosterd.sock on macOS
ROSTERD_HARNESShookthe harness name when the process tree does not say
ROSTERD_CONFIG_DIR, ROSTERD_STATE_DIR, ROSTERD_CONFIGdaemon, CLI, scriptsoverride the directories and the config file
ROSTERD_GATEpi extensionattention pauses before each tool call until allowed or denied
ROSTERD_MACHINE, ROSTERD_TERMINAL_CMDopenerthis machine's name, how to open a terminal
RUST_LOGdaemonlog filter, info by default