run it yourself
configuration
.claude/agentmash/config.json is written by the CLI and committed:
{ "url": "https://agentmash.dev", "room": "mash_…", "label": "team repo" }Environment variables override it, highest precedence first — real env vars, <repo>/.env,
<repo>/.claude/agentmash/.env, then the committed config:
| Variable | Default | What it does |
|---|---|---|
AGENTMASH_URL | from config.json | Coordination server |
AGENTMASH_ROOM | from config.json | Room id |
AGENTMASH_DEV | the GitHub login this machine is signed in as, else git config user.email | Identity override, kept beside a sign-in too, so two terminals can be two developers (the seat is still the login's) |
AGENTMASH_STRICT | off | 1 blocks conflicting edits instead of advising |
AGENTMASH_HINTS | on | 0 stops this developer's prompts leaving the machine |
AGENTMASH_DIFFS | on | 0 keeps this developer's changed text off the wire even in a room that streams diffs |
AGENTMASH_WORKTREE | on | 0 keeps the names of files the agent did not change itself on this machine: those already uncommitted when a session starts, and those changed between prompts. A file changed by hand while a command runs still goes out, as that command's. "share_worktree": false in config.json does the same for the whole repo |
AGENTMASH_DISABLE | off | 1 makes the hooks no-ops and the MCP tools report themselves off — the kill switch |
AGENTMASH_TOKEN | from ~/.agentmash | Developer token override (rarely needed) |
AGENTMASH_TIMEOUT_MS | 1500 | The advisory budget — the one network call an edit waits on. init writes timeout_ms for a distant server; see Where the 1500 ms goes |
AGENTMASH_MCP_TIMEOUT_MS | 4000 | Per-request budget for the MCP tools |
AGENTMASH_MCP_ENTRY | unset | Path to a copy of the MCP server to run instead of the one committed beside the launcher |
AGENTMASH_MCP_PACKAGE | the packed version | What the launcher fetches — a private registry, a fork, a tarball |
AGENTMASH_MCP_NPX | off | 1 lets this machine fetch and run the package from npm at session start |
Server-side:
| Variable | Default | What it does |
|---|---|---|
PORT | 8787 | Listen port |
AGENTMASH_DB | server/agentmash.db | SQLite file |
AGENTMASH_PUBLIC_URL | http://localhost:$PORT | Origin used in printed links |
AGENTMASH_WINDOW_MIN | 30 | Conflict lookback for /check |
AGENTMASH_SCOPE | file | dir also treats the same directory as a conflict |
AGENTMASH_ACTIVITY_MIN | 60 | How far back the dashboard looks |
AGENTMASH_ACTIVE_MIN | 2 | Silence, in minutes, after which a session stops reading as working |
AGENTMASH_IDLE_MIN | 15 | Silence, in minutes, after which a session that never said goodbye reads as no signal |
AGENTMASH_ROOMS_PER_HOUR | 20 | Room creation limit per caller (behind Fly, per Fly-Client-IP; an IPv6 caller by its /64). Anything but a positive whole number stops the server from starting |
AGENTMASH_MAX_ROOMS | 10000 | Rooms the server holds at most; the same rule for bad values |
AGENTMASH_PUSH_ACCESS_DAYS | 7 | How long GitHub's answer to "who can push to this repository" opens its room before that person has to sign in again |
AGENTMASH_GITHUB_CHECKS_PER_HOUR | 30 | How often an hour the server asks GitHub whether a repository a room is tied to is public (each one at most daily): as the OAuth app when GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET are set, unauthenticated otherwise. 0 asks nothing; anything else that is not a positive whole number stops the server from starting |
AGENTMASH_CLOSED | off | 1 closes the server to new teams: no new rooms, and only AGENTMASH_ALLOW can sign in. Rooms that already exist carry on |
AGENTMASH_ALLOW | — | GitHub logins, comma-separated, that can still sign in while closed |
AGENTMASH_ADMINS | — | GitHub logins, comma-separated, that can read /admin/stats (and /admin/stats.json) when signed in on the website: the last 30 days of page counts, utm tags and the funnel, whose paid teams are the accounts with an active team or enterprise subscription, once a provider's webhook secret is set. Unset, it is a 404 to everyone |
AGENTMASH_ROTATE_GRACE_MIN | 4320 | How long a rotated-away key keeps working — three days |
AGENTMASH_RETENTION_DAYS | 7 | How long events are kept, unless a room sets its own |
AGENTMASH_AUDIT_RETENTION_DAYS | 30 | How long the audit trail is kept |
AGENTMASH_BILLING | off | Plans and seats (below): off changes nothing; report counts seats and repositories, answers GET /api/billing and logs an account that is over its plan, and refuses nothing; enforce refuses what the plan does not cover. Anything but these three stops the server from starting |
AGENTMASH_BILLING_PRICES | — | Which plan each price buys: pri_01…=team,pri_02…=enterprise (a Polar product or price id the same way). A price a webhook carries that is not here is logged and ignored; a plan name that is not team or enterprise, or none at all with a webhook secret set, stops the server from starting |
AGENTMASH_BILLING_UPGRADE_URL | <public url>/pricing | Where every seat_limit, repo_limit and retention_limit refusal sends people to upgrade. Unset, the site's own /pricing, which leads to the checkout. Anything but an http(s) address stops the server from starting |
AGENTMASH_BILLING_GRACE_DAYS | 7 | How long a past_due subscription keeps its plan while the provider retries the payment |
AGENTMASH_BILLING_COMP | — | GitHub accounts on team without paying, comma-separated (design partners, say): acme,globex, each read as people type one (@acme, github.com/acme). GET /api/billing says "plan": "team", "source": "comp"; a subscription of team or higher outranks it. One that is not a GitHub account stops the server from starting |
AGENTMASH_BILLING_SYNC_SEATS | off | 1 sets the seat quantity of each paid subscription whose checkout carried its account's checkout_ref to the seats counted this period: in enforce only, once a day at most, only when the count differs, never below 1. A count above what is bought is set at once; one below it only in the period's last two days, so a renewal, which starts a period with nobody counted yet, never credits seats back only to charge them again |
AGENTMASH_PADDLE_WEBHOOK_SECRET | — | The Paddle notification destination's secret (pdl_ntfset_…). Set, it opens POST /api/billing/webhook/paddle, in every mode; unset, that route does not exist. A secret: fly secrets set, never fly.toml |
AGENTMASH_PADDLE_API_KEY | — | A Paddle API key that can write subscriptions, for the seat sync. A secret |
AGENTMASH_PADDLE_API_URL | https://api.paddle.com | https://sandbox-api.paddle.com for Paddle's sandbox |
AGENTMASH_POLAR_WEBHOOK_SECRET | — | The Polar webhook endpoint's secret (whsec_…, either kind Polar has issued). Set, it opens POST /api/billing/webhook/polar. A secret |
AGENTMASH_POLAR_API_KEY | — | A Polar organization access token with subscriptions:write, for the seat sync. A secret |
AGENTMASH_POLAR_API_URL | https://api.polar.sh | https://sandbox-api.polar.sh for Polar's sandbox |
AGENTMASH_LOG | off | 1 writes a line per request to stdout: the method, the route's pattern (/rooms/:id, never the address asked for), the status and how long it took, an id on a 5xx and on every /api/v1 request (the request_id its caller was given), and on a 5xx the error's kind. Never a query, a header, a body, a name or an address |
AGENTMASH_METRICS_PORT | unset | Serves Prometheus metrics at /metrics on this port, and only there (never on PORT); unset, there are none. Anything but a port, or PORT's own, stops the server from starting |
AGENTMASH_IDLE_MIN is the honesty dial: lower it and the board admits sooner that it
doesn't know; raise it for a team that thinks for a long time between edits. It is never
allowed below AGENTMASH_ACTIVE_MIN.
Symbol matching is always on and needs no setting: it is driven by what the hooks send, and it
adds holders rather than replacing path matching. AGENTMASH_SCOPE=dir is the one setting it
changes, by dropping same-directory matches that share no symbols.
Plans and seats
Off by default (AGENTMASH_BILLING=off), and then nothing here happens: no route below but a
provider's webhook (and, once its secret is set, GET /api/billing, for what a checkout
carries) exists, no request asks anything about a plan, and nothing is limited.
| Plan | Developers | Private repositories | History |
|---|---|---|---|
| free | 2 a month | 1 (public ones never count) | 7 days |
| team | per seat, no limit | no limit | 90 days |
| enterprise | per seat, no limit | no limit | up to 365 days |
The limits live in one table, PLANS in server/src/billing.ts. Who pays is the billing
account: the GitHub account, user or organization, that owns the repository a room is
connected to (acme for acme/app), or that the pull-request installer made it for. A room
made with npx agentmash init and connected to no repository is billed to the first GitHub
account that owns it, or, with no owner, is an account of its own (room:<ref>) that stays on
free.
A seat is a developer whose agent reported at least one event to any of the account's
rooms in the period: the subscription's current billing period on a paid plan, and on free
the calendar month (UTC), so the count starts again on the 1st. The developer is the GitHub
login a machine signed in beside the key (npx agentmash login) as someone who can push to the
room's repository, which is also the name the event is stored under; else whoever the event's
developer token was issued to, never the alias it claims (which its holder chose); and the
event's own developer only for an event sent with neither, trimmed and lowercased. A name the
room knows is another of a login's, because a machine signed in as that login sent it (or a
GitHub room token carries it as its alias), counts as that login: one person reporting under a
git email here and a GitHub login there is one seat, once one of their machines is signed in.
A name two logins' machines sent is neither's, a name every machine of its kind shares (root,
vscode, unknown and the like) is nobody's, and a name no signed-in machine has sent within the
room's retention is forgotten, as purge events forgets it at once.
Joining asks the same of the token's developer, never of its alias. A teammate the room knows
only from their commits (the git client) has no agent reporting and is never a seat: an event
in the exact shape the client reports a commit in (tool: "git-commit", a session_id of
commit-<sha>, an edit or session_end, no prompt_id) is never counted or refused. That
is the client's word and nothing more, since it sends the hooks' own token: the honour
system, with an agent's hooks that claimed it showing their work as commits, in no session of
their own. Team and enterprise never count agents or repositories.
Only a developer GitHub has vouched for takes one of the account's seats: one whose token came
from a GitHub sign-in (npx agentmash login for a repository connected from the website,
npx agentmash login --room for one that carries its room's key), or a machine signed in with
npx agentmash login whose hooks send the sign-in beside the committed key, counted under its
GitHub login when GitHub says it can push to the room's repository. Any other name is one that
whoever holds the room key could have typed, and a room's key is committed in every clone, so
for a public repository it is anyone's. Such a name takes a seat of that room's own, on the
account's plan (two a month in each room on free): a key that leaked can fill its own room's
seats, as it could always fill its own room's board, and never those of the account's other
rooms. These room seats are never billed (the seat sync counts only the account's), and
GET /api/billing counts them apart, as room_seats, only when asked of a room; a room that
wants every developer counted as the account's sets npx agentmash secure github.
With AGENTMASH_BILLING=report the server counts seats (one row per developer per period,
written by their first event, never a count over events), answers GET /api/billing, and
logs, once per period, an account that is over its plan. It refuses nothing. With enforce:
- 402
seat_limitonPOST /eventsfor a developer the plan has no seat for: on free, one not counted this period when two already are (the account's two, or the room's own two for a name sent with only its key). Nothing of the event is stored, nothing reaches/live, and the developers already counted keep working. Every coding agent's hooks report throughPOST /events, and the public API'sPOST /api/v1/eventsis the same route, so all of them are held to it.POST /claimsandPOST /resolutions(and their/api/v1paths) put a developer on the board too, and answer the same, with nothing claimed or recorded; on/api/v1the refusal'serroris its sentence.POST /rooms/tokens(the room's seats) and alogin --roomsign-in (the account's) answer the same for someone who would be the third, though joining takes no seat by itself. - 402
repo_limitonPOST /api/connect,POST /api/connect/roomand the pull-request installer'sPOST /api/installfor a private repository beyond what the plan covers, the ones installed counted with the ones connected. One already connected or installed is not a new one, and a public one never counts. Whether a repository is private is what GitHub last said of it: to the server asking it (AGENTMASH_GITHUB_CHECKS_PER_HOUR), or at the last sign-in of anyone who can push to it, whichever came later; one it has not described yet counts as private. - 400
retention_limitonPOST /rooms/settingsforretention_dayspast the plan's history, and the hourly sweep keeps min(the room's own setting or the server default, the plan's) days.
Both 402s are { "error": "seat_limit" | "repo_limit", "code": …, "message": "…", "upgrade_url": "…" }, the address AGENTMASH_BILLING_UPGRADE_URL sets (unset, /pricing).
A subscription canceled for the end of its period keeps its plan until then,
because both providers keep it active until then; once a provider says it is canceled, the
plan ends when it says the subscription ended (Paddle's canceled_at, Polar's ended_at),
never at the end of a period nobody paid for. A past_due one keeps its plan for
AGENTMASH_BILLING_GRACE_DAYS from when it went past due.
Subscriptions come from a merchant of record's webhooks, behind one small adapter per provider
(server/src/billing-providers.ts), Paddle first and Polar as the fallback. Each route exists
only while its secret is set, in every mode, so a subscription bought before enforcement is on
record when it starts:
POST /api/billing/webhook/paddlechecksPaddle-Signature(ts=…;h1=…, HMAC-SHA256 ofts:raw bodywith the destination's secret, any of severalh1while a secret rotates) over the exact bytes received, and turns away a timestamp more than five minutes off. It readssubscription.*events; the account isdata.custom_data.account, set at checkout besidedata.custom_data.checkout_ref, the plan comes from the items' price ids (AGENTMASH_BILLING_PRICES), and the seats from their quantity.POST /api/billing/webhook/polarchecks Standard Webhooks signatures (webhook-id,webhook-timestamp,webhook-signature, either kind of secret Polar has issued) and readssubscription.*events. The team plan is one checkout link offering a seat-based product billed monthly and one billed yearly, with a required custom field whose slug isgithub-account: the account isdata.metadata.accountwhen it names one, elsedata.custom_field_data["github-account"], read as people type it (acme,@Acme,github.com/acme,https://github.com/orgs/acme,acme/app). Polar lets a subscription's metadata be changed, never its field, so settingaccountin its metadata is how an account typed wrong at checkout is put right. The reference isdata.metadata.checkout_ref, ordata.metadata.reference_id, which a checkout link puts there from its own query (?custom_field_data.github-account=acme&reference_id=chk_…). The plan comes from the product's id or any of its prices' ids (AGENTMASH_BILLING_PRICES, e.g.<monthly product id>=team,<yearly product id>=team), and the seats fromdata.seats.
Whoever buys fills the checkout in, so the account it names is anyone's to name. A plan paid
for is that account's all the same, but only a subscription that carries the checkout_ref
this server gave that account (GET /api/billing, below) has its seats set to the account's
count (AGENTMASH_BILLING_SYNC_SEATS): one without it is billed for the seats its buyer chose,
and logged as such, so nobody pays for, or reads off their own invoices, the developers of an
account they named by mistake or on purpose. An account that is not a GitHub login is ignored,
and logged. Each event is applied once (by its
event_id, or webhook-id), and one older than what is stored for its subscription is
ignored, because neither provider delivers in order. Every answer to a verified webhook is
200, so nothing retries an event this server has no use for; a bad or stale signature is 401.
A subscription it gives no plan to (an account it cannot read, or no price
AGENTMASH_BILLING_PRICES names) was paid for all the same, and its account is on free until
one is applied, so it is kept, and the preview below lists it as paid but not applied until an
update for it applies. That event is never marked as applied, so once the setting or the
account at the provider is fixed, its next event, or the same one redelivered, applies it.
GET /api/billing?account=<login>, for a signed-in session whose login is the account, or
who owns one of its rooms through one of its repositories they can still push to (403
otherwise; a room claimed with its key does not count, and it exists from report on, or once
a provider's secret is set), says the plan and where it comes from (source: subscription,
comp for an account AGENTMASH_BILLING_COMP names, null on free), the subscription's
status and provider, the period
and when it ends, the seats used (and who took them, to the account itself only: developers
is null for anyone else, since the names come from all its rooms), the limits, the private
repositories counted, and what a checkout has to carry:
{ "provider": "paddle", "custom_data": { "account": "acme", "checkout_ref": "chk_…" } }, the
reference made for the account the first time it is asked for and the same ever after. Never
a secret, and never a provider's id for anything.
GET /api/billing?room=<ref> asks the same of the account a room bills to, for the dashboard
(404 unless the session owns the room or may read that account's plan, so nobody else learns
whose it is), and adds the room's own seats, room_seats: { used, limit }: the names sent
with only its key, counted as the 402 counts them (limit is null on a plan with no seat
limit), how many and never who. The dashboard says the plan is full when either is.
GET /go/checkout?from=dashboard&account=<login> is how the dashboard's upgrade button
reaches Polar: it counts the click and sends it to the team plan's checkout link with the
github-account field filled in and, for a signed-in session that may read the account's
plan, its checkout_ref as reference_id, so the subscription it buys is one the seat sync
may set. Anything else in the query is dropped.
The hooks treat a 402 like any other refusal: nothing printed, one line in the report log on
the machine (npx agentmash doctor says what it means and where to upgrade), and no retry. The
first one a session gets is said once, at its next prompt, in the server's own words.
Before turning enforce on, npx tsx src/billing-cli.ts --preview (from server/, or in the
image with /app/node_modules/.bin/tsx /app/src/billing-cli.ts --preview; --json for the
same as data) says first which subscriptions were paid for and given no plan, then, for every
billing account, who was counted this period and who reported
(read from the events, claims and resolutions still kept, for when billing was off and
nothing was counted), its private repositories, its plan (comped, or which subscription, and
whether its checkout carried the reference), and what enforce would do: who would be
refused a seat, whether another private repository would be, which rooms' history the sweep
would cut, and what a subscription is billed for against who was counted. It opens the
database read-only and never through Store, and decides plans with the server's own code,
in the server's own environment.
Run it with the environment the deploy will have (AGENTMASH_BILLING_COMP as the new
fly.toml sets it, say), not the one running now. The sweep runs once as the server starts,
so a room on its history list loses everything past its plan's days the moment enforce
boots, and nothing brings that back. Comp the room's account for a while, or tell its owner
first: the terms promise notice by email of a change that matters, and a history cut is one.
API
Every request identifies its room with the x-agentmash-room header. ?room= is still
accepted, because a browser cannot set a header on a WebSocket — but it puts the key in a
URL, so nothing but the dashboard should use it.
POST /rooms · GET /rooms/self · POST /events · GET /check · GET /activity ·
GET /events · WS /live · GET /healthz (open) · GET /readyz (open: 200 {ok:true}, or
503 {ok:false, failing:"db"} when the database does not answer) · GET /r/:room (dashboard).
For a tool of your own, the same routes are under /api/v1, with a bearer token (a room key,
a machine's sign-in or a personal API token), the advice an agent should read before an edit,
server-sent events beside the WebSocket and one shape for every error: that is the public API,
in docs/API.md, and GET /api/v1/openapi.json describes it.
GET /rooms/:id is the same answer as GET /rooms/self with the key in the path instead of
a header. It is legacy: nothing we ship calls it any more, because a key in a path is a
key in every access log between the client and here. It stays routed so clients written
before /rooms/self keep working, and a newer CLI falls back to it against an older server.
Room administration, all authorized by the room's current key — never one that has been
rotated away from, which gets 403 room_key_rotated — all with the key in a header:
POST /rooms/rotate · POST /rooms/revoke · GET /rooms/keys · POST /rooms/settings ·
POST /rooms/tokens · GET /rooms/tokens · POST /rooms/tokens/revoke · GET /audit ·
POST /rooms/purge · POST /rooms/disconnect · DELETE /rooms. Developer sign-in:
POST /auth/cli/start · POST /auth/cli/token, and in the browser, after GitHub,
POST /auth/cli/confirm with the code the terminal shows: nothing is issued before it.
A live view (WS /live) lasts only as long as what opened it: a key revoked or rotated past
its grace, a repository disconnected, a machine or browser signed out, or push access gone
stale closes it with code 4001.
A room connected to a repository opens to everyone who can push to it, whatever its key, so
connecting one that already exists takes its key, never its ref: a ref is in every /r/ link.
POST /api/connect (a signed-in session) connects a repository that carries a room — the key
init committed in .claude/agentmash/config.json on its default branch, read with the
signer's own GitHub token, or one sent in x-agentmash-room — to that room; a key this server
cannot open makes a room as before, answering committed_room_unusable: true. A fork is never
connected to the room a key names, its config.json's (never read) or the header's, since forking
copied its original's key: a fork gets a room of its own.
POST /api/connect/room does the same from a machine (npx agentmash connect): its sign-in in
x-agentmash-user, the room's current key in x-agentmash-room, and
{ "repo": "owner/name", "replace": false }. What the machine can push to must be fresh for it,
or the answer is 401 reauth, and a repository GitHub listed as a fork then is 403 fork
(GET /api/lookup says fork: true of one that is not connected). Either way, a room the
repository was connected to that nothing was ever reported or claimed in gives way, disconnected
and never deleted; one with history, however long ago, is 409 (two_rooms from the website,
repo_taken from a machine, both naming committed_ref and connected_ref) unless replace is
true, and a room already connected to another repository is 409 room_taken. GET /rooms/self
says which repository a room is connected to, POST /rooms/rotate says it too, since a new key
does not change it, and POST /rooms/disconnect does.
The pages and the web installer, served when the server has web/ (and, for the crew's,
crew/): GET / · GET /install · GET /rooms (both from the landing build, or from
web/install.html and web/rooms.html when it is not built) · GET /agents ·
GET /agents/:short (one of the 22 names in crew/catalog.json, checked before anything is
read from disk; any other name is the ordinary 404) · GET /teams/new · GET /docs ·
GET /docs/:slug (a slug the build's docs/manifest.json lists, checked the same way) ·
GET /how · GET /security · GET /faq · GET /vs/git-worktrees ·
GET /claude-code-teams · GET /cursor · GET /glossary (these the landing build's too). None
of them is cached; the assets they load are, under /landing/assets. Sign-in and the installer: GET /auth/github
(?next=teams comes back to /teams/new, the only place it can come back to besides the
installer's own) · GET /api/config · GET /api/me · GET /api/repos · POST /api/install
(AgentMash, which creates a room) · POST /api/teams/install (a crew team, which does not).
POST /api/teams/install takes { "repo": "owner/name", "default_branch": "main", "name": "agentmash crew", "members": ["pipe", "flex"], "slug": "optional" } from a signed-in session,
from the site's own page, as JSON, at most ten times an hour per login. It reads the
repository's .claude/ at the head of the default branch and opens a pull request on
agentmash-team-<slug>-<sha7> built on that commit, answering
{ changed: true, pr_url, branch, files, team, warnings }; { changed: false, team } when
the repository already has the team as asked. A refusal is { error, code }, the error in
the planner's own words: 400 for the request (bad_request, bad_name, bad_slug,
unknown_agent, no_agents), 409 for the repository (foreign_files, unsafe_path,
bad_manifest, too_large, cannot_install), 429 rate_limited, 502 github when GitHub
fails.
Two fields carry semantic detection, both optional in both directions, so hooks and servers can be upgraded in either order:
POST /eventsacceptssymbols: string[]— the exported names an edit changed. A server that predates the field ignores it; a server that has it stores it.GET /checkacceptssymbols=a,b,c— the names the caller's file imports or exports. Omit it and the response is byte-identical to v0's. Each holder gainsmatch_kind(symbol|file|dir),symbols(the overlap), andconfidence; older hooks ignore fields they don't know.
POST /events accepts agent_type, the crew agent that made an edit ("pipe-backend"). It
is sanitised, never validated, so no value of it costs an event a 400: the server keeps it only on
an edit and only when it is one of the names in crew/catalog.json, and stores anything else,
of any type or length, as null. A server started without crew/ has no roster to check it
against and keeps any string shaped like a catalog name (lowercase words joined by single
hyphens, at most 64 characters); the hooks never send anything but a crew name either way.
Events carry it back on /activity, /events and /live, and each /activity session
carries the agent_type of its newest edit (with two crew agents at once in one session,
whichever edited last).
/check holders and /activity sessions both carry presence (active · idle ·
stale · ended) and a presence_label written to read after a developer's name.
/activity also returns presence_thresholds, so the dashboard ages cards between polls
using the server's own definition of working.
GET /events?minutes=&limit=&cursor= is the raw read behind the retrospective views and the
digest: newest first, paged with a keyset next_cursor of "<ts>.<id>" that is absent on
the last page. /activity aggregates a live slice and stops at a few hundred rows, which is
right for now and silently wrong for this week — a digest built from a silently truncated
week is worse than none — hence a separate endpoint rather than a bigger cap. Same room
credential, same rows the dashboard already renders.
The MCP server reads GET /activity and nothing else; it adds no route and no credential.
PROTOCOL.md is the contract: the event shape field by field, how a room
is identified, what the server does and does not promise about ordering and deduplication,
what a client must do on failure, and the versioning rule that keeps a two-year-old
vendored hook working. Read it before writing a client; POST /events is frozen as
protocol 1 and will never change incompatibly.
Tests: npm test — server routes, rotation and revocation, forged identities with and
without tokens, deletion and purging, the presence state machine and its threshold
boundaries, both hook scripts against a mock server that is up, down, and slow, every message
shape and severity level against partial and missing data, the presence-by-working-tree
severity table in full (every level against every presence state, including a server that
sends none), the strict-mode acknowledge path
end to end, the CLI's install/join/doctor/uninstall behavior, the digest's aggregation,
per-target escaping, empty and thin rooms and failure modes against a mock coordination
server and a mock webhook, the published package — what the tarball contains, that its bin
resolves, and that a stranger's install writes files byte-identical to the web
installer's — the MCP tools driven through a real MCP client against a mock server that is
up, down, slow, hostile, and absent, and the dashboard's presence, reconnect, degraded and
empty states against a fake socket on a mocked clock, plus the timeline's block grouping, its
day boundaries, the DOM it renders opened and closed, and the hot-zone collision count over
empty, single-event and 20,000-event windows — including the pair of cases the metric exists
to tell apart, two agents alternating thirty seconds apart and the same two relaying across a
week — the git and Cursor clients, the git one against real throwaway repositories, making
real commits — and both directions of version skew: old clients against a new server, new
clients against an old one.
Three are worth naming: one runs the CLI and the web installer against the same throwaway
repo and diffs every file they write, so the two installers cannot drift; another connects a
real MCP client to the launcher on a machine with no dependency and no npm, and asserts it
answers with the real tools. A third runs a fresh init into a repo whose path has a space
and no node_modules anywhere above it, spawns the command out of .mcp.json with no shell,
expanded the way Claude Code expands it, from the root and from a subfolder, and asks it who is
working, so a registration that cannot start fails the check instead of passing it on a
resolved path no session uses.