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; unknown t values are rejected with bad_message.
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.
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" |
yes | |
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 strip (standard), seg14/oled I2C modules (cheaper option), none (Kids Lite: printed key labels, LEDs and voice). |
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. |
|
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 |
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. |
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 |
rtc.sdp
Section titled “rtc.sdp”SDP offer/answer. Relayed to the peer (p2p) or to the SFU (cloudflare-realtime).
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) | ||
callId |
string (len ≤64) | yes | |
type |
"offer" | "answer" |
yes | |
sdp |
string | yes |
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 |
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) }[] | yes | Only mapped buttons are listed. |
quiet |
boolean | yes | Quiet hours currently in effect. |
quietUntil |
string (`^([01]\d | 2[0-3]):[0-5]\d$`) | |
missed |
{ from: string (len ≤24) }[] | Unheard voicemails, newest first, for the status display. |
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) } | yes |
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. |
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 peer (p2p) or to the SFU (cloudflare-realtime).
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) | ||
callId |
string (len ≤64) | yes | |
type |
"offer" | "answer" |
yes | |
sdp |
string | yes |
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 |
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. |
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 |
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 |
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 |
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 |
rtc.sdp
Section titled “rtc.sdp”SDP offer/answer. Relayed to the peer (p2p) or to the SFU (cloudflare-realtime).
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) | ||
callId |
string (len ≤64) | yes | |
type |
"offer" | "answer" |
yes | |
sdp |
string | yes |
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 |
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 |
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. |
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 |
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 |
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) } | yes |
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. |
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 peer (p2p) or to the SFU (cloudflare-realtime).
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) | ||
callId |
string (len ≤64) | yes | |
type |
"offer" | "answer" |
yes | |
sdp |
string | yes |
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 |
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. |
Keep-alive reply.
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≤64) |