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.

ConceptWhat it is
RoomIdentified by a namespace. Exists while someone is connected.
PeerOne connected client, with a human-readable slug, a description and advertised actions.
MessageJSON sent to everyone (*) or to one or several peers, best effort or acknowledged.
ActionAn RPC capability advertised by a peer that others can run.
MediaOptional 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 | jq

From code

npm install @open-rooms/sdk
import { 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 }) // 42

The 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 }) => {})
OptionDefault
to"*"A slug, a list of slugs, or * for every other peer.
guaranteedfalseResolve only when every recipient acknowledged.
attempts3Guaranteed delivery attempts: the initial send plus two retries.
timeoutBest 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.

Guaranteed means “the connected recipient acknowledged receipt”. It is not offline queueing, persistence or exactly-once execution: nothing is stored for peers that are not connected.

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 run rejects immediately with PeerNotFoundError or ActionNotFoundError.
  • "*" selects one provider client-side — oldest connectedAt first, 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 stores SHA256(challenge) — never the passphrase or any key.
  • PUT /rooms/:namespace/lock claims a namespace. It is serialized by the room's Durable Object, idempotent for the same challenge, 409 ROOM_LOCKED for another one, and 409 ROOM_ACTIVE if 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 opens wss://…/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 seeThe relay cannot see
namespace, message IDs and kinds, sender and recipients, payload sizes, timing, peer slugs, descriptions, action namespassphrase, 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 media

Subscriptions 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 script

Inside 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_URLRelay origin (default https://relay.open-rooms.dev). Also --relay.
OPEN_ROOMS_PASSPHRASEPassphrase for locked rooms.
OPEN_ROOMS_WEB_URLBase 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 attempt with { "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 in media:signal envelopes 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: 4001 ROOM_LOCKED, 4002 INVALID_TICKET, 4003 ROOM_FULL, 4004 PEER_SLUG_TAKEN, 4005 INVALID_SLUG, 4006 INVALID_NAMESPACE, 4007 RESERVED_NAMESPACE.
  • Send the text frame ping to receive pong. Heartbeats are answered without waking the Durable Object.

HTTP API

EndpointDescription
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/lockBody { challenge } → { locked: true, expiresAt }. 409 ROOM_LOCKED / ROOM_ACTIVE.
POST /rooms/:namespace/ticketBody { 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.

ClasscodeWhen
PeerNotFoundErrorPEER_NOT_FOUNDrun() on a peer that is not connected
ActionNotFoundErrorACTION_NOT_FOUNDthe host does not provide the action
NoActionProviderErrorNO_ACTION_PROVIDERrun(action, "*") with no provider
RpcTimeoutErrorRPC_TIMEOUTno response before the timeout
RemoteActionErrorACTION_FAILED or the remote codethe action threw
DeliveryErrorDELIVERY_FAILEDguaranteed delivery was not acknowledged
RoomLockedErrorROOM_LOCKEDlocked room, missing or wrong passphrase
PeerSlugTakenErrorPEER_SLUG_TAKENthe requested slug is in use
RoomActiveErrorROOM_ACTIVElocking a public room that has clients
NotConnectedErrorNOT_CONNECTEDthe relay is unreachable or the room was closed

Limits

LimitValue
clients per room100
frame size1 MB
messages per peer50 per second
advertised actions per peer128
action name128 characters
description256 UTF-8 bytes
lock operations20 per IP per minute
ticket operations100 per IP per minute
RPC timeout (default)30 seconds
lock lifetime7 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
Anyone with the passphrase can read and write. Use high-entropy passphrases when it matters, and authorize sensitive actions with 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 relay

Without TURN secrets the relay hands out STUN-only ICE servers. REALTIME_APP_ID and REALTIME_APP_TOKEN enable the SFU topology.