skip to content
agentmash get started
docsconfiguration

run it yourself

configuration

.claude/agentmash/config.json is written by the CLI and committed:

json
{ "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:

VariableDefaultWhat it does
AGENTMASH_URLfrom config.jsonCoordination server
AGENTMASH_ROOMfrom config.jsonRoom id
AGENTMASH_DEVthe GitHub login this machine is signed in as, else git config user.emailIdentity override, kept beside a sign-in too, so two terminals can be two developers (the seat is still the login's)
AGENTMASH_STRICToff1 blocks conflicting edits instead of advising
AGENTMASH_HINTSon0 stops this developer's prompts leaving the machine
AGENTMASH_DIFFSon0 keeps this developer's changed text off the wire even in a room that streams diffs
AGENTMASH_WORKTREEon0 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_DISABLEoff1 makes the hooks no-ops and the MCP tools report themselves off — the kill switch
AGENTMASH_TOKENfrom ~/.agentmashDeveloper token override (rarely needed)
AGENTMASH_TIMEOUT_MS1500The 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_MS4000Per-request budget for the MCP tools
AGENTMASH_MCP_ENTRYunsetPath to a copy of the MCP server to run instead of the one committed beside the launcher
AGENTMASH_MCP_PACKAGEthe packed versionWhat the launcher fetches — a private registry, a fork, a tarball
AGENTMASH_MCP_NPXoff1 lets this machine fetch and run the package from npm at session start

Server-side:

VariableDefaultWhat it does
PORT8787Listen port
AGENTMASH_DBserver/agentmash.dbSQLite file
AGENTMASH_PUBLIC_URLhttp://localhost:$PORTOrigin used in printed links
AGENTMASH_WINDOW_MIN30Conflict lookback for /check
AGENTMASH_SCOPEfiledir also treats the same directory as a conflict
AGENTMASH_ACTIVITY_MIN60How far back the dashboard looks
AGENTMASH_ACTIVE_MIN2Silence, in minutes, after which a session stops reading as working
AGENTMASH_IDLE_MIN15Silence, in minutes, after which a session that never said goodbye reads as no signal
AGENTMASH_ROOMS_PER_HOUR20Room 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_ROOMS10000Rooms the server holds at most; the same rule for bad values
AGENTMASH_PUSH_ACCESS_DAYS7How 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_HOUR30How 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_CLOSEDoff1 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_MIN4320How long a rotated-away key keeps working — three days
AGENTMASH_RETENTION_DAYS7How long events are kept, unless a room sets its own
AGENTMASH_AUDIT_RETENTION_DAYS30How long the audit trail is kept
AGENTMASH_BILLINGoffPlans 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>/pricingWhere 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_DAYS7How 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_SEATSoff1 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_URLhttps://api.paddle.comhttps://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_URLhttps://api.polar.shhttps://sandbox-api.polar.sh for Polar's sandbox
AGENTMASH_LOGoff1 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_PORTunsetServes 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.

PlanDevelopersPrivate repositoriesHistory
free2 a month1 (public ones never count)7 days
teamper seat, no limitno limit90 days
enterpriseper seat, no limitno limitup 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_limit on POST /events for 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 through POST /events, and the public API's POST /api/v1/events is the same route, so all of them are held to it. POST /claims and POST /resolutions (and their /api/v1 paths) put a developer on the board too, and answer the same, with nothing claimed or recorded; on /api/v1 the refusal's error is its sentence. POST /rooms/tokens (the room's seats) and a login --room sign-in (the account's) answer the same for someone who would be the third, though joining takes no seat by itself.
  • 402 repo_limit on POST /api/connect, POST /api/connect/room and the pull-request installer's POST /api/install for 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_limit on POST /rooms/settings for retention_days past 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/paddle checks Paddle-Signature (ts=…;h1=…, HMAC-SHA256 of ts:raw body with the destination's secret, any of several h1 while a secret rotates) over the exact bytes received, and turns away a timestamp more than five minutes off. It reads subscription.* events; the account is data.custom_data.account, set at checkout beside data.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/polar checks Standard Webhooks signatures (webhook-id, webhook-timestamp, webhook-signature, either kind of secret Polar has issued) and reads subscription.* 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 is github-account: the account is data.metadata.account when it names one, else data.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 setting account in its metadata is how an account typed wrong at checkout is put right. The reference is data.metadata.checkout_ref, or data.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 from data.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 /events accepts symbols: string[] — the exported names an edit changed. A server that predates the field ignores it; a server that has it stores it.
  • GET /check accepts symbols=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 gains match_kind (symbol | file | dir), symbols (the overlap), and confidence; 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.

from README.md, “Configuration”