run it yourself
the public api
npx agentmash login connects the coding agents it knows, and there is always one it does
not: the one your team built itself, a bot, a CI job, an editor plugin. This page is for that
one. There are two ways in, and they reach the same room.
- Your tool has hooks (a command it runs before and after a tool call, with JSON on
stdin): register the generic hook,
agent_hook.mjs, and it answers in the format your tool reads. It reports the edits, asks before one and words the advice exactly as the Claude Code hooks do. See your own tool's hooks below. - Your tool talks HTTP: call
/api/v1, described here, with a room key or a personal API token. The machine-readable version isGET /api/v1/openapi.json(OpenAPI 3.1).
The wire contract under both is PROTOCOL.md. /api/v1 is protocol 1 under
a versioned path, plus what a client of its own needs: a bearer token, the advice an agent
should be shown, a stream it can read without a WebSocket, and errors in one shape.
Base URL and versioning
https://agentmash.dev/api/v1, or /api/v1 on your own server. Every route of protocol 1 is
there under the same name (POST /api/v1/events is POST /events, answered by the same code),
and the unversioned routes stay where they are, unchanged, for the hooks already committed to
repositories: they are never removed.
Within v1, changes are additive only: new optional fields, query parameters, endpoints, frame
types and error codes. Ignore a field, a frame type or a code you do not know, and fall back on
the HTTP status. A change that would break a client goes to /api/v2, which runs beside v1 for
at least twelve months; a v1 route on its way out sends Deprecation and Sunset headers at
least six months before it goes, and this page says so.
Authentication
Send one credential, as Authorization: Bearer <token>. The older headers work too, and mean
the same:
| Token | What it is | Opens | The same as |
|---|---|---|---|
mash_… | the room key, from .claude/agentmash/config.json | its own room | x-agentmash-room: mash_… |
amu_… | a machine's sign-in, from npx agentmash login | every connected repository its login can push to | x-agentmash-user: amu_… |
amp_… | a personal API token, from agentmash.dev/keys or npx agentmash token create | the same, held to its scopes and repositories | (v1 only) |
- A room key names its own room. With an
amu_oramp_token, name the room by its ref:x-agentmash-room: rm_…, or?room=rm_…, which is safe because a ref is not a secret.GET /api/v1/lookup?repo=owner/namegives the ref for a repository. A room-scoped route with no room named answers 400room_required. - A developer token (
amt_…, from a room whosetoken_modeis on) still goes inx-agentmash-tokenbeside the key; sent as a Bearer it answers 400invalid_credentials. Two credentials that disagree answer 400ambiguous_credentials; anything else as a Bearer answers 401invalid_token, with aWWW-Authenticateheader. - A room key in a repository that is public on GitHub, whose room is connected to it, is
anyone's who reads it, so it opens the room only beside the sign-in of someone who can push
there: send
x-agentmash-user: amu_…with it. Alone, or beside anyone else's sign-in, it answers 403room_key_public, with the repository asrepo. - A room key in a query string (
?room=mash_…) answers 400key_in_urleverywhere but/api/v1/liveand/api/v1/stream, because a browser cannot set a header on a WebSocket or anEventSource. Anywhere else it would land in every access log between you and us. /api/v1reads no cookie, so a page open in someone's browser cannot call it as them.
Which one to use. A room key is a shared secret with no expiry, for one room: right for CI
and bots. A personal token is a person's, for their own tools: it opens what its login can push
to, and like every sign-in it goes quiet when that person has not signed in for seven days
(AGENTMASH_PUSH_ACCESS_DAYS), answering 401 reauth until they run npx agentmash login or
sign in on the website again. A room connected to a repository that is public on GitHub has no
credential that lasts: its keys open it only beside such a sign-in (room_key_public, above),
which goes quiet the same way, so CI there has to sign in again at least once a week.
Personal API tokens
The quickest way is agentmash.dev/keys: sign in with GitHub, make a
key, copy it, and the page shows the first calls to paste, with your room's ref in them. A
signed-in machine makes them too; nothing else can, a room key and another token included (403
machine_token_required).
npx agentmash token create --name ci-bot --read-only --repo acme/app --expires 30d
npx agentmash token list
npx agentmash token revoke ci-botcreate prints the token alone on stdout, so TOKEN=$(npx agentmash token create --name ci)
keeps it, and says the rest on stderr. The token is shown that once and kept only as a hash.
- Scopes: read and write by default;
--read-onlygives a token that answers 403insufficient_scopeto anything but a GET. - Repositories: every one its login can push to by default;
--repo owner/name, once or more, limits it, and a room of another repository answers 403token_repo_forbidden. - Expiry: 90 days by default, 1 to 365 (
--expires 30d), or--expires never, only when asked for by name. - Limits: 25 live tokens a person, 10 made an hour.
- Where they work:
/api/v1only. Anamp_token on an unversioned route answers 401api_token_v1_only, so its scopes, repositories and rate limits are checked in one place. - Revoking one ends its open live views and streams at once.
Over HTTP, with the machine's sign-in as the Bearer: POST /api/v1/tokens with { "name", "scopes", "repos", "expires_in_days" }, GET /api/v1/tokens and DELETE /api/v1/tokens/{id}.
Endpoints
| Method and path | What it does |
|---|---|
POST /api/v1/events | Report an edit, a prompt or the end of a session (protocol 1's body) |
GET /api/v1/events | The room's events, newest first, a page at a time (next_cursor) |
GET /api/v1/check?file_path=… | Ask before editing a file: who else is in it, and the advice to show the agent |
GET /api/v1/claims | Live claims, or those on the given paths or symbols |
POST /api/v1/claims | Claim files for a task, for a bounded time; nothing is locked |
POST /api/v1/claims/release | Release one claim, or every claim of a session |
GET /api/v1/collisions?advisory=1 | Files the caller changed that a teammate changed too |
POST /api/v1/resolutions | Say how a collision was settled |
GET /api/v1/activity | The room right now: sessions, presence, claims, hot files, recent events |
GET /api/v1/team | Teammates at work now, and what each is on |
GET /api/v1/live | The room's frames, live, over a WebSocket |
GET /api/v1/stream | The same frames as server-sent events |
GET /api/v1/lookup?repo=owner/name | Which room a repository reports to, for a sign-in or a token |
GET /api/v1/room | The room, for any credential that opens it |
GET /api/v1/me | Who this credential is |
POST /api/v1/tokens, GET /api/v1/tokens, DELETE /api/v1/tokens/{id} | Personal API tokens |
GET /api/v1/openapi.json | This API as an OpenAPI 3.1 document, open to anyone |
Room creation, the admin routes (rotation, settings, purge, deletion) and the sign-in flows are not in v1: they stay on the routes the CLI calls, in the README's API section.
Asking before an edit
GET /api/v1/check is /check with one more field, advice: the exact text an agent should
read before it edits the file, worded by the same code the hooks use, so your tool says what
Claude Code is told, and says it differently only when the hooks do.
curl -s "https://agentmash.dev/api/v1/check?file_path=src/models/user.ts&developer=bob&branch=main" \
-H "Authorization: Bearer $AGENTMASH_ROOM"{
"conflict": true,
"holders": [{ "developer": "alice", "file_path": "src/models/user.ts", "minutes_ago": 2, "branch": "main", "task_hint": "add soft deletes", "change_summary": "+12/-3 lines, touching deletedAt" }],
"claimed": false,
"claims": [],
"advice": {
"level": "active",
"severity": 4,
"developer": "alice",
"file": "src/models/user.ts",
"text": "[AgentMash: ACTIVE COLLISION] alice's agent is working in src/models/user.ts — last write 2 min ago on main, as you are, while working on 'add soft deletes' (+12/-3 lines, touching deletedAt). …",
"parts": [{ "level": "active", "developer": "alice", "text": "…" }],
"strict_block": true
}
}Show advice.text as it is. level says how loud it is, from nearby to active, and
claimed for a claim alone; severity is the same as a number, -1 to 4. strict_block is
whether a client in strict mode stops the edit: a claim alone never does. advice is null when
there is nothing to say, and null with advice_unavailable: true on the rare answer the server
could not word. branch and checkout_id make the wording right for a teammate on another
branch, or in another clone of yours; hints=0 leaves teammates' task descriptions out.
Reporting an edit
POST /api/v1/events takes protocol 1's event, one per file, with the path relative to the
repository root and tool naming your client:
{
"developer": "bob@example.com",
"session_id": "your-tool-session-42",
"event_type": "edit",
"tool": "mytool:Edit",
"file_path": "src/models/user.ts",
"git_branch": "main",
"task_hint": "add a role to users",
"change_summary": "+2/-1 lines, touching role"
}What each field means is PROTOCOL.md, and what a client must do when the server is slow or down is §6: a hard timeout, no retries, and on any failure, nothing at all.
Live: a WebSocket, or server-sent events
GET /api/v1/live upgrades to a WebSocket; GET /api/v1/stream sends the same frames as
text/event-stream, for a client that would rather read lines. Each frame is a JSON object
with a type: hello, event (with the changed text when the room streams diffs), claim,
claim_released, claims_released and resolution; ignore a type you do not know.
On the stream, each frame is event: <type> and data: <the same JSON>, with id: on event
frames. It opens with retry: 5000, sends a : keepalive comment every 25 seconds, and ends
with an event: close frame carrying the code: 4001 when the credential no longer opens the
room (stop reconnecting), 4003 when the room was deleted, 1001 when the server is restarting.
A credential holds five streams at once and a server a thousand; past that, 429
stream_limit. There is no replay from Last-Event-ID yet: a client that reconnects reads
GET /api/v1/events for what it missed.
Errors
Every error on /api/v1 has one shape, and every answer an x-request-id header:
{ "error": "a sentence for a person", "code": "room_required", "request_id": "6f0c…" }code is stable and snake_case; some errors carry more fields (a claim's outcome, a fork's
fork). Quote request_id when you write to us: it is the one thing in our log that finds
your request, which names a route and never its address, a header or a body.
| Status | Codes |
|---|---|
| 400 | room_required, key_in_url, ambiguous_credentials, invalid_credentials, file_path_not_relative, invalid_request |
| 401 | room_unauthorized, reauth, signed_out, invalid_token, token_required, api_token_v1_only |
| 403 | insufficient_scope, token_repo_forbidden, machine_token_required, token_unverified, closed, room_key_public, forbidden |
| 404 | not_connected, not_found |
| 409 | token_limit |
| 410 | room_deleted |
| 413, 415 | the body is too large; send JSON (json_only) |
| 429 | rate_limited, stream_limit, with Retry-After |
Rate limits
Counted per credential on /api/v1, in a fixed one-minute window: 1200 calls a minute to
/api/v1/check, 600 to every other read and 600 writes, which is room for a fifty-file commit
reported at once. Each answer carries RateLimit-Limit, RateLimit-Remaining and
RateLimit-Reset, and a 429 a Retry-After. The unversioned routes the hooks call are not
limited.
CORS
Any origin may call /api/v1 (Access-Control-Allow-Origin: *), which is safe because it
reads no cookie: a page gets in only with a token it holds. The allowed headers are
content-type, authorization, x-agentmash-room, x-agentmash-token, x-agentmash-user
and idempotency-key; the exposed ones x-request-id, retry-after, ratelimit-limit,
ratelimit-remaining, ratelimit-reset and idempotent-replayed; a preflight is cached for
600 seconds. An Idempotency-Key is allowed through but not acted on yet: a write sent twice
is recorded twice, as protocol 1 says.
Your own tool's hooks: the generic hook
A tool that runs a command before and after its tool calls needs no client of its own:
npx agentmash login puts agent_hook.mjs in ~/.agentmash/hooks, and that one script is
what every connector since the first four runs. It reads what your tool sends, asks the room or
reports to it through the same scripts Claude Code runs, and answers in your tool's format.
The advice, strict mode, claims, symbols and identity are those scripts', so they never drift.
npx agentmash hook --print-command prints the line to register, one per event:
npx agentmash hook --print-command --client mytool --event pre_edit --dialect json
# node "/home/you/.agentmash/hooks/agent_hook.mjs" pre_edit --global --client mytool --dialect jsonRegister that node line, never npx: npx looks the package up on every call, which takes
seconds when it is cold, and the hook before an edit has 1.5 seconds to answer. The line is the
same string every time for the same arguments, with no version in it, so an agent that asks
the person to approve a hook (Codex does) asks once.
| Event | When your tool runs it | What it does |
|---|---|---|
pre_edit | before a tool that changes files | asks the room, and answers with the advice, if any |
post_edit | after one | reports each file, in the background, and answers at once |
prompt | when the person sends a prompt | reports it, and answers with a heads-up when a teammate is near |
session_start | when a session starts | answers with that heads-up |
stop | when the agent finishes a turn | answers with a word for the person when a teammate changed the same files |
session_end | when the session ends | reports it |
--client names your tool, a short lowercase name: the room shows its edits as
mytool:Edit and mytool:Write. --global finds the room for the checkout by this machine's
sign-in, as every connector's hooks do. To try a configuration by hand, npx agentmash hook pre_edit --client mytool --dialect json < payload.json runs the same hook once.
What it reads
With --input native, a dialect reads your tool's own JSON (the connectors' dialects each read
one agent's). Otherwise, send this, and it is read whatever your tool is:
{
"agentmash": 1,
"session_id": "your-tool-session-42",
"cwd": "/home/you/src/app",
"files": [{ "path": "src/models/user.ts", "before": "id: string", "after": "id: string; role: string" }],
"tool": "replace_in_file"
}session_idis your tool's own stable id for the session, at most 200 characters;cwdis where it works, and the repository is found from there.files, forpre_editandpost_edit, are the files the call changes, absolute or relative tocwd.beforeandafterare the text replaced and its replacement;afteralone is a whole new file;edits: [{ "before", "after" }]is several edits of one file. A plain string is a path alone. A file outside the repository is dropped before anything is sent.patch, in place offiles, is anapply_patch-format patch;command, after a shell command, has the working tree say what it changed.promptis what the person typed, forprompt.agent_idandagent_typename a subagent;end_reasongoes withsession_end, andstop_hook_activewithstop.
Who the developer is never comes from stdin: it is the GitHub login the machine signed in with, else their git email, as for every hook.
What it answers
--dialect picks the answer's format. Left out, it is the dialect named like the client when
there is one, else claude.
| Dialect | For | Before an edit |
|---|---|---|
claude | tools that read Claude Code's hook output | hookSpecificOutput.additionalContext, the advice beside the edit |
deny-once | tools whose hook before a tool can only let it run or refuse it, with a reason the model reads | the first try refused, with the advice as the reason; the same edit again goes through |
plain | tools that put a hook's stdout in front of the model | the advice as text |
json | anything else, and your own code | the result, below |
{ "agentmash": 1, "event": "pre_edit", "action": "advise", "level": "active", "text": "[AgentMash: …] …", "files": ["/home/you/src/app/src/models/user.ts"], "strict": false }action is allow, advise or deny, and deny only in a room's strict mode. A dialect is
one file in ~/.agentmash/hooks/dialects/, export default { name, parse, answer, neutral, tools }; the ones the connectors use are there too, and the comment at the top of
hooks/agent_hook.mjs is the whole contract.
It fails open
Anything going wrong (an event or dialect it does not know, no --client, stdin that is not
JSON, a room that is down or slow, AGENTMASH_DISABLE=1) is the dialect's neutral answer and
exit 0, in time: the call goes on as it would have without the hook. Nothing it says ever
approves something the person would otherwise be asked about.