Skip to content

Wire protocol

Devices and companion apps hold one WebSocket to the backend. Every frame is a UTF-8 JSON object of at most 16384 bytes with a string discriminator t. Any message may carry an optional id (URL-safe, ≤64 chars); an error reply echoes it as ref. Unknown fields are ignored; a message of an unknown t is answered with error bad_message naming it in unsupported, and the connection stays open.

Device connection: hello → (unpaired) pair.begin → pair.code … pair.done, then reconnect; (paired) auth.challenge → auth.proof → config. After that, call control and signaling.

App connection: app.hello (session token from HTTP login) → app.ready.

Endpoints and routing hints. Devices connect to /ws/device, adding ?device=<deviceId> once paired; apps connect to /ws/app?household=<householdId>. Hints only route the socket (to the household’s Durable Object on Cloudflare; the self-hosted server ignores them) — every connection is still authenticated by the handshake. Keep-alive {"t":"ping"} must be sent byte-for-byte as shown so hibernating servers can answer it without waking.

  • Protocol version. PROTOCOL_VERSION (now 1) changes only for breaking changes. Additive changes keep it: new optional fields, new message types, new values behind a server feature. Removing or renaming a field, making one required (or a required one optional), changing a field’s type, meaning or limits, or a new value in a closed enum is breaking.
  • What a client says. hello carries proto (the protocol version the phone speaks) and fw (its firmware version; semver on hardware, e.g. 0.6.0); app.hello carries proto.
  • What the server says. It accepts protocol version 1 (MIN_PROTOCOL_VERSION..PROTOCOL_VERSION): when PROTOCOL_VERSION is bumped, the previous version stays accepted for at least 12 months. config.server and app.ready.server tell the client the range, the software version and the optional features it offers (call-control, rooms, greetings, extensions, lounge, recording, voicemail-play, public-lines, line-callback, notices, addons, addon-shares, addon-about, missed-calls, call-waiting, checkin, chime, intercom, key-pages), so a client can leave out what a server doesn’t offer; clients ignore names they don’t know.
  • Outside the range. A client older than the server accepts gets, for a phone, a config with update (show message, e.g. UPDATE NEEDED, and don’t treat the phone as signed in), then error unsupported_version with server, and the connection closes (4400). A client newer than the server gets the same error (the message says the server is older) and may reconnect speaking a version in server.protocol if it can.
  • Tolerance, both ways. Receivers ignore unknown fields (the schemas strip them; size limits still apply) and never disconnect for an unknown message type. Clients drop unknown server messages and optional fields they can’t read; firmware ignores both.
  • Frozen vectors. tests/conformance/protocol-v1/ holds example messages (hello, pairing, auth.proof, config, calls) and a snapshot of every v1 schema; their tests must keep passing for v1 (see CONTRIBUTING.md).

First message on every connection.

Field Type Required Notes
id string (len ≤64)
proto integer yes
deviceId string (len ≤64) Omitted by an unpaired device.
model "web-emulator" | "desktop" | "esp32s3" | "addon" yes addon = an add-on (a service linked to a person’s account, docs/add-ons.md).
fw string (len ≤32) yes Firmware / emulator version.
buttons integer (≥1, ≤16) yes Number of speed-dial buttons.
display "eink" | "seg14" | "oled" | "none" yes Status display fitted: eink = a 2.13-inch e-paper module on the board’s display header (standard); seg14 / oled / none remain for other builds and the browser phone (none = keys, LEDs and voice only, with printed key labels).
build { sha256: string (^[0-9a-f]{64}$), project: string (len ≤32), version: string (len ≤32), official: boolean } Hardware phones: the firmware image it is running (the browser phone sends none: it runs what the server serves).
features string (len ≤32)[] What the phone handles beyond the base protocol, so the server sends it only what it shows truthfully: missed-calls (missed calls in config.missed with kind: "call", and call.back), call-waiting (a second call while in one, call.ringing.waiting), key-pages (speed-dial keys beyond the first ten, in config.pages; BACK turns the page). A phone that doesn’t list one gets today’s behaviour (missed calls left out; a second call refused as busy; only the first page of keys). Unknown names are ignored.

Team/org spaces: dial an extension of your space (a member, a phone, a room or a ring group). Phones: MENU → Dial extension, the digits, then MENU. Refused (denied) in homes and for unknown numbers; a ring group that’s closed follows its after-hours action.

Field Type Required Notes
id string (len ≤64)
number string (^[0-9]{2,6}$) yes

Server feature addons. Call by address. An add-on calls its owner, or (with share) someone who holds that share (people only); an app calls an add-on it may talk to: its own (handle+name@host), or a share of one that its owner gave them (handle+name+share@host). Phones use speed-dial keys instead. A refusal answers call.state ended with a note in plain words (core refusals.ts): someone who may not call it gets the same unavailable + You can't call that address whether it exists or not; its owner and holders hear why (unreachable + not connected, busy + on as many calls as it takes, no add-on by that name linked to your own handle, add-ons off), and fair use answers denied with its note.

Field Type Required Notes
id string (len ≤64)
address string (len ≥3, len ≤300) yes handle@host (a person), handle+name@host (an add-on) or handle+name+share@host (one of its shares), on this server or another one.
share string (^[a-z0-9-]{1,30}$) Server feature addon-shares, from an add-on only: call within this share of it. The callee must be its owner or hold the share; they see the share’s label with the add-on’s name.

Server feature addons: a key pressed during an answered call with an add-on (0–9, *, #). Sent by an app; the server passes it to the add-on (on another server too) and drops it for anyone else.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
key string (^[0-9*#]$) yes

Server feature addon-shares, from an add-on only: the parts of itself it offers for sharing (e.g. one shared project). Replaces the previous list; send it after connecting and whenever it changes. A share left out is gone: the people it was given to lose it and their calls into it end.

Field Type Required Notes
id string (len ≤64)
shares { id: string (^[a-z0-9-]{1,30}$), label: string (len ≤40) }[] yes Everything it offers for sharing now; ids unique.

Server feature addon-shares, from an add-on: it made the share its owner asked for (addon.share.request). Send addon.shares with it first; the server then gives it to the people in the request.

Field Type Required Notes
id string (^[a-z0-9-]{1,30}$) yes The new share (already in the add-on’s latest addon.shares).
requestId string (len ≤64) yes

Server feature addon-shares, from an add-on: it won’t make that share.

Field Type Required Notes
id string (len ≤64)
requestId string (len ≤64) yes
reason string (len ≤200) yes Shown to its owner as it is.

From an add-on: the answer to addon.test, sent at once (the bridge does it by itself). The server measures the time from its addon.test to this.

Field Type Required Notes
id string (len ≤64)
testId string (len ≤64) yes The addon.test this answers.
version string (len ≤64) Its software and version (plain text), e.g. seat/1.4.
uptimeS integer (≥0) Seconds since it started (its process, not this connection).
calls integer (≥0, ≤100) Calls it’s on right now.

Server feature addons, from an add-on: what it does, in its own words, for its page in the app. Send it after connecting, before addon.shares; it replaces the earlier one. The server enforces answers, calls, shares, keys and lines; summary, hears and transcribes are shown as the add-on’s own statement. An add-on that never sends it is shown as not having said what it does, and keeps the rules of server 0.7.0 (it can be called, may call, may offer shares, gets keys, one call at a time).

Field Type Required Notes
id string (len ≤64)
summary string (len ≤140) One plain sentence: what it is, as its page in the app shows it.
hears "nothing" | "live" | "keeps" yes nothing: it only plays sound and doesn’t use what you say (one-way, e.g. a radio). live: it listens during the call and keeps nothing afterwards. keeps: it keeps what it hears (recordings, transcripts or notes).
transcribes boolean It turns what it hears into text (default false).
answers boolean People can call it (default true). False: the server refuses every call to it (unavailable).
calls boolean It may call people (its owner, or holders within a share; default false). False: the server refuses its call.address.
shares boolean It offers parts of itself to share (default false). False: the server refuses addon.shares that lists any share, and drops shares it offered before.
keys boolean It responds to keys pressed during a call (default false). False: apps hide the keypad and the server doesn’t pass call.key to it.
lines integer (≥1, ≤100) How many calls it takes at once (default 1). The server allows at most its own limit (10 by default); a call beyond that is busy. It places one call at a time either way.

Unpaired device asks for a pairing code to show on its display.

Field Type Required Notes
id string (len ≤64)
alg "ed25519" | "p256" Defaults to ed25519.
kind "kids" | "personal" | "lounge" | "addon" What the phone was set up as (first-run choice: a PhoneMode); whoever claims it can choose another mode. Defaults to kids. An add-on sends addon (with an ed25519 key).
publicKey string yes Raw public key, base64url: Ed25519 32 bytes, or P-256 uncompressed SEC1 point 65 bytes.

Answer to auth.challenge.

Field Type Required Notes
id string (len ≤64)
sig string (len 86) yes Signature over the raw nonce bytes: Ed25519 (64 bytes) or P-256 ECDSA/SHA-256 as r‖s (64 bytes).

Handset lifted (up) or returned to the cradle (down).

Field Type Required Notes
id string (len ≤64)
state "up" | "down" yes

Speed-dial button pressed (0-based).

Field Type Required Notes
id string (len ≤64)
index integer (≥0, ≤15) yes
page integer (≥1, ≤10) The page of keys on screen (config.pages, phones listing key-pages): index is then the digit’s slot on that page (0-9). Absent = page 1.

Periodic health report, forwarded to guardians.

Field Type Required Notes
id string (len ≤64)
battery { pct: integer (≥0, ≤100), charging: boolean }
rssi integer Wi-Fi signal strength in dBm.
uptimeS integer (≥0)
power { source: "default" | "1.5A" | "3A", reduced: boolean } USB power source; the Lounge phone needs a ≥1.5 A source for full features.

Lounge phone: a key pressed while lounge.challenge is showing (proximity proof), as a button index.

Field Type Required Notes
id string (len ≤64)
index integer (≥0, ≤15) yes

Lounge phone: asks for a fresh takeover code (answered with lounge.idle). Sent only while the code is on screen and the current one is missing or about to expire, so an unused phone costs the server nothing.

Field Type Required Notes
id string (len ≤64)

Lounge phone: MENU → Log out. Ends the session; the phone forgets everything.

Field Type Required Notes
id string (len ≤64)

Lounge phone: the person here toggles “open to chat” (ends with the session).

Field Type Required Notes
id string (len ≤64)
open boolean yes

MENU → Voicemail → Record: the phone wants to record its greeting (a kids’ phone: its own; a person’s own phone: theirs). Answered with greeting.ticket, or greeting.done not_allowed.

Field Type Required Notes
id string (len ≤64)
kind "name" | "custom" yes

MENU → Voicemail → Default: back to the spoken default greeting.

Field Type Required Notes
id string (len ≤64)

MENU → Voicemail → Play: the phone asks for its unheard messages (a person’s own phone: theirs, from every space they’re in). Answered with voicemail.items.

Field Type Required Notes
id string (len ≤64)

The phone played this message to the end: mark it heard (as the app’s Heard does).

Field Type Required Notes
id string (len ≤64) yes

MENU → Missed → Clear: mark the missed calls seen and forget the missed call backs (config.missed items with kind: "call" or callback): a person’s own phone clears its owner’s, on all their phones and apps; a household phone its own. Voicemail is cleared with voicemail.heard.

Field Type Required Notes
id string (len ≤64)

MENU → Missed → 1 (handset up): call this missed caller back, like dialing: a household phone only someone on its allow-list it may call (and its quiet hours), a person’s own phone as its owner. Answered like any dial (call.state); refused ones end denied or unreachable. Device feature missed-calls.

Field Type Required Notes
id string (len ≤64) yes A config.missed item’s id (a missed call or a missed call back).

Daily check-in (config.checkin): the person at the phone pressed I’m OK today. The server counts it for the day (the phone’s next config.checkin.done).

Field Type Required Notes
id string (len ≤64)

Labs daily check-in: nobody pressed I’m OK by by. The server tells the space’s guardians (an alert in their voicemail inbox), at most once a day per phone. Phones with config.checkin don’t send it: the server watches the time itself.

Field Type Required Notes
id string (len ≤64)
by string (^[0-2][0-9]:[0-5][0-9]$) yes The time it was due (local HH:MM).

Chime (config.chime): the phone posts its chime’s words as a notice to every phone of its space (15 minutes, at most once a minute). Answered by the notice itself; refused if the phone has no chime.

Field Type Required Notes
id string (len ≤64)

Notices (device feature notices): this phone dismissed the notice (a key while it shows). It isn’t shown on this phone again.

Field Type Required Notes
id string (len ≤64) yes

Accept an incoming call.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes

Leave (or decline) a call.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
lost true The call’s audio connection dropped and couldn’t be restored (reconnecting gave up): the call ends for everyone as error with the note The connection was lost, so the other side says so too.

Put an answered call on hold (hold: true) or take it back. The other side hears a soft tone (played by its own phone or app) and your audio stops; you may then place one more call (consult).

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
hold boolean yes

3-way: join both calls into one room. Both calls end with merged and room.state follows; keep the old audio playing until the room’s audio is connected, so nobody hears a gap.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes The call on hold.
with string (len ≤64) yes The call you’re in now (the consult call).

Transfer a call: blind (to) or attended (toCall).

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes The call to hand over (the other person in it is transferred).
to { button: integer (≥0, ≤15) } | { userId: string (len ≤64) } | { deviceId: string (len ≤64) } | { connectionId: string (len ≤64) } | { extension: string (^[0-9]{2,6}$) } | { groupId: string (len ≤64) } Blind transfer: ring this target for them; you leave at once. Allowed only if they could call the target themselves.
toCall string (len ≤64) Attended transfer: your other call (consult); the two other people are connected and you leave both calls.

Public lines: stop waiting in line (call.state.queue says whether it’s offered) and leave a voicemail instead. The call ends with call.state ended carrying a voicemail offer; ignored when the line doesn’t offer it.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes

Public lines (feature line-callback): stop waiting but keep your place; the line calls you back when it’s your turn (call.ringing with callback). Offered when call.state.queue.callback is set; the call ends with call.state ended carrying callback. Ignored otherwise.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes

Leave a room (a phone’s hook going down does the same).

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes

Mute or unmute. A muted participant’s audio isn’t forwarded by the relay.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
muted boolean yes
participant string (len ≤64) The host mutes someone else (they may unmute themselves). Default: yourself.

Host only: a locked room lets nobody new in.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
locked boolean yes

Host only: remove a participant (they get room.ended removed).

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
participant string (len ≤64) yes

Voice activity from your own microphone (send on changes only). Picks the active speakers a relay forwards in big rooms, and counts as activity for the idle rule.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
speaking boolean yes

Any interaction with the room (e.g. an answer to room.idle): you’re still here.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes

Rooms with a relay (media: sfu): the one peer connection to the relay. The client offers its microphone once and the server answers. Then the server adds the other participants’ audio (it offers, the client answers) and, when the active speakers change, asks the client to close a slot (the client stops it and offers; the server answers). The server decides whose audio you get.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
type "offer" | "answer" | "close" yes offer/answer: SDP. close (server → client): stop the transceivers with these mids, then send a new offer.
sdp string
mids string (len ≤8)[]

SDP offer/answer, relayed to the other party of a call (or a mesh room’s participant).

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
type "offer" | "answer" yes
sdp string yes
peer string (len ≤64) Rooms without a relay (peer-to-peer mesh): the other participant this is for (sent) or from (received); callId is then the room id.

Trickled ICE candidate.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
candidate string | null yes null signals end-of-candidates.
sdpMid string | null
sdpMLineIndex integer (≥0) | null
peer string (len ≤64) Mesh rooms: the other participant (see rtc.sdp).

Keep-alive.

Field Type Required Notes
id string (len ≤64)

Paired device must sign nonce with its private key.

Field Type Required Notes
id string (len ≤64)
nonce string (len ≥22) yes

Code the device displays; a guardian types it into the companion app.

Field Type Required Notes
id string (len ≤64)
code string (^\d{6}$) yes
expiresAt integer (≥0) yes

Pairing succeeded; device persists deviceId and reconnects with it.

Field Type Required Notes
id string (len ≤64)
deviceId string (len ≤64) yes
householdId string (len ≤64) yes

Sent after authentication and whenever guardians change settings.

Field Type Required Notes
id string (len ≤64)
buttons { index: integer (≥0, ≤15), label: string (len ≤24), addon?: true }[] yes Only mapped buttons are listed. The first page of keys (slots 0-9).
pages { page: integer (≥2, ≤10), buttons: { index: integer (≥0, ≤15), label: string (len ≤24), addon?: true }[] }[] Speed-dial pages after the first, in order (only to phones listing key-pages; only pages with keys). With the handset up, BACK shows the next page; a key then sends button with its page.
quiet boolean yes Quiet hours currently in effect.
quietUntil string (`^([01]\d 2[0-3]):[0-5]\d$`)
utcOffsetMin integer (≥-720, ≤840) The space’s current UTC offset in minutes (its quiet-hours time zone), so a phone with a clock knows local time: hardware phones install updates overnight.
now integer The server’s time (ms since the Unix epoch) when it sent this, so a phone can show a clock without asking a time server: hardware phones set their clock from it.
missed { from: string (len ≤24), callback?: true, kind?: "voicemail" | "call", at?: integer (≥0), count?: integer, id?: string (len ≤64) }[] Missed calls for the status display, newest first: unheard voicemails by sender, missed calls (phones listing missed-calls), and (a person’s own phone) missed call backs from public lines.
greeting { kind: "default" | "name" | "custom", canRecord: boolean } The phone’s voicemail greeting (absent on Lounge phones).
owner { mode: "kids" | "personal" | "lounge" | "left", space: string (len ≤64), person?: string (len ≤24) } Who the phone belongs to and how it is used, for the status strip’s trust line (“Kids · Smith home”, “Jesse’s phone”, “Lounge · Office”).
addon { name: string (len ≤30), address: string (len ≤300), owner: { name: string (len ≤64), address: string (len ≤300) } } Sent to an add-on only (instead of owner): who it is and whose account it belongs to.
checkin { by: string (`^([01]\d 2[0-3]):[0-5]\d$), done: boolean, alerted?: true` }
chime { text: string (len ≤40) } This phone may post a chime to every phone of its space (notice.chime).
intercom { room: string (len ≤24), key: string (len 43) } House intercom on for this phone: it calls the space’s other phones with it on, on the same local network, with no server in between (hardware phones).
houseLine boolean Lounge phone with nobody signed in: buttons are the space’s house-line keys, and pressing one calls as the space (off unless the space turns it on).
here { name: string (len ≤24), where: string (len ≤24) }[] Lounge phone with nobody signed in, when the space turns on “who’s here”: people signed in at its other Lounge phones who are open to chat.
extensions boolean The phone’s space is a team or org with extensions: MENU offers Dial extension (call.extension).
hold true A kids’ phone with Ask me first started running firmware that isn’t an official update: it waits for a guardian’s OK. Show ASK A GROWN-UP (also off the hook); nobody can call it and it makes no calls (buttons is empty, no missed, every key is refused) until a guardian allows the new firmware in the app.
server { software: string (len ≤64), protocol: { min: integer, max: integer }, features: string (len ≤32)[] } What the server speaks (protocol range, features), so a phone can leave out what it doesn’t offer.
update { minProtocol: integer, message: string (len ≤32) } The phone’s protocol is older than the server accepts: show message, don’t treat the phone as signed in, and retry rarely (the server closes the connection next). Sent before authentication, with no other settings.

Lounge phone: current takeover nonce. Show a QR code for <server>/lounge#<deviceId>.<nonce>. Single use; sent on connect, after each use or failed proof, and on lounge.refresh. Expired nonces are refused.

Field Type Required Notes
id string (len ≤64)
nonce string (len ≥16, len ≤64) yes
expiresAt integer (≥0) yes

Lounge phone: someone scanned the code. Flash this key; they must press it on the phone before expiresAt.

Field Type Required Notes
id string (len ≤64)
index integer (≥0, ≤15) yes Button index of the key to flash.
expiresAt integer (≥0) yes

Lounge phone: taken over by name (show “Hi ”). Their speed-dial arrives as config.

Field Type Required Notes
id string (len ≤64)
name string (len ≤24) yes
openToChat boolean yes

Lounge phone: the session is over. Forget everything about the person (names, speed-dial, call history) and show the takeover code again.

Field Type Required Notes
id string (len ≤64)
reason "logout" | "left" | "idle" | "replaced" | "removed" | "offline" yes logout = MENU → Log out on the phone; left = Leave in the app; idle = the space’s session length ran out (idle minutes, or end of day); replaced = a new takeover; removed = the person was removed; offline = the phone disconnected.

Go ahead: record up to maxMs (name 3 s, greeting 30 s) and POST /api/vm/greeting?ticket=&durationMs= with a raw audio/* body. Single use, expires in 10 minutes.

Field Type Required Notes
id string (len ≤64)
kind "name" | "custom" yes
ticket string (len ≥16, len ≤128) yes
maxMs integer yes

The phone asked to record (greeting.begin) and may not, or its greeting was reset (greeting.reset). A recorded greeting is confirmed by the upload’s HTTP 201.

Field Type Required Notes
id string (len ≤64)
result "reset" | "not_allowed" yes
kind "default" | "name" | "custom" yes The greeting callers hear now.

The answer to voicemail.fetch. Empty for phones that don’t play messages (a kids’ phone: a grown-up listens in the app).

Field Type Required Notes
id string (len ≤64)
items { id: string (len ≤64), from: string (len ≤24), at: integer, durationMs: integer (≥0), ticket?: string (len ≥16, len ≤128) }[] yes Unheard messages, oldest first.

The phone must wipe itself: forget its device id, owner and settings, delete its device key and make a new one, then show “Set me up” again (Wi-Fi is kept so it can be claimed again; a factory reset clears that too). Sent to a removed phone that is connected, or when it next connects and proves it holds the removed key.

Field Type Required Notes
id string (len ≤64)
reason "removed" yes removed = its owner removed it in the app (the server has forgotten its key).

Notices (device feature notices): a short notice for the phones of a space, posted by its owner, admins or guardians. Show it with a chime (no chime during quiet hours); lifting the handset plays its audio; a key dismisses it (notice.dismiss). Sent again with the same id when its audio is added (no second chime). The newest one replaces any earlier.

Field Type Required Notes
id string (len ≤64) yes
lines string (len ≤16)[] yes One or two lines of at most 16 characters (“BUS IS HERE”).
from string (len ≤24) yes Who posted it.
at integer yes When it was posted (ms since the Unix epoch).
expiresAt integer yes When it goes away by itself (notice.cleared follows).
audio { token: string (len ≥16, len ≤128), durationMs: integer (≥0) } A recorded message: GET /api/notices/<id>/audio?token= returns it (8 kHz 16-bit mono WAV) while the notice lasts. Play it when the handset is lifted.

The notice expired or was taken down: stop showing it.

Field Type Required Notes
id string (len ≤64) yes

Incoming call; device rings until answered, hung up, or ended.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
from { label: string (len ≤24), address?: string (len ≤300), kind?: "addon", share?: string (len ≤40) } yes
share { id: string (^[a-z0-9-]{1,30}$), label: string (len ≤40), grantId: string (len ≤64) } To an add-on only: the call came in through this share (handle+name+share@host); keep it inside that scope. Absent = its owner is calling.
line { name: string (len ≤64), description?: string (len ≤140), address?: string (len ≤300) } The call is for a public line you answer for.
waiting true Call waiting: you’re in a call and this one rings softly (a beep in the earpiece, a banner). call.answer takes it and puts your current call on hold (call.state active hold: you); call.hangup declines it; left alone it goes to voicemail after the usual ring time. Only to apps, and to phones listing call-waiting.
callback { name: string (len ≤64), description?: string (len ≤140), address?: string (len ≤300) } A public line you asked to call you back: it’s your turn. Answer, and your call to the line goes on as a new call (call.state ended with transfer, you’re its caller); hang up to let it go.

Call progress update.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
state "requesting" | "ringing" | "connecting" | "active" | "ended" yes Lifecycle of a call as seen by one participant.
reason "hangup" | "declined" | "busy" | "denied" | "voicemail" | "timeout" | "unreachable" | "unavailable" | "error" Present when state is ended.
note string (len ≤200) With ended: the server’s explanation in words, when it has one (e.g. a fair-use allowance reached). Sign in at this phone to call a public line (reason denied): a Lounge phone with nobody signed in tried a public line; phones show SIGN IN TO CALL.
voicemail { ticket: string (len ≥16, len ≤128), name: string (len ≤24), maxMs: integer, prompts: "name" | "greeting" | "vm.person" | "vm.cant_take" | "vm.leave_message" | "vm.tone" | "vm.sent" | "vm.not_sent" | "greet.say_name" | "greet.say_greeting" | "greet.saved" | "greet.not_saved" | "greet.default" | "greet.not_allowed"[] } With ended, to the caller only: the call went unanswered and a message may be left.
hold "you" | "them" With active: you = you put this call on hold; them = the other side did (play the soft hold.tone). Absent = not on hold.
merged { roomId: string (len ≤64) } With ended (reason hangup): the call became part of a room (3-way). Keep its audio until the room’s is connected; room.state follows.
transfer { callId: string (len ≤64), ringing: boolean, offerer: boolean } With ended (reason hangup): you were transferred; your call continues as callId (you’re its caller when ringing).
recording { by: string (len ≤64), ticket?: string (len ≥16, len ≤128), maxMs?: integer } With active: the call is recorded (sent once, when it starts; it stays on until the call ends). Firmware drives the recording light (the status LED on the minimal board) from it.
queue { position?: integer (≥1), size?: integer (≥1), voicemail?: boolean, callback?: boolean } With ringing: nobody on the public line is free, and you wait in line. Updated as the line moves; a ringing without queue means the line is ringing for you again.
callback { until: integer } With ended (reason hangup): you asked to be called back and keep your place in line.
line { name: string (len ≤64), description?: string (len ≤140), address?: string (len ≤300) } Calling a public line: its name (and description), with the first ringing.

Server feature addons: a key pressed during an answered call with an add-on (0–9, *, #). Sent by an app; the server passes it to the add-on (on another server too) and drops it for anyone else.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
key string (^[0-9*#]$) yes

Server feature addon-shares, to an add-on: its owner asks for a new shared scope (“Ask it for a new share…”). Make it, send addon.shares with it, then addon.share.created; or addon.share.refused. Unanswered after a minute, the request lapses.

Field Type Required Notes
id string (len ≤64)
requestId string (len ≤64) yes
label string (len ≤40) yes What its owner called it.
by string (len ≤300) yes Its owner’s address (handle@host).
with string (len ≤300)[] yes The people to give it to, by address (handle@host).

To an add-on: its owner (or someone holding one of its shares) pressed Test connection. Answer addon.test.ok with the same testId at once; no call is placed. Unanswered within 5 seconds, the test says it didn’t answer.

Field Type Required Notes
id string (len ≤64)
testId string (len ≤64) yes

ICE servers for this call; precedes any rtc.sdp.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
iceServers { urls: string | string[], username?: string, credential?: string }[] yes

SDP offer/answer, relayed to the other party of a call (or a mesh room’s participant).

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
type "offer" | "answer" yes
sdp string yes
peer string (len ≤64) Rooms without a relay (peer-to-peer mesh): the other participant this is for (sent) or from (received); callId is then the room id.

Trickled ICE candidate.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
candidate string | null yes null signals end-of-candidates.
sdpMid string | null
sdpMLineIndex integer (≥0) | null
peer string (len ≤64) Mesh rooms: the other participant (see rtc.sdp).

You’re in a room: who’s there and how its audio travels. Sent on every change.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
name string (len ≤40) yes
kind "party" | "phone" | "call" yes party = a space’s always-open party line; phone = a named room with an address; call = a 3-way call made by merging.
address string (len ≤300) phone rooms: name@host.
you string (len ≤64) yes Your participant id.
locked boolean yes
media "sfu" | "mesh" | "livekit" yes mesh = peer to peer (≤ 4 people, rtc.* with peer, end-to-end encrypted); sfu = through the room owner’s relay (room.media); livekit = through the owner’s LiveKit server (livekit). Relayed rooms are encrypted in transit, not end to end (the relay could hear them); end-to-end room encryption (SFrame) is planned.
e2ee boolean yes Whether only the participants can hear the audio.
participants { id: string (len ≤64), name: string (len ≤24), muted: boolean, speaking?: boolean, host?: boolean, remote?: string (len ≤260) }[] yes
forward string (len ≤64)[] Relayed rooms: whose audio the relay sends you now (top 3 speakers in rooms of more than 4).
livekit { url: string (len ≤300), token: string (len ≤2048) } media: livekit: where to connect and your join token (for you only).
note string (len ≤200)
recording { by: string (len ≤64), ticket?: string (len ≥16, len ≤128), maxMs?: integer } The room is recorded by its space (see RecordingNotice); ticket only to the participant who records.

Rooms with a relay (media: sfu): the one peer connection to the relay. The client offers its microphone once and the server answers. Then the server adds the other participants’ audio (it offers, the client answers) and, when the active speakers change, asks the client to close a slot (the client stops it and offers; the server answers). The server decides whose audio you get.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
type "offer" | "answer" | "close" yes offer/answer: SDP. close (server → client): stop the transceivers with these mids, then send a new offer.
sdp string
mids string (len ≤8)[]

10 minutes of silence and no interaction: you’ll be dropped at dropAt unless you speak or interact (room.here). Phones play prompt room.idle. With closing, the room closes at dropAt.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
dropAt integer (≥0) yes
closing boolean The room’s hours end at dropAt (a party line with hours): it closes for everyone then, whatever you do.

You’re out of the room (or weren’t let in).

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) Absent when a join by address was refused before a room was known.
reason "left" | "removed" | "idle" | "closed" | "denied" | "locked" | "full" | "unreachable" | "busy" | "error" yes left = you left; removed = the host removed you; idle = 10 minutes of silence and no interaction after a warning; closed = the room went away; denied = not allowed (default deny) or over the fair-use allowance; locked = the host locked it; full = no room (a relay-less room holds 4); unreachable = its server can’t be reached; busy = you’re in a call.
note string (len ≤200)

Request failed. Connection stays open unless code is unauthorized.

Field Type Required Notes
id string (len ≤64)
code "bad_message" | "unsupported_version" | "unauthorized" | "not_found" | "rate_limited" | "internal" yes
message string (len ≤256) yes
ref string (len ≤64) id of the message that caused the error.
unsupported string (len ≤64) With bad_message: the message type the server doesn’t know (a newer client’s message). The connection stays open.
server { software: string (len ≤64), protocol: { min: integer, max: integer }, features: string (len ≤32)[] } With unsupported_version: the versions the server accepts.

Keep-alive reply.

Field Type Required Notes
id string (len ≤64)

First message from a companion app.

Field Type Required Notes
id string (len ≤64)
proto integer yes
token string (len ≥16, len ≤512) yes
household string (len ≤64)

Team/org spaces: dial an extension of your space (a member, a phone, a room or a ring group). Phones: MENU → Dial extension, the digits, then MENU. Refused (denied) in homes and for unknown numbers; a ring group that’s closed follows its after-hours action.

Field Type Required Notes
id string (len ≤64)
number string (^[0-9]{2,6}$) yes

Companion app calls a device.

Field Type Required Notes
id string (len ≤64)
deviceId string (len ≤64) yes

Companion app calls another member of the same server, app to app. Refused unless they’re online and available.

Field Type Required Notes
id string (len ≤64)
userId string (len ≤64) yes

Call someone you’re connected with (another household or another server). Their server decides: an active connection, their availability and busy state apply.

Field Type Required Notes
id string (len ≤64)
connectionId string (len ≤64) yes

Call a household phone that a connection’s guardian put you on the allow-list of (GET /api/connections lists them as phones). Its own allow-list and quiet hours decide.

Field Type Required Notes
id string (len ≤64)
connectionId string (len ≤64) yes
deviceId string (len ≤64) yes

Call a public line (a team’s or organization’s published ring group) on this server or another one; no connection needed. When nobody is free you may wait in line (call.state.queue). Kids’ phones never may. A line that doesn’t exist, is off, or doesn’t take your call answers ended unavailable.

Field Type Required Notes
id string (len ≤64)
address string (len ≥3, len ≤300) yes The line’s address, handle@host.

Server feature addons. Call by address. An add-on calls its owner, or (with share) someone who holds that share (people only); an app calls an add-on it may talk to: its own (handle+name@host), or a share of one that its owner gave them (handle+name+share@host). Phones use speed-dial keys instead. A refusal answers call.state ended with a note in plain words (core refusals.ts): someone who may not call it gets the same unavailable + You can't call that address whether it exists or not; its owner and holders hear why (unreachable + not connected, busy + on as many calls as it takes, no add-on by that name linked to your own handle, add-ons off), and fair use answers denied with its note.

Field Type Required Notes
id string (len ≤64)
address string (len ≥3, len ≤300) yes handle@host (a person), handle+name@host (an add-on) or handle+name+share@host (one of its shares), on this server or another one.
share string (^[a-z0-9-]{1,30}$) Server feature addon-shares, from an add-on only: call within this share of it. The callee must be its owner or hold the share; they see the share’s label with the add-on’s name.

Server feature addons: a key pressed during an answered call with an add-on (0–9, *, #). Sent by an app; the server passes it to the add-on (on another server too) and drops it for anyone else.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
key string (^[0-9*#]$) yes

Whether this person is taking app-to-app calls (persisted).

Field Type Required Notes
id string (len ≤64)
available boolean yes

Use a Lounge phone as yourself: deviceId and nonce from its QR code. The phone then asks for the key proof.

Field Type Required Notes
id string (len ≤64)
deviceId string (len ≤64) yes
nonce string (len ≥16, len ≤64) yes

End your session on a Lounge phone (guardians can end anyone’s).

Field Type Required Notes
id string (len ≤64)
deviceId string (len ≤64) yes

Accept an incoming call.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes

Leave (or decline) a call.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
lost true The call’s audio connection dropped and couldn’t be restored (reconnecting gave up): the call ends for everyone as error with the note The connection was lost, so the other side says so too.

Put an answered call on hold (hold: true) or take it back. The other side hears a soft tone (played by its own phone or app) and your audio stops; you may then place one more call (consult).

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
hold boolean yes

3-way: join both calls into one room. Both calls end with merged and room.state follows; keep the old audio playing until the room’s audio is connected, so nobody hears a gap.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes The call on hold.
with string (len ≤64) yes The call you’re in now (the consult call).

Transfer a call: blind (to) or attended (toCall).

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes The call to hand over (the other person in it is transferred).
to { button: integer (≥0, ≤15) } | { userId: string (len ≤64) } | { deviceId: string (len ≤64) } | { connectionId: string (len ≤64) } | { extension: string (^[0-9]{2,6}$) } | { groupId: string (len ≤64) } Blind transfer: ring this target for them; you leave at once. Allowed only if they could call the target themselves.
toCall string (len ≤64) Attended transfer: your other call (consult); the two other people are connected and you leave both calls.

Public lines: stop waiting in line (call.state.queue says whether it’s offered) and leave a voicemail instead. The call ends with call.state ended carrying a voicemail offer; ignored when the line doesn’t offer it.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes

Public lines (feature line-callback): stop waiting but keep your place; the line calls you back when it’s your turn (call.ringing with callback). Offered when call.state.queue.callback is set; the call ends with call.state ended carrying callback. Ignored otherwise.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes

Companion app: join a room. Answered with room.state (you’re in) or room.ended (refused). Phones join with a speed-dial key a guardian set up.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) A room of this space (party line or phone room).
address string (len ≥3, len ≤300) A phone room’s address name@host, on this server or another one.

Leave a room (a phone’s hook going down does the same).

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes

Mute or unmute. A muted participant’s audio isn’t forwarded by the relay.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
muted boolean yes
participant string (len ≤64) The host mutes someone else (they may unmute themselves). Default: yourself.

Host only: a locked room lets nobody new in.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
locked boolean yes

Host only: remove a participant (they get room.ended removed).

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
participant string (len ≤64) yes

Voice activity from your own microphone (send on changes only). Picks the active speakers a relay forwards in big rooms, and counts as activity for the idle rule.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
speaking boolean yes

Any interaction with the room (e.g. an answer to room.idle): you’re still here.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes

Rooms with a relay (media: sfu): the one peer connection to the relay. The client offers its microphone once and the server answers. Then the server adds the other participants’ audio (it offers, the client answers) and, when the active speakers change, asks the client to close a slot (the client stops it and offers; the server answers). The server decides whose audio you get.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
type "offer" | "answer" | "close" yes offer/answer: SDP. close (server → client): stop the transceivers with these mids, then send a new offer.
sdp string
mids string (len ≤8)[]

SDP offer/answer, relayed to the other party of a call (or a mesh room’s participant).

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
type "offer" | "answer" yes
sdp string yes
peer string (len ≤64) Rooms without a relay (peer-to-peer mesh): the other participant this is for (sent) or from (received); callId is then the room id.

Trickled ICE candidate.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
candidate string | null yes null signals end-of-candidates.
sdpMid string | null
sdpMLineIndex integer (≥0) | null
peer string (len ≤64) Mesh rooms: the other participant (see rtc.sdp).

Keep-alive.

Field Type Required Notes
id string (len ≤64)

Companion app authenticated.

Field Type Required Notes
id string (len ≤64)
userId string (len ≤64) yes
server { software: string (len ≤64), protocol: { min: integer, max: integer }, features: string (len ≤32)[] } What the server speaks (protocol range, features).

Your connections changed (a knock arrived, someone accepted or disconnected); reload them with GET /api/connections.

Field Type Required Notes
id string (len ≤64)

Sent to your other open apps when your account is signed in to in a way that doesn’t need your passkey; the session list (GET /api/sessions) shows how each session started.

Field Type Required Notes
id string (len ≤64)
kind "recovered" | "signin_link" yes recovered: someone signed in to your account with its recovery code; signin_link: with a sign-in link a guardian or admin made.
at integer (≥0) yes

Sent to a home’s guardians when one of its kids’ phones starts running firmware that isn’t an official update (built at home, or flashed over USB). Official updates only go in the audit trail.

Field Type Required Notes
id string (len ≤64)
deviceId string (len ≤64) yes
name string (len ≤24) yes The phone’s name.
project string (len ≤32) yes
version string (len ≤32) yes
held boolean yes Ask me first is on: the phone waits until a guardian allows it (its page).
at integer (≥0) yes When the phone first connected with it.

Progress of your takeover of a Lounge phone, and the end of your session there.

Field Type Required Notes
id string (len ≤64)
deviceId string (len ≤64) yes
step "press_key" | "started" | "failed" | "ended" yes
reason "expired" | "wrong_key" | "timeout" | "busy" | "not_found" | "logout" | "left" | "idle" | "replaced" | "removed" | "offline" Why a claim failed or a session ended.
expiresAt integer (≥0) With press_key: when the key proof runs out.
host string (len ≤260) Set when the Lounge phone is on another server (you’re a guest there).

Presence of another member of the server; sent on connect and on every change.

Field Type Required Notes
id string (len ≤64)
userId string (len ≤64) yes
online boolean yes Has at least one open companion session.
available boolean yes Taking app-to-app calls.
lounge { deviceId: string (len ≤64), label: string (len ≤24), openToChat: boolean } At a Lounge phone (calls to them ring there).

Presence and health of a device in the guardian’s household.

Field Type Required Notes
id string (len ≤64)
deviceId string (len ≤64) yes
online boolean yes
battery { pct: integer (≥0, ≤100), charging: boolean }
rssi integer Wi-Fi signal strength in dBm.
power { source: "default" | "1.5A" | "3A", reduced: boolean } USB power source; the Lounge phone needs a ≥1.5 A source for full features.
lastSeen integer (≥0) yes
lounge { userId: string (len ≤64), name: string (len ≤24), since: integer (≥0) } Lounge phone: who is using it right now.

A voicemail was left for a phone in the guardian’s household.

Field Type Required Notes
id string (len ≤64) yes
deviceId string (len ≤64) yes
from string (len ≤24) yes

A voicemail was left for you (your own inbox, or a shared box of a ring group you’re in: GET /api/voicemails).

Field Type Required Notes
id string (len ≤64) yes
from string (len ≤24) yes
box string (len ≤40) Left in a ring group’s shared box you’re in (its name).

Your missed calls changed (a new one, or they were cleared on a phone or another app): reload them with GET /api/missed.

Field Type Required Notes
id string (len ≤64)

Incoming call; device rings until answered, hung up, or ended.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
from { label: string (len ≤24), address?: string (len ≤300), kind?: "addon", share?: string (len ≤40) } yes
share { id: string (^[a-z0-9-]{1,30}$), label: string (len ≤40), grantId: string (len ≤64) } To an add-on only: the call came in through this share (handle+name+share@host); keep it inside that scope. Absent = its owner is calling.
line { name: string (len ≤64), description?: string (len ≤140), address?: string (len ≤300) } The call is for a public line you answer for.
waiting true Call waiting: you’re in a call and this one rings softly (a beep in the earpiece, a banner). call.answer takes it and puts your current call on hold (call.state active hold: you); call.hangup declines it; left alone it goes to voicemail after the usual ring time. Only to apps, and to phones listing call-waiting.
callback { name: string (len ≤64), description?: string (len ≤140), address?: string (len ≤300) } A public line you asked to call you back: it’s your turn. Answer, and your call to the line goes on as a new call (call.state ended with transfer, you’re its caller); hang up to let it go.

Call progress update.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
state "requesting" | "ringing" | "connecting" | "active" | "ended" yes Lifecycle of a call as seen by one participant.
reason "hangup" | "declined" | "busy" | "denied" | "voicemail" | "timeout" | "unreachable" | "unavailable" | "error" Present when state is ended.
note string (len ≤200) With ended: the server’s explanation in words, when it has one (e.g. a fair-use allowance reached). Sign in at this phone to call a public line (reason denied): a Lounge phone with nobody signed in tried a public line; phones show SIGN IN TO CALL.
voicemail { ticket: string (len ≥16, len ≤128), name: string (len ≤24), maxMs: integer, prompts: "name" | "greeting" | "vm.person" | "vm.cant_take" | "vm.leave_message" | "vm.tone" | "vm.sent" | "vm.not_sent" | "greet.say_name" | "greet.say_greeting" | "greet.saved" | "greet.not_saved" | "greet.default" | "greet.not_allowed"[] } With ended, to the caller only: the call went unanswered and a message may be left.
hold "you" | "them" With active: you = you put this call on hold; them = the other side did (play the soft hold.tone). Absent = not on hold.
merged { roomId: string (len ≤64) } With ended (reason hangup): the call became part of a room (3-way). Keep its audio until the room’s is connected; room.state follows.
transfer { callId: string (len ≤64), ringing: boolean, offerer: boolean } With ended (reason hangup): you were transferred; your call continues as callId (you’re its caller when ringing).
recording { by: string (len ≤64), ticket?: string (len ≥16, len ≤128), maxMs?: integer } With active: the call is recorded (sent once, when it starts; it stays on until the call ends). Firmware drives the recording light (the status LED on the minimal board) from it.
queue { position?: integer (≥1), size?: integer (≥1), voicemail?: boolean, callback?: boolean } With ringing: nobody on the public line is free, and you wait in line. Updated as the line moves; a ringing without queue means the line is ringing for you again.
callback { until: integer } With ended (reason hangup): you asked to be called back and keep your place in line.
line { name: string (len ≤64), description?: string (len ≤140), address?: string (len ≤300) } Calling a public line: its name (and description), with the first ringing.

ICE servers for this call; precedes any rtc.sdp.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
iceServers { urls: string | string[], username?: string, credential?: string }[] yes

SDP offer/answer, relayed to the other party of a call (or a mesh room’s participant).

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
type "offer" | "answer" yes
sdp string yes
peer string (len ≤64) Rooms without a relay (peer-to-peer mesh): the other participant this is for (sent) or from (received); callId is then the room id.

Trickled ICE candidate.

Field Type Required Notes
id string (len ≤64)
callId string (len ≤64) yes
candidate string | null yes null signals end-of-candidates.
sdpMid string | null
sdpMLineIndex integer (≥0) | null
peer string (len ≤64) Mesh rooms: the other participant (see rtc.sdp).

You’re in a room: who’s there and how its audio travels. Sent on every change.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
name string (len ≤40) yes
kind "party" | "phone" | "call" yes party = a space’s always-open party line; phone = a named room with an address; call = a 3-way call made by merging.
address string (len ≤300) phone rooms: name@host.
you string (len ≤64) yes Your participant id.
locked boolean yes
media "sfu" | "mesh" | "livekit" yes mesh = peer to peer (≤ 4 people, rtc.* with peer, end-to-end encrypted); sfu = through the room owner’s relay (room.media); livekit = through the owner’s LiveKit server (livekit). Relayed rooms are encrypted in transit, not end to end (the relay could hear them); end-to-end room encryption (SFrame) is planned.
e2ee boolean yes Whether only the participants can hear the audio.
participants { id: string (len ≤64), name: string (len ≤24), muted: boolean, speaking?: boolean, host?: boolean, remote?: string (len ≤260) }[] yes
forward string (len ≤64)[] Relayed rooms: whose audio the relay sends you now (top 3 speakers in rooms of more than 4).
livekit { url: string (len ≤300), token: string (len ≤2048) } media: livekit: where to connect and your join token (for you only).
note string (len ≤200)
recording { by: string (len ≤64), ticket?: string (len ≥16, len ≤128), maxMs?: integer } The room is recorded by its space (see RecordingNotice); ticket only to the participant who records.

Rooms with a relay (media: sfu): the one peer connection to the relay. The client offers its microphone once and the server answers. Then the server adds the other participants’ audio (it offers, the client answers) and, when the active speakers change, asks the client to close a slot (the client stops it and offers; the server answers). The server decides whose audio you get.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
type "offer" | "answer" | "close" yes offer/answer: SDP. close (server → client): stop the transceivers with these mids, then send a new offer.
sdp string
mids string (len ≤8)[]

10 minutes of silence and no interaction: you’ll be dropped at dropAt unless you speak or interact (room.here). Phones play prompt room.idle. With closing, the room closes at dropAt.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
dropAt integer (≥0) yes
closing boolean The room’s hours end at dropAt (a party line with hours): it closes for everyone then, whatever you do.

You’re out of the room (or weren’t let in).

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) Absent when a join by address was refused before a room was known.
reason "left" | "removed" | "idle" | "closed" | "denied" | "locked" | "full" | "unreachable" | "busy" | "error" yes left = you left; removed = the host removed you; idle = 10 minutes of silence and no interaction after a warning; closed = the room went away; denied = not allowed (default deny) or over the fair-use allowance; locked = the host locked it; full = no room (a relay-less room holds 4); unreachable = its server can’t be reached; busy = you’re in a call.
note string (len ≤200)

Someone came into or left one of your space’s rooms (“members see who’s in”).

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
people string (len ≤24)[] yes Who’s in the room now (names), for members who aren’t in it.

A room of your space with opening hours just opened (“party line open”): join it like any room. Sent once per opening to the members’ apps; phones with the room on a key get a notice.

Field Type Required Notes
id string (len ≤64)
roomId string (len ≤64) yes
name string (len ≤40) yes
closesAt integer (≥0) yes When its hours end.

Request failed. Connection stays open unless code is unauthorized.

Field Type Required Notes
id string (len ≤64)
code "bad_message" | "unsupported_version" | "unauthorized" | "not_found" | "rate_limited" | "internal" yes
message string (len ≤256) yes
ref string (len ≤64) id of the message that caused the error.
unsupported string (len ≤64) With bad_message: the message type the server doesn’t know (a newer client’s message). The connection stays open.
server { software: string (len ≤64), protocol: { min: integer, max: integer }, features: string (len ≤32)[] } With unsupported_version: the versions the server accepts.

Keep-alive reply.

Field Type Required Notes
id string (len ≤64)

Proudly supported by unsubscribe.llc · Sponsor the project