← back/blog/room-protocol

the Room Protocol · shared-state coordination

Coordinating coding agents through shared state, not messages

The moment a second agent joins your project, coordination becomes the bottleneck. I don't think the answer is a group chat for robots. I think it's a shared room — and this is the protocol I built to test that idea.

13 min read·shreyaspadmakiran.com

A single coding agent is great on its own. It holds a task, reads the codebase, edits files, reports back. Then you add a second agent to the same project and everything that was implicit becomes a problem. Who is building the auth service? What shape is its API, so the frontend agent can call it? Is that task actually done, or just started? What did we decide about sessions last Tuesday, and why?

None of these questions have good answers today, and the ways people paper over them all share the same flaw. This post is about that flaw, the idea I think fixes it, and the working server I built to find out.

Act I — The problem and the idea

Three bad ways to coordinate agents

When two agents need to agree on something, coordination happens one of three ways right now, and each one hurts.

A human relays it. You copy an API contract out of one agent's chat and paste it into the other's. This works for exactly one handoff and then you are the integration bus for two robots that type faster than you.

The agents talk. One agent sends the other a natural-language message describing what it needs. Conversation is lossy and order-dependent. There's no durable record to query later, no signal that a message was even read, and nothing stopping both agents from editing the same plan at the same instant.

They share a repo. Great for code, useless for intent. A git history can't tell agent B that agent A is halfway through the auth service right now, or that a contract B depends on changed five minutes ago.

The common thread: all three treat coordination as message-passing. But messages are transient and have to be interpreted. What agents actually need is state— something durable they can query. An agent booting cold should be able to ask “what's the plan, what's mine, what changed since I last looked, who else is here” and get a structured answer, not a transcript to re-read.

If the fix is shared state instead of messages, the question becomes: what's the smallest amount of shared state that actually lets two agents build software together? That set turned out to be surprisingly small.

The idea: a room, not a chat

The central abstraction is a room. A room is one id — say my-saas-backend— that namespaces everything the agents share on a project. Agents don't address each other. They join a room and read and write its state. Joining is implicit: the first time an agent touches a room, it's known.

Message-passing between agents versus a shared roomLeft: three agents connected to each other by tangled message arrows. Right: three agents each connected to a single central room box.messages between agentsA1A2A3lossy · transient · no recordshared roomroomA1A2A3
Left: N agents, N² message paths, nothing durable. Right: every agent reads and writes one shared room. The room holds the truth, not the conversation.

A room is also the unit of ownership and access — but hold that thought, because first it helps to see what actually lives inside one.

Five primitives, and why each exists

The claim the protocol makes is that five kinds of shared state are enough to coordinate independent coding agents. Each one earns its place because a specific failure happens without it.

PlanA shared task list — status, owner, dependencies. Answers 'what is there to do and who has it.' Without it, two agents grab the same task or both assume the other will do it.
ContextA typed store of artifacts: API contracts, architecture decisions, notes. Answers 'what has been decided that I build against.' This is the durable record conversation lacks.
EventsAn append-only log, newest first. Answers 'what changed since I last looked.' Some events are posted by agents; others fire automatically when the plan changes.
PresenceA liveness signal. Answers 'who is actually here right now,' so an agent knows whether the peer it's waiting on is even connected.
LocksDistributed mutual exclusion. Answers 'may I safely modify the plan.' Without it, two simultaneous plan edits silently overwrite each other.

Agents reach these through MCP tools — 19 of them in the running server — but the tools are just the surface. The interesting part is what they do to shared state, and how they keep that state consistent when several agents hit it at once.


Act II — How it works

Typed context beats prose

If context were free text, we'd be right back to message-passing — a consuming agent would have to parse English to extract a contract. Instead, every context entry has a type from a closed set, and each type has a payload schema the author fills in: api_contract, arch_decision, task, change_request, note.

A frontend agent reading an api_contractknows there's an endpoints array with a method and pathon each entry. It doesn't infer that from a paragraph. The data is structured at the moment it's written, not reconstructed at the moment it's read. That single difference is the whole ballgame.

The handoff, with no human in it

Here's the pattern that made me build this: a backend agent designs an interface, and a frontend agent builds against it — asynchronously, on different machines, no one relaying anything.

backend  → update_task(auth API, in_progress, owner: backend)
backend  → write_context(type: api_contract, payload: {...})
              ↳ room auto-posts "context_available"
backend  → update_task(auth API, done)

frontend → get_my_summary()          // one cold-start call
              ↳ my tasks, unread events, context count, presence
frontend → list_context(type: api_contract)   // reads the contract
frontend → post_event(type: change_request, to: backend,
              "need a /me endpoint")

backend  → get_unread_events()        // sees it exactly once
backend  → write_context(api_contract v1.1) + reply_to_event(...)

Not one step there is a message in the conversational sense. Every line is a durable read or write of shared state. The frontend agent that shows up an hour later doesn't replay a transcript — it queries the state that exists. If either agent crashes and restarts, it recovers by asking the room again, because the room holds the truth, not the agent.

“Sees it exactly once” and “auto-posts” are doing quiet work in that trace. Both come from how the room stays consistent under concurrent agents — which is where the engineering actually lives.

Keeping the room consistent

The plan is a single JSON document that multiple agents modify with read-modify-write. Two agents doing that at once would lose an update. So every plan write takes a distributed lock built on Redis's atomic “set only if absent, with an expiry”:

await redis.set(lockKey, agentId, { nx: true, px: 10_000 });
// nx  → only one caller can win the key
// px  → auto-expires, so a crashed holder can't deadlock the plan

The winner does its read-modify-write and releases; losers retry with a little backoff. It costs one extra round trip on the contended path, and in exchange two agents updating tasks at the same time apply one after the other instead of clobbering each other.

Events use a different trick. The log is shared, but each agent has its own cursor — a timestamp of what it last saw. get_unread_events reads everything newer than the cursor, advances it, and returns the slice. Two agents reading the same log each get every event exactly once, with zero coordination between them and no mutation of the shared log. Presence is the same spirit: a heartbeat key with a 120-second expiry. Stop heartbeating and you go offline on your own, no cleanup job required.

Stateless server, one store

The server itself remembers nothing between requests. Every call spins up a fresh MCP server, handles the one request, and throws it away. All the durable state lives in a single Redis instance. That sounds austere, but it's the point: any server instance can serve any request because no instance holds state, so the thing restarts and scales without ceremony.

roomd architectureAgent A, Agent B and an operator dashboard on the left connect to a stateless roomd server in the middle, which reads and writes a single Redis store on the right.agent Aagent BoperatorMCP / HTTP + Bearerroomdstateless · auth + rate limitfresh server per request · 19 toolsall stateRedisthe only database
Everything durable is a key in Redis. The server is disposable. That austerity is also what makes the whole thing multi-tenant.

Why does statelessness make it multi-tenant? Because with no server-side state, isolation becomes a property of how keys are named, enforced in one place. A room is owned by the first team that touches it, claimed with the same atomic set-if-absent used for locking. Later calls from the same team pass; anyone else gets a deliberately vague “room not found or access denied.” Guests get room-scoped invite tokens that can reach exactly one room and nothing else. Three kinds of key, one team identity, no separate auth database.


Act III — Using it, and what's honest about it

Two changes to start

Connecting an agent is two edits. Point Claude Code at the server in .claude/settings.json, and tell it which room to join.

// .claude/settings.json
{
  "mcpServers": {
    "room-protocol": {
      "type": "http",
      "url": "https://roomd.sh/mcp",
      "headers": { "Authorization": "Bearer YOUR_SECRET" }
    }
  }
}

Then, at the start of a session: “our room is my-saas-backend, call get_my_summaryto catch up.” That one call returns your tasks, your unread events, how much new context exists, and who else is online. The agent is oriented in a single round trip instead of five.

Does it actually beat a group chat?

Honest answer: I've run it, it works, and I haven't proven the advantage with numbers yet. The clean way to test the claim is a side-by-side — run the same multi-agent build task twice, once where agents coordinate by chatting and once through the room, and measure a few things:

Human interventionsHow many times a person had to relay or restate something. Target: zero. This is the headline number.
Handoff correctnessDid the consumer build against the actual current contract, or a stale one?
Cold-start costTokens to recover context after a fresh session: replaying a transcript versus one get_my_summary.
Concurrency safetyLost-update rate when N agents write the plan at once — directly measurable with a stress harness.

The neat part is that the event log is the dataset. Every task change and handoff already emits a typed, timestamped event, so a finished room is a machine-readable trace of the coordination that happened. Most of the evaluation is just replaying read_events over a completed room.

What I left out on purpose

The first version is deliberately small. Agents poll instead of getting pushed to — polling was simpler and enough. Context is fetched by type and id, not by meaning; semantic search is a later problem. Updating a contract writes a new entry with no version history yet. Logs and context grow without a retention policy. None of these were oversights — each was a thing I could add once the core idea earned it, and shipping the core idea was the point.

That's the whole bet, really. Multi-agent development breaks at the seams — the handoffs, the shared decisions, the “wait, who's doing that” moments. The reflex is to treat those seams as conversations, and conversations are lossy. Give the agents a room instead — a little structured, durable, queryable shared state behind one id — and let them coordinate by reading and writing it. It's the same move any working team already makes: you don't stay in sync by talking louder, you keep a board everyone can see.

— shreyas

more in this series

01Coordinating agents through shared state · you are here

02Concurrency control for a shared plan · locks and cursors

03A stateless server on a single Redis · architecture

04Typed context over prose and vector search · context model

05MCP as transport · protocol layer

06Multi-tenancy on one Redis · isolation and access