Documentation
Open Rooms
Rooms, peers, messages and actions — plus optional private rooms and media.
Introduction
Open Rooms is a minimal realtime primitive: pick a namespace, join a room, and exchange messages, RPC calls, audio or video with the nodes currently connected. There are no accounts, no room creation step, no server-side message history and no application servers to run.
| Concept | What it is |
|---|---|
| Room | Identified by a namespace. Exists while someone is connected. |
| Peer | One connected client, with a human-readable slug, a description and advertised actions. |
| Message | JSON sent to everyone (*) or to one or several peers, best effort or acknowledged. |
| Action | An RPC capability advertised by a peer that others can run. |
| Media | Optional WebRTC audio, video and screen sharing, signaled through the room. |
Clients connect to wss://relay.open-rooms.dev/rooms/:namespace. The relay is a Cloudflare Worker with one Durable Object per namespace: it tracks presence and routes frames between the peers that are connected right now. It never stores messages and, for locked rooms, only ever sees ciphertext.
Quickstart
From a terminal
# join a room and chat (every line you type is broadcast)
npx open-rooms babar
# in another terminal
npx open-rooms send babar '{"hello":"world"}'
npx open-rooms listen babar | jqFrom code
npm install @open-rooms/sdkimport { openRoom } from "@open-rooms/sdk"
// Node A: exposes an action
const laptop = await openRoom("demo", {
slug: "laptop",
actions: {
add: async ({ a, b }) => a + b,
},
})
// Node B: anywhere else, browser or server
const browser = await openRoom("demo", { slug: "browser" })
browser.peers() // laptop, browser
await browser.send({ hello: true }, { to: "laptop", guaranteed: true })
await browser.run("add", "laptop", { a: 20, b: 22 }) // 42The SDK works in browsers and in Node.js 22+ (both ship WebSocket and WebCrypto). Pass relayUrl or set OPEN_ROOMS_RELAY_URL to use your own relay.
Without the SDK
Raw WebSocket clients work too; they speak the wire protocol directly.
const ws = new WebSocket("wss://relay.open-rooms.dev/rooms/babar")
ws.onopen = () => {
ws.send(JSON.stringify({ type: "hello", slug: "raw-client" }))
ws.send(JSON.stringify({ v: 1, id: "1", kind: "message", to: "*", encrypted: false, payload: "hi" }))
}
ws.onmessage = (event) => console.log(event.data)Namespaces
A namespace is normalized with trim().toLowerCase() and must match [a-z0-9][a-z0-9-_]{0,62}: 1 to 63 characters, no /, ?, #, % or spaces. api, rooms, docs, relay, www, admin and status are reserved.
Rooms need no setup: the first client implicitly creates the room and every client using the same namespace reaches the same Durable Object. When nobody is connected there is no application state left.
Peers & presence
type Peer = {
id: string // ephemeral connection id, new on every reconnect
slug: string // human-readable routing identity
description?: string // untrusted metadata, at most 256 UTF-8 bytes
connectedAt: number
actions: string[]
media?: MediaPeerState
}Slugs
Choose a slug with openRoom("babar", { slug: "macbook" }). Without one, the relay generates an adjective-animal slug such as quiet-panda, suffixing collisions (quiet-panda-2). Slugs are unique among connected peers. An explicitly requested slug that is taken fails with PEER_SLUG_TAKEN — it is never silently renamed — and slugs cannot change while connected.
Presence
room.peers() // every connected peer, including yourself
room.peer("macbook") // Peer | undefined
room.providers("deploy") // peers advertising "deploy", in selection order
room.canRun("deploy") // does anyone provide it?
room.canRun("deploy", "worker-a")
room.on("peer:join", (peer) => {})
room.on("peer:update", (peer) => {})
room.on("peer:leave", (peer) => {})
await room.setDescription("Main development laptop")The welcome frame carries a full presence snapshot, so peers() is populated as soon as openRoom resolves. The SDK also emits connect, disconnect and error events.
Messaging
await room.send({ hello: "world" }) // broadcast to everyone else
await room.send({ hello: "world" }, { to: "macbook" }) // one peer
await room.send({ deploy: true }, { to: ["macbook", "worker-a", "worker-b"] })
room.on("message", ({ id, from, to, data }) => {})| Option | Default | |
|---|---|---|
| to | "*" | A slug, a list of slugs, or * for every other peer. |
| guaranteed | false | Resolve only when every recipient acknowledged. |
| attempts | 3 | Guaranteed delivery attempts: the initial send plus two retries. |
| timeout | — | Best effort: how long to wait for the relay receipt. Guaranteed: overall deadline. |
Best effort
A normal send never throws just because a peer disappeared. It resolves with a DeliveryResult; deliveredTo only means the relay routed the frame, not that the peer received it.
type DeliveryResult = {
messageId: string
deliveredTo: string[]
unavailable: string[]
}Guaranteed delivery
With guaranteed: true the promise resolves only after the connected recipients acknowledged receipt. The recipient SDK answers automatically with an ack. Unacknowledged recipients are retried: attempt 1, wait 1s; attempt 2, wait 2s; attempt 3, wait 4s; then a DeliveryError is thrown with deliveredTo, failedTo and attempts. With several recipients it succeeds only if all of them acknowledge.
Deduplication
Retries can duplicate frames, so every client remembers the last 10,000 message IDs (up to 10 minutes). A duplicate is acknowledged again but never emitted twice — effectively-once at the event layer while the client is alive.
RPC actions
A peer advertises actions; any peer in the room can run them.
const room = await openRoom("agents", {
slug: "macbook",
actions: {
ping: async () => ({ pong: true }),
add: async ({ a, b }) => a + b,
},
})Handlers
type ActionHandler = (params: Record<string, any>, context: ActionContext) => Promise<JSONEncodableValue>
type ActionContext = {
room: string
requestId: string
caller: { id: string; slug: string; description?: string }
}
room.actions.set("shell", async ({ command }, { caller }) => {
if (caller.slug !== "browser") {
throw Object.assign(new Error("not allowed"), { code: "UNAUTHORIZED" })
}
return run(command)
})The caller slug is stamped by the relay, so handlers can implement authorization. Thrown errors reach the caller as RemoteActionError with code (ACTION_FAILED unless the error carries an upper-case code) and message; stack traces are never sent.
Running actions
const result = await room.run("add", "calculator", { a: 20, b: 22 }) // 42
await room.run("build", "*", { branch: "main" }) // any provider
await room.run("long-task", "worker", {}, { timeout: 120_000 }) // default 30s- A specific host must exist and advertise the action, otherwise
runrejects immediately withPeerNotFoundErrororActionNotFoundError. "*"selects one provider client-side — oldestconnectedAtfirst, then slug — and never broadcasts. With no provider:NoActionProviderError(NO_ACTION_PROVIDER).- Requests and responses always use guaranteed delivery, and hosts cache executions by request ID for 10 minutes: a retried request never runs an action twice; a finished one resends its cached result or error.
- Slow actions reject with
RpcTimeoutError.
Dynamic actions
room.actions.set("deploy", async (params) => deploy(params))
room.actions.set("deploy", deployV2) // replace
room.actions.has("deploy") // true
room.actions.list() // ["deploy"]
room.actions.delete("deploy") // true
room.actions.clear()Changes are broadcast to the room immediately as a peer update. Action names match [a-zA-Z0-9][a-zA-Z0-9._:-]{0,127} (e.g. files.read, git:status) and are opaque strings; a peer can advertise up to 128.
Private rooms
Pass a passphrase to lock a room. If the namespace is unclaimed the SDK locks it; if it is already locked, the passphrase lets you in. Application payloads are then encrypted and decrypted entirely by clients.
const room = await openRoom("babar", { passphrase: process.env.ROOM_PASSPHRASE })What happens
masterKey = PBKDF2-HMAC-SHA-256(passphrase, SHA256("open-rooms/v1:" + namespace), 250_000)
authKey = HKDF(masterKey, "open-rooms/auth/v1")
messageKey = HKDF(masterKey, "open-rooms/messages/v1")
challenge = HMAC-SHA-256(authKey, "open-rooms:join:" + namespace)- The client sends only the
challenge. The relay storesSHA256(challenge)— never the passphrase or any key. PUT /rooms/:namespace/lockclaims a namespace. It is serialized by the room's Durable Object, idempotent for the same challenge,409 ROOM_LOCKEDfor another one, and409 ROOM_ACTIVEif the public room currently has clients.- To connect, the client exchanges the challenge for a single-use ticket valid for 30 seconds (
POST /rooms/:namespace/ticket) and openswss://…/rooms/:namespace?ticket=…. Long-lived credentials never appear in URLs. - Payloads are sealed with AES-256-GCM and a random 96-bit nonce per message. Routing metadata (namespace, kind, message id, sender, reply-to) is bound as additional authenticated data, so the relay cannot re-attribute a message without breaking decryption.
- A lock lives for 7 days after the latest authenticated connection (refreshed on every one, and while authenticated peers stay connected). There is no explicit unlock: after 7 idle days the namespace becomes public and claimable again.
| The relay can see | The relay cannot see |
|---|---|
| namespace, message IDs and kinds, sender and recipients, payload sizes, timing, peer slugs, descriptions, action names | passphrase, keys, message content, RPC params and results, media signaling (SDP/ICE) |
The viewer asks for the passphrase, derives everything in the browser and stores the passphrase in localStorage under open-rooms:<namespace>:passphrase until you click Forget passphrase.
Media
@open-rooms/media adds audio, video and screen sharing on top of a room. The room handles discovery, authorization and SDP/ICE signaling; the media itself flows over WebRTC — never through the relay's WebSockets.
import { openRoom } from "@open-rooms/sdk"
import { media } from "@open-rooms/media"
// Publisher
const laptop = await openRoom("demo", { slug: "laptop" })
const m = media(laptop)
await m.publish({ audio: true, video: true })
// Viewer
const browser = await openRoom("demo", { slug: "browser" })
const viewer = media(browser)
viewer.on("track", ({ peer, stream, kind }) => {
if (peer.slug === "laptop" && kind === "video") video.srcObject = stream
})
await viewer.subscribe("laptop")Publishing
const mic = await m.publishAudio({ echoCancellation: true, noiseSuppression: true })
const camera = await m.publishVideo({ width: 1280, height: 720, frameRate: 30 })
const screen = await m.publishScreen()
await m.publish(stream) // an existing MediaStream
await m.publishTrack(canvas.captureStream().getVideoTracks()[0], { slug: "whiteboard" })
camera.mute()
camera.unmute()
camera.stop()Publications get human-readable slugs (microphone, camera, screen, or your own) and are advertised in the peer's metadata, which distinguishes what a peer can publish from what it is publishing:
room.peer("macbook")?.media
// { supported: { audio: true, video: true, screen: true },
// publishing: [{ slug: "microphone", kind: "audio", source: "microphone" },
// { slug: "webcam", kind: "video", source: "camera" }] }Subscribing
await m.subscribe("camera-1") // everything camera-1 publishes
await m.subscribe("camera-1", { audio: false, video: true })
await m.subscribe("macbook", "webcam") // one track by slug
await m.subscribe("*") // every peer
await m.unsubscribe("camera-1")
m.onAudio("macbook", (stream) => (audio.srcObject = stream))
m.onVideo("camera-1", (stream) => (video.srcObject = stream))
m.on("track", (event) => {}) // { peer, track, stream, kind, slug, source }
m.on("track:ended", (event) => {})
m.on("publication:add", (publication) => {})
m.on("publication:remove", (publication) => {})
m.on("connection", ({ peer }) => {})
m.on("disconnection", ({ peer }) => {})
await m.connect("macbook") // direct WebRTC connection without mediaSubscriptions are remembered: tracks published later, and peers that reconnect, are picked up automatically.
Connectivity
Peers connect directly when they can. Otherwise ICE falls back to Cloudflare Realtime TURN over UDP, TCP or TLS on port 443 for restrictive networks. No configuration is needed: the client fetches short-lived credentials from POST /rooms/:namespace/media/credentials (locked rooms require the room challenge); permanent TURN secrets never ship in the SDK. Providing your own servers disables the defaults:
const m = media(room, { iceServers: [{ urls: "turn:turn.example.com:3478", username, credential }] })Topologies
The default p2p topology is best for one-to-one sessions — a full mesh of N peers needs N×(N−1)/2 connections. topology: "sfu" (experimental) pushes and pulls tracks through Cloudflare Realtime SFU via the relay, while Open Rooms keeps room identity, presence, authorization and RPC.
Media + RPC
// A camera node streams continuously and answers discrete requests.
const room = await openRoom("house", {
slug: "garage-camera",
actions: { snapshot: async () => captureStill() },
})
await media(room).publishTrack(cameraTrack, { slug: "live" })
// Anyone else:
await media(other).subscribe("garage-camera", "live")
const still = await other.run("snapshot", "garage-camera")Outside browsers, pass a WebRTC implementation: media(room, { webrtc: { RTCPeerConnection, MediaStream } }) — for example from @roamhq/wrtc in Node.js. The signaling protocol is platform-independent.
CLI
npm install -g open-rooms # or: npx open-rooms …# Connect interactively: incoming messages and presence are printed,
# each line you type is broadcast (JSON if it parses, text otherwise)
open-rooms babar
open-rooms connect babar --slug macbook --description "Main laptop"
# Send and listen
open-rooms send babar '{"hello":"world"}'
open-rooms send babar --to macbook --guaranteed '{"hello":"world"}'
echo 'build finished' | open-rooms send babar --to macbook,browser
open-rooms listen babar | jq
open-rooms listen babar --events --quiet >> babar.ndjson # presence events too
# Presence
open-rooms peers agents
# RPC: prints only the returned JSON
open-rooms run agents shell --host macbook '{"command":"pwd"}'
open-rooms run agents shell --host '*' '{"command":"pwd"}'
# Raw pipe: stdin lines -> frames, frames -> stdout lines (sends a hello first unless --no-hello)
echo '{"kind":"message","to":"*","payload":"hi"}' | open-rooms pipe babar --slug scriptInside connect, lines starting with a slash are commands: /to <slug> <message>, /peers, /run <action> <host> [json], /help and /quit.
Private rooms
Passphrases are never accepted as command-line flags, so they stay out of your shell history. When a room is locked the CLI prompts for it; --lock creates a locked room; for automation use an environment variable or a file:
OPEN_ROOMS_PASSPHRASE=foo open-rooms babar
open-rooms listen babar --passphrase-file ./secret.txt| Environment | |
|---|---|
| OPEN_ROOMS_RELAY_URL | Relay origin (default https://relay.open-rooms.dev). Also --relay. |
| OPEN_ROOMS_PASSPHRASE | Passphrase for locked rooms. |
| OPEN_ROOMS_WEB_URL | Base URL used when printing viewer links. |
WebSocket protocol
Everything is JSON text frames on wss://relay.open-rooms.dev/rooms/:namespace (append ?ticket=… for locked rooms). The SDK adds encryption, tickets, reconnection, ACKs, RPC, presence helpers and capability discovery on top.
Handshake
// client → relay
{ "type": "hello", "slug": "macbook", "description": "Main laptop", "actions": ["shell", "deploy"] }
// relay → client (peers includes yourself)
{ "type": "welcome", "v": 1, "room": "babar", "locked": false,
"self": { "id": "peer_…", "slug": "macbook", "connectedAt": 1780000000000, "actions": ["shell", "deploy"] },
"peers": [ … ] }Every field of hello is optional. A client that sends an envelope before saying hello joins implicitly with a generated slug. Presence changes arrive as peer:join, peer:update and peer:leave frames carrying the full peer. To change metadata while connected, send { "type": "peer:update", "description": "…", "actions": [ … ] }.
Envelopes
type Envelope = {
v: 1
id: string
kind: "message" | "ack" | "rpc:request" | "rpc:response" | "rpc:error" | "media:signal"
from: string // stamped by the relay; whatever the client sends is ignored
to: "*" | string[]
attempt?: number // present when an acknowledgement is requested
replyTo?: string // acks, RPC responses and errors
encrypted: boolean
payload?: JSONEncodableValue | string // base64url(nonce ‖ AES-GCM ciphertext) when encrypted
}- Recipients answer envelopes that carry
attemptwith{ "kind": "ack", "replyTo": "<id>", "to": ["<sender>"] }. - For every envelope except acks the sender gets a routing receipt:
{ "type": "receipt", "id", "deliveredTo": [], "unavailable": [] }. - RPC payloads are
{ "action", "params" },{ "result" }and{ "code", "message" }. Media signaling (media:offer,media:answer,media:ice,media:close,media:track…) travels inmedia:signalenvelopes and never surfaces as a message event. - Errors arrive as
{ "type": "error", "code", "message", "replyTo"? }. Handshake failures also close the socket with a 4xxx code whose reason is the error code: 4001ROOM_LOCKED, 4002INVALID_TICKET, 4003ROOM_FULL, 4004PEER_SLUG_TAKEN, 4005INVALID_SLUG, 4006INVALID_NAMESPACE, 4007RESERVED_NAMESPACE. - Send the text frame
pingto receivepong. Heartbeats are answered without waking the Durable Object.
HTTP API
| Endpoint | Description |
|---|---|
| GET /rooms/:namespace | { namespace, locked, connected } — or { namespace, locked: true, expiresAt } |
| GET /rooms/:namespace/peers | { namespace, peers }. Locked rooms need Authorization: Bearer <challenge>. |
| PUT /rooms/:namespace/lock | Body { challenge } → { locked: true, expiresAt }. 409 ROOM_LOCKED / ROOM_ACTIVE. |
| POST /rooms/:namespace/ticket | Body { challenge } → { ticket, expiresAt } (30s, single use). 403 ROOM_LOCKED, 409 ROOM_NOT_LOCKED. |
| GET /rooms/:namespace (Upgrade) | WebSocket connection. |
| POST /rooms/:namespace/media/credentials | { iceServers, expiresAt, turn } — short-lived TURN credentials. |
| /rooms/:namespace/media/sfu/… | Cloudflare Realtime SFU session proxy (topology: "sfu"). |
PUT /rooms/babar/lock HTTP/1.1
Host: relay.open-rooms.dev
Content-Type: application/json
{"challenge":"q0bY2cV1…43 base64url chars"}Errors are JSON: { "error": "ROOM_LOCKED", "message": "…" }. All endpoints send permissive CORS headers so browsers can call them from any origin.
Errors
Every SDK error extends OpenRoomsError and exposes a stable error.code.
| Class | code | When |
|---|---|---|
| PeerNotFoundError | PEER_NOT_FOUND | run() on a peer that is not connected |
| ActionNotFoundError | ACTION_NOT_FOUND | the host does not provide the action |
| NoActionProviderError | NO_ACTION_PROVIDER | run(action, "*") with no provider |
| RpcTimeoutError | RPC_TIMEOUT | no response before the timeout |
| RemoteActionError | ACTION_FAILED or the remote code | the action threw |
| DeliveryError | DELIVERY_FAILED | guaranteed delivery was not acknowledged |
| RoomLockedError | ROOM_LOCKED | locked room, missing or wrong passphrase |
| PeerSlugTakenError | PEER_SLUG_TAKEN | the requested slug is in use |
| RoomActiveError | ROOM_ACTIVE | locking a public room that has clients |
| NotConnectedError | NOT_CONNECTED | the relay is unreachable or the room was closed |
Limits
| Limit | Value |
|---|---|
| clients per room | 100 |
| frame size | 1 MB |
| messages per peer | 50 per second |
| advertised actions per peer | 128 |
| action name | 128 characters |
| description | 256 UTF-8 bytes |
| lock operations | 20 per IP per minute |
| ticket operations | 100 per IP per minute |
| RPC timeout (default) | 30 seconds |
| lock lifetime | 7 days after the latest authenticated connection |
Security model
Private rooms protect against
- passive visibility by the infrastructure
- random unauthorized joins
- plaintext message storage
- permanent namespace squatting
They do not fully protect against
- weak passphrases and dictionary attacks
- browser XSS or compromised clients
- traffic and timing analysis
- denial of service
context.caller. Public rooms are public: anyone who knows the namespace can join.Self-hosting
The monorepo contains the relay (apps/relay, Cloudflare Workers + Durable Objects) and this site (apps/web, Next.js on Cloudflare Workers via OpenNext). The web app is never on the realtime path.
pnpm install
pnpm dev # local relay on :8787, web on :3000, packages in watch mode
pnpm test
# Cloudflare (set account_id and routes in apps/*/wrangler.jsonc first)
pnpm --filter @open-rooms/relay exec wrangler login
pnpm --filter @open-rooms/relay exec wrangler secret put TURN_KEY_ID # Realtime TURN (optional)
pnpm --filter @open-rooms/relay exec wrangler secret put TURN_KEY_API_TOKEN
pnpm deploy:relay # relay Worker + Durable Objects + KV
pnpm deploy:web # this site, built with NEXT_PUBLIC_RELAY_URL pointing to your relayWithout TURN secrets the relay hands out STUN-only ICE servers. REALTIME_APP_ID and REALTIME_APP_TOKEN enable the SFU topology.