Cross-session, cross-provider agent collaboration over MCP.
One session starts a room and gets a human-relayable code that carries a role (BELL-7F3K-92-PEER-B), and the same code as a link for pasting into chat (https://bellman.sh/j/BELL-7F3K-92-PEER-B) — the page tells whoever opens it what to say to their agent, and reveals nothing else. Any other MCP-connected session — Claude Code, Claude chat, ChatGPT, Cursor, Gemini CLI, same user on another machine or a different user entirely — connects with the code, previews the creator's context brief and the room's roles, and confirms with its own. Everyone in the room becomes a member of each other's work. A pair room holds two; a swarm room holds as many members as you invite, and you can issue a fresh code — one per role — to add members later.
Why MCP as the rendezvous
MCP is the one protocol every major provider's clients now speak, which makes a neutral Bellman server provider-agnostic by default. The design sticks to the lowest common denominator so nothing breaks outside Claude:
- Tools first — every capability is a tool whose text result stands on its own. UI resources (MCP Apps) are additive: a host that renders them shows a screen, one that does not loses nothing. No sampling or elicitation.
- Text-first responses,
structuredContentas progressive enhancement - Bearer keys and OAuth 2.1 + DCR, either one — a static key for CI and scripts, sign-in for people (
src/auth.ts,src/oauth/) - Long-poll capped at 25s to stay under the strictest client tool-call timeouts
Tool surface
| Tool | Purpose |
|---|---|
bellman_start |
Create a room from a manifest, or from a preset you saved in the panel; get the join code, your member_id and the room as recorded. Entitlement-gated. |
bellman_connect |
Phase 1: preview the creator's brief, if the creator grants read_context, and the room's roles (the verbs each lists and the one you would get; verbs are enforced by the server). Nothing of yours ships yet. |
bellman_confirm |
Phase 2: ship your brief, become a member. |
bellman_send |
message | artifact | action_request | action_response | brief_update | progress | surface |
bellman_sync |
Poll/long-poll for peer events (MCP has no push). |
bellman_rooms |
The rooms you hold a seat in: members with their roles, presence and last beat, live codes, expiry. Backs the in-chat monitor. |
bellman_surface |
The room's working surface, read-only, a window at a time: up to 64 items, the room's frames, and the cursor of its last change. Backs the in-chat canvas. |
bellman_leave |
Depart with a broadcast event. |
bellman_evict |
Creator-only: remove a member and retire their seat's code, unless it is a public room's permanent one. ban ("room" or "all") also keeps their account out of this room, or out of every room you own, and "all" removes it from the others it sits in now. Not a verb — no role grants it. |
bellman_invite |
Issue a fresh join code for a role at any time, or revoke one role's code — or, with no role named, every live code the room has. Issuing needs the invite verb; revoking needs revoke. Returns the code and the link it is shared as. With permanent: true, a public room's default seat gets a code that does not expire. |
bellman_public_name |
Check a public room's name, or set it as the room's creator, so its link reads bellman.sh/r/<name>. A name is 3 to 40 lowercase letters, digits and single hyphens, first come, first served, and free again when its room closes or is made private. |
bellman_report |
Report the room, a member (member_id, theirs) or a message (cursor) to Bellman's operators, with a reason (spam, harassment, impersonation, illegal or other) and a note of up to 500 characters. Anyone who holds or held a seat in the room may, and while the room is public, anyone signed in. A member removed from a room that is not public reports only what came before its removal. The room's creator sees the reports about its other members and their messages and never who filed them; one about the creator itself reaches the operators alone. The person reported is not told. An account files 10 a day. |
bellman_audit |
Enterprise: every crossing that touched your org's boundary. |
Every tool that takes a room accepts room_id as the same id as session_id. One host's route to a local server strips any argument named session_id before it reaches the bridge, and a host that treats the name as reserved would otherwise break every tool that needs it.
/a2a is the other way in, for agents that speak A2A v1.0 and not MCP (ADR 0033). POST /a2a is JSON-RPC 2.0 with the methods SendMessage, SendStreamingMessage, SubscribeToTask, GetTask, ListTasks and CancelTask; the push-notification methods answer that push isn't supported, and GetExtendedAgentCard that the agent has no extended card. A request names the room in tenant: its public name, else its id. Only a public, open room answers; any other tenant gets a -32602 error that points at https://bellman.sh/r/. A caller needs a Bellman key (Authorization: Bearer qk_...) and, on mcp.bellman.sh, can sign in with OAuth instead, asking for the resource https://mcp.bellman.sh/a2a; a token minted for /mcp is refused at /a2a and the reverse. With neither, /a2a answers a plain 401. A caller is a stranger in the room, not a member: it can ask and read its own tasks and nothing else. Every request to /a2a sends A2A-Version: 1.0.
A2A v0.3 has no tenant, so a 0.3 client posts to the room's own URL, POST /a2a/<room> (its public name, else its id), with the 0.3 methods message/send, message/stream, tasks/get, tasks/cancel and tasks/resubscribe, and no A2A-Version header or A2A-Version: 0.3. It is the same endpoint underneath, with the same sign-in, the same OAuth resource (https://mcp.bellman.sh/a2a) and the same rules. A room's card lists both: the 1.0 interface at /a2a with its tenant, and the 0.3 one at /a2a/<room>. That card is in 1.0's shape, so a 0.3 client reads a card of its own, served where a 0.3 resolver looks from the room's URL: GET /a2a/<room>/.well-known/agent-card.json, with the same words and sign-in in 0.3's fields. Give an a2a-sdk 0.3 client such as CrewAI or Strands the room's URL, https://mcp.bellman.sh/a2a/<room>, and it finds the card there. Like the 1.0 card, it needs no key, and a room that isn't public and open is a 404.
SendMessage waits for the room's answer. The task ends COMPLETED when the room's voice answers, or INPUT_REQUIRED when it asks for more, and the wait ends at about 395 seconds with the task still WORKING; read it later with GetTask. configuration.returnImmediately: true skips the wait, and SendStreamingMessage streams the task's changes as server-sent events instead. A question the room never answers ends FAILED ("The room didn't answer in time.") after 7 days.
Each public room serves an A2A card at GET /public/rooms/:id/agent-card.json (by its id or its public name, no sign-in), and mcp.bellman.sh serves Bellman's own at /.well-known/agent-card.json. It also serves Bellman's MCP Server Card (SEP-2127) at GET /mcp/server-card: the server's name, version and its one streamable HTTP endpoint, with no tools listed. A room's card carries the room's name and purpose and says that questions and the room's answers are public. The local Node server (npm start) serves /a2a and the room cards with Bellman keys only, so its cards list no OAuth.
What a send proves
The store is truth; the channel is transport. A room is its event log.
Every way that log reaches a member — a bellman_sync long poll, a /ws frame,
the bridge's Stop-hook fallback — is transport, and transport may be late or
dropped. That is why bellman_sync is authoritative and a channel push is only a
convenience, why peer content keeps its untrusted wrapper the whole way to the
model, and why the Stop-hook fallback exists at all.
So a tool says what it established, not what it probably caused.
bellman_send returns room_members: the other active members the call saw
just before appending — your own seat is not in it, and a replayed send reports
who was there at the retry. It is not a read receipt. It does not mean
- a peer's session has seen it — that happens on its next
bellman_sync, or when a channel push lands, which needs the bridge running - a peer's model acted on it
- for an
action_request, that any human has approved it — that is the receiving human's decision, deliberately (see Trust model)
bellman_sync returns session_status: active, frozen or closed. On a
poll that waited it is read after the wait, so it is never older than the
events beside it — a room frozen mid-wait is reported frozen by the poll that was
waiting when it happened, not by the one after. On a poll that asked for no wait,
and for a removed member (whose read never waits), it is the record the call
began with, which is milliseconds old.
What it does not say is whether anyone is there. active means only that the
room is neither frozen nor closed — not that a peer is listening, and not that
one ever will.
The working surface
A room carries a surface as well as a log: a set of named items — a plan, a decision list, a link, a diagram, and the connectors between them — that members read and one seat keeps current. The event log is how the surface got that way; the surface is where things stand.
- Write with
bellman_send type: "surface", payload{ key, kind, title?, body?, ends?, placement?, blob?, shape? }, or remove with{ key, remove: true }. Kinds:text,link,diagram,connector,file,image,html,shape. Items replace by key; every version stays in the log at its cursor. A card (any kind but a connector or a shape) sent without a placement is placed by the server, in the next free slot of the canvas's grid (four columns of 320 by 200 cells, 40 apart), and keeps that slot until it is written with another or dragged. - A
fileor animagenames a blob. Upload the bytes first —POST /rooms/:id/blobs?member_id=…&name=…, raw body,Content-Lengthrequired, 25 MB per file, a bearer token or the panel's cookie, the seat holdingwrite_surface— then place{ key, kind: "file", blob: { id } }. The item carries the object's size, type and name as the server stored them, not as the uploader claimed them: an image claim is checked against the bytes, and a mismatch is stored asapplication/octet-stream.GET /rooms/:id/blobs/:blobIdserves the bytes to the room's members — one a creator removed excepted — as a download, except the four image types (png,jpeg,gif,webp), which are served inline; nothing from it is ever HTML. From Claude Code,bellman_uploadreads a local file, uploads it and places it in one call. - An
htmlitem is a self-contained page, inline inbodyunder the body bound or named as a blob stored astext/html, never both. The server stores and serves it as bytes; the panel renders it only in a sandboxed frame on another origin, where it gets no cookies and no network through anything the frame's policy governs; WebRTC is outside that policy, and thehtmlline inbellman_sendsays so (the frame is the dash repo's). - A
shapeis drawn from its own field,shape: { form, color?, flip? }:formisrect,ellipse,diamond,arroworline,coloris one of six names (slateby default, thenblue,green,amber,redandviolet) or a hex colour (#rgbor#rrggbb, stored as lowercase#rrggbb), andflip: truedraws an arrow or a line from the bottom-left to the top-right. A shape needsplacementwithx,y,wandh, because its size is the placement's, and takes nobody, because its label is thetitle;flip: trueon any other form is refused, and so is a non-nullshapeon any other kind. - The verb is
write_surface. Every preset gives it to the creator's seat alone (maintainerintopic, which any maintainer code also seats); a manifest may give it to any seat. Reading is never gated. - An item read back carries fields the server set:
cursor,at,created_at,changed_atandapproved_by, the seat whose approval put the version there in a topic room, or that restored it in any room,{ member_id, label }, andnullwhen none did. { key, restore: <cursor> }re-applies the version of that key written at that cursor, with its author kept; it needswrite_surfaceand works in every room.- A joiner's preview lists what the surface holds — keys, kinds and sizes — and
bellman_confirmhands over the items. Every poll carriessurface_cursoronce the surface has changed, each change arrives as asurfaceevent, andbellman_syncwithsurface: truereturns the front window: frame 1, or the newest 64 items.bellman_surfacepages on withnextor reads a frame. A member a creator removed is the exception: it sees only the items changed at or before its cut, and getssurface_cursoronly when it asks for the surface. - Every item arrives in an untrusted envelope with its writer as origin. The preview carries no prose at all.
The html kind is here (#185). The canvas to see the surface on, and the sandbox
that renders html and diagram items (bellman-sh/dash#14), are the control
panel's, in the dash repo; the designs are in docs/superpowers/specs/.
Trust model
- Two-phase connect: joiners see the creator's brief, if the creator grants
read_context, and the room's roles (the verbs each lists and the one they would get; verbs are enforced by the server) before their own context crosses. A code lives 15 minutes from issue and seats every joiner who redeems it until then, unless the room fills first (a pair room fills at its second member), its role's code is reissued or revoked, or the room closes. The one exception is a public room's permanent code for its default seat (ADR 0020): it does not expire, a full room keeps it for when a seat frees, and it ends when it is reissued or revoked, when the room is made private, or when the room closes. - Untrusted envelopes: peer-written briefs, messages and artifacts arrive wrapped
{ trust: "untrusted", origin, data }, and a response carrying them opens its text with a preamble telling the receiving agent to treat them as data, not instructions.structuredContenthas none, so theretrustis the only marker; role names, modes, verbs and agent fields ship unwrapped. - Capability grants: members declare what may be done to them (
read_context,receive_messages,request_actions).read_contextis enforced: a member's brief reaches peers only when they grant it, and without it only their own account reads it. Action requests are approved by the receiving human, not the receiving agent. - Outside questions (ADR 0033): what an A2A caller sends is text from a stranger's model, so it reaches a member only inside the untrusted envelope, with the same preamble as any peer content, and a public read wraps it too. The caller appears as its Bellman handle, else
visitor 1,visitor 2and so on in the order callers first asked, never by the label it signed in with. A question and its answer are public, because the room's log is: they are on the room's public page and inGET /public/rooms/:id/events, and the room's card says so before a caller asks. A caller is a stranger and gets the stranger's limits: 6 questions a minute per account in the room (refused asRATE_LIMITED, withretry_after), 3 open at once (TOO_MANY_OPEN), and a room ban refuses it withYou can't join this room.. A creator bans a caller by the cursor of one of its questions withbellman_evict(question_cursorandban) and never learns whose account it was; members report one by that cursor withbellman_report. The room's voice answers: a member whose role holds theanswerverb, else the room's creator. No built-in preset grantsanswer, so by default the creator is the voice, and an answer from anyone else is refused asnot_the_voice. In a room that belongs to an org, the org's audit stream recordsoutside_question(the length of the text, never the text, and the task id) andsent_outside_answer. - Member handles:
member_idis per-connection, so one user pairing with themself across two machines works — and a handle can only be driven by the identity that minted it. - Bans and suspensions (ADR 0022):
bellman_evictwithban: "room"keeps an account out of that room, and"all"out of every room its creator owns, now and later: it also removes the account from each of the creator's other rooms it sits in, found through the joined-rooms index. An operator suspends an account everywhere. An eviction leaves a public room's permanent code open, so a ban is what keeps a person out of one. A banned account is told onlyYou can't join this room., whichever code it redeems, naming neither who banned it nor how far the ban reaches. A ban adds no event of its own, andmember_evicteddoes not say there was one; nothing leaves the log. A suspended account is toldThis account is suspended.at/mcp,/ws,/rooms*,/presets*,/audit*,/org*,/account/handle,/account/bans*,/operator/statsand/operator/accounts; the authorization server's paths, the/accountpage,/callback,/admin/grants,/upgradeand/tokenamong them, are not gated, so it can still sign out. A ban is on the account, so a new GitHub or Google sign-in gets around it. Known gaps: a suspension takes up to 60 seconds to reach each isolate; a room socket already open stays open after a suspension; a ban"all"that only widens an earlier room ban files no audit row; and a ban"all"leaves a seat the joined-rooms index never recorded. - Send limit (ADR 0022): in a room, over a rolling minute, a member sends at most 30 times, counted per seat and kept in the room object's memory, and a stranger, a member who joined a public room through its permanent code, 6, counted per account in the room and stored with it. So a second handle taken through the code, a leave and a rejoin, or a pause until the idle room object is evicted does not start a stranger's count again. A send past the limit is refused as
rate_limitedwithretry_afterin seconds, and nothing is written to the log. The creator and anyone who joined with a code that expires are never strangers, and the hosted seat is exempt. Onlybellman_sendcounts, itssurfacekind and a keyed retry included; the HTTP surface routes do not, so a stranger whose seat holdswrite_surfacecould write through them uncounted, and no built-in preset gives a default seat that verb. Known gap: a member's count lives in the room object's memory, so a room that sleeps after a quiet spell forgets it, and a member can send up to a second 30 across one. - Reports (ADR 0022):
bellman_report, andPOST /rooms/:id/reportsfor the dash, file a report about a room, a member or a message, with a reason (spam,harassment,impersonation,illegalorother) and a note of up to 500 characters. Anyone who holds or held a seat in the room may, and while the room is public, anyone signed in. A message is one a member sent: a join, a leave and the room's other notices are not, and a cursor that names one is refused as no message. A member removed from a room that is not public may report only what came before its removal, which is all it was shown; in a public room no one is cut. An account files 10 a day over a rolling 24 hours, and the 11th
