Build it

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_url and ice_servers. It must grant rtc:subscribe to receive and rtc:publish to 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 register frame 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
offerPublish or renegotiate; sdp is the RTCSessionDescription.
answerAnswer an SFU re-offer; echo request_id.
candidateTrickle ICE; candidate is an RTCIceCandidateInit.
leaveLeave 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.started pushes; you are not notified that a call ended.
Dedupe on 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.