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.
Versions and compatibility
Section titled “Versions and compatibility”- 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.
hellocarriesproto(the protocol version the phone speaks) andfw(its firmware version; semver on hardware, e.g.0.6.0);app.hellocarriesproto. - What the server says. It accepts protocol version 1
(
MIN_PROTOCOL_VERSION..PROTOCOL_VERSION): whenPROTOCOL_VERSIONis bumped, the previous version stays accepted for at least 12 months.config.serverandapp.ready.servertell 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
configwithupdate(showmessage, e.g.UPDATE NEEDED, and don’t treat the phone as signed in), thenerrorunsupported_versionwithserver, and the connection closes (4400). A client newer than the server gets the sameerror(the message says the server is older) and may reconnect speaking a version inserver.protocolif 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).
Device → server
Section titled “Device → server”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. |
call.extension
Section titled “call.extension”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 |
call.address
Section titled “call.address”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. |
call.key
Section titled “call.key”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 |
addon.shares
Section titled “addon.shares”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. |
addon.share.created
Section titled “addon.share.created”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 |
addon.share.refused
Section titled “addon.share.refused”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. |
addon.test.ok
Section titled “addon.test.ok”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. |
addon.about
Section titled “addon.about”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. |
pair.begin
Section titled “pair.begin”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. |
auth.proof
Section titled “auth.proof”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 |
button
Section titled “button”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. |
status
Section titled “status”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.press
Section titled “lounge.press”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.refresh
Section titled “lounge.refresh”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.leave
Section titled “lounge.leave”Lounge phone: MENU → Log out. Ends the session; the phone forgets everything.
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) |
lounge.chat
Section titled “lounge.chat”Lounge phone: the person here toggles “open to chat” (ends with the session).
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) | ||
open |
boolean | yes |
greeting.begin
Section titled “greeting.begin”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 |
greeting.reset
Section titled “greeting.reset”MENU → Voicemail → Default: back to the spoken default greeting.
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) |
voicemail.fetch
Section titled “voicemail.fetch”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) |
voicemail.heard
Section titled “voicemail.heard”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 |
missed.clear
Section titled “missed.clear”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) |
call.back
Section titled “call.back”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). |
checkin.ok
Section titled “checkin.ok”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) |
checkin.missed
Section titled “checkin.missed”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). |
notice.chime
Section titled “notice.chime”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) |
notice.dismiss
Section titled “notice.dismiss”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 |
call.answer
Section titled “call.answer”Accept an incoming call.
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) | ||
callId |
string (len ≤64) | yes |
call.hangup
Section titled “call.hangup”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. |
call.hold
Section titled “call.hold”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 |
call.merge
Section titled “call.merge”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). |
call.transfer
Section titled “call.transfer”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. |
call.voicemail
Section titled “call.voicemail”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 |
call.callback
Section titled “call.callback”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 |
room.leave
Section titled “room.leave”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 |
room.mute
Section titled “room.mute”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. |
room.lock
Section titled “room.lock”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 |
room.remove
Section titled “room.remove”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 |
room.talk
Section titled “room.talk”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 |
room.here
Section titled “room.here”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 |
room.media
Section titled “room.media”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)[] |
rtc.sdp
Section titled “rtc.sdp”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. |
rtc.ice
Section titled “rtc.ice”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) |
Server → device
Section titled “Server → device”auth.challenge
Section titled “auth.challenge”Paired device must sign nonce with its private key.
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) | ||
nonce |
string (len ≥22) | yes |
pair.code
Section titled “pair.code”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 |
pair.done
Section titled “pair.done”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 |
config
Section titled “config”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.idle
Section titled “lounge.idle”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.challenge
Section titled “lounge.challenge”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.session
Section titled “lounge.session”Lounge phone: taken over by name (show “Hi config.
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) | ||
name |
string (len ≤24) | yes | |
openToChat |
boolean | yes |
lounge.ended
Section titled “lounge.ended”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. |
greeting.ticket
Section titled “greeting.ticket”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 |
greeting.done
Section titled “greeting.done”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. |
voicemail.items
Section titled “voicemail.items”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). |
notice
Section titled “notice”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. |
notice.cleared
Section titled “notice.cleared”The notice expired or was taken down: stop showing it.
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) | yes |
call.ringing
Section titled “call.ringing”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.state
Section titled “call.state”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. |
call.key
Section titled “call.key”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 |
addon.share.request
Section titled “addon.share.request”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). |
addon.test
Section titled “addon.test”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 |
rtc.config
Section titled “rtc.config”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 |
rtc.sdp
Section titled “rtc.sdp”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. |
rtc.ice
Section titled “rtc.ice”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). |
room.state
Section titled “room.state”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. |
room.media
Section titled “room.media”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)[] |
room.idle
Section titled “room.idle”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. |
room.ended
Section titled “room.ended”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) |
App → server
Section titled “App → server”app.hello
Section titled “app.hello”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) |
call.extension
Section titled “call.extension”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 |
call.dial
Section titled “call.dial”Companion app calls a device.
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) | ||
deviceId |
string (len ≤64) | yes |
call.user
Section titled “call.user”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.connection
Section titled “call.connection”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.phone
Section titled “call.phone”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.line
Section titled “call.line”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. |
call.address
Section titled “call.address”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. |
call.key
Section titled “call.key”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 |
presence.set
Section titled “presence.set”Whether this person is taking app-to-app calls (persisted).
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) | ||
available |
boolean | yes |
lounge.claim
Section titled “lounge.claim”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 |
lounge.leave
Section titled “lounge.leave”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 |
call.answer
Section titled “call.answer”Accept an incoming call.
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) | ||
callId |
string (len ≤64) | yes |
call.hangup
Section titled “call.hangup”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. |
call.hold
Section titled “call.hold”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 |
call.merge
Section titled “call.merge”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). |
call.transfer
Section titled “call.transfer”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. |
call.voicemail
Section titled “call.voicemail”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 |
call.callback
Section titled “call.callback”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 |
room.join
Section titled “room.join”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. |
room.leave
Section titled “room.leave”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 |
room.mute
Section titled “room.mute”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. |
room.lock
Section titled “room.lock”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 |
room.remove
Section titled “room.remove”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 |
room.talk
Section titled “room.talk”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 |
room.here
Section titled “room.here”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 |
room.media
Section titled “room.media”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)[] |
rtc.sdp
Section titled “rtc.sdp”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. |
rtc.ice
Section titled “rtc.ice”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) |
Server → app
Section titled “Server → app”app.ready
Section titled “app.ready”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). |
connections.changed
Section titled “connections.changed”Your connections changed (a knock arrived, someone accepted or disconnected); reload them with GET /api/connections.
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) |
account.notice
Section titled “account.notice”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 |
phone.firmware
Section titled “phone.firmware”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. |
lounge.progress
Section titled “lounge.progress”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). |
member.status
Section titled “member.status”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). |
device.status
Section titled “device.status”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. |
voicemail.new
Section titled “voicemail.new”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 |
voicemail.inbox
Section titled “voicemail.inbox”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). |
missed.changed
Section titled “missed.changed”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) |
call.ringing
Section titled “call.ringing”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.state
Section titled “call.state”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. |
rtc.config
Section titled “rtc.config”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 |
rtc.sdp
Section titled “rtc.sdp”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. |
rtc.ice
Section titled “rtc.ice”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). |
room.state
Section titled “room.state”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. |
room.media
Section titled “room.media”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)[] |
room.idle
Section titled “room.idle”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. |
room.ended
Section titled “room.ended”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) |
rooms.changed
Section titled “rooms.changed”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. |
room.opened
Section titled “room.opened”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) |