Audio & video calls
Multi-party calling through a Selective Forwarding Unit. Each participant uploads one stream no matter how many people are in the room, and no client ever learns another's IP address.
The model #
- Calls happen in rooms. There is no separate call object to create — you open the RTC socket for a room you are a member of, and you are in the call.
- Same token as chat. The bundle you already minted carries
rtc_websocket_urlandice_servers. It must grantrtc:subscribeto receive andrtc:publishto send media. - Signalling on the socket, media on UDP. Only SDP and ICE candidates ride the WebSocket; RTP goes directly between the client and the SFU.
- No client-side register frame. The server registers your connection from the
verified token. A
registerframe from a client is rejected by design — identity is never taken from client input.
Connecting #
const pc = new RTCPeerConnection({ iceServers: bundle.ice_servers });
const ws = new WebSocket(bundle.rtc_websocket_url,
["chatbox", "bearer." + bundle.access_token]);
Use the ice_servers array exactly as handed to you. The TURN credentials inside are
per-session and time-limited — hard-coding them will work in the office and fail in the field.
Publishing #
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
stream.getTracks().forEach((t) => pc.addTrack(t, stream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({ cmd: "offer", sdp: pc.localDescription }));
pc.onicecandidate = (e) => {
if (e.candidate) ws.send(JSON.stringify({ cmd: "candidate", candidate: e.candidate }));
}; Receiving, and the re-offer loop #
This is the part integrations most often get wrong. When another participant publishes, the SFU
sends you a fresh offer; you must answer it and echo its request_id, or the
answer will not be matched to the negotiation.
ws.onmessage = async (ev) => {
const f = JSON.parse(ev.data);
if (f.event) return; // {"event":"joined"} | {"event":"error","reason":…}
if (f.type === "answer") { // answer to the offer we sent
await pc.setRemoteDescription(f);
return;
}
if (f.type === "offer") { // SFU re-offer: someone else published
await pc.setRemoteDescription(f);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
ws.send(JSON.stringify({ cmd: "answer", sdp: answer, request_id: f.request_id }));
}
};
pc.ontrack = (e) => attachToUi(e.streams[0]); // remote media arrives here Client → server (cmd) | Purpose |
|---|---|
offer | Publish or renegotiate; sdp is the RTCSessionDescription. |
answer | Answer an SFU re-offer; echo request_id. |
candidate | Trickle ICE; candidate is an RTCIceCandidateInit. |
leave | Leave cleanly. Dropping the socket also works, just less promptly. |
Screen share #
A second video track, not a mode — so a participant can send camera and screen simultaneously and subscribers receive them as independent streams.
const display = await navigator.mediaDevices.getDisplayMedia({ video: true });
pc.addTrack(display.getVideoTracks()[0], display);
// renegotiate: createOffer → setLocalDescription → cmd:"offer" Multi-device #
Pass a distinct device_id when minting each token. Call legs are keyed by
(tenant, room, principal, device), so without it a second device replaces the first
instead of joining alongside it.
Knowing a call started #
A room emits call.started when its first participant joins and call.ended
when the last one leaves. Both carry the same call_id.
{ "v": 1, "event_id": 0, "type": "call.started", "room_id": "0f5c…",
"payload": { "call_id": "5a3e…", "initiator": "alice" },
"occurred_at": "2026-09-10T11:04:22Z" } These reach you three ways, which together cover every device state:
- Chat socket — for a device that is online but not in the call.
- Webhook — for your app-server, e.g. to trigger your own call UI or logging.
- Push — for a backgrounded or killed device. Only
call.startedpushes; you are not notified that a call ended.
call_id. A lone caller whose connection blips can re-fire
the pair. The push path is already debounced by a per-room cooldown; for webhook and socket
consumers, call_id is what makes it idempotent. Note also that call events are not
catch-up replayable — a client joining late should read the live roster from the room status
endpoint instead.
What is bounded #
- Media threads are fixed; a room is pinned to one media loop, so a room's call is one core's work.
- Per-room participant cap is configured at deploy time (64 by default).
- Keyframes are requested and relayed, so a late subscriber gets video quickly.
- Call quality is measured per subscriber (bandwidth estimate, loss fraction) and exported as aggregate metrics — reported, not yet acted upon.