Your app
Create a call, mint a token, connect the browser.
Rill does not know your users. Your server creates a call and mints a token. The browser uses that token to join. Mute, hang-up, and “who is this person” stay in your app.
npm i @rill/server @rill/clientUntil a v* tag exists, use this repo’s sdk/ workspace. React: also @rill/react. This repo’s apps/web is the reference app: SvelteKit mints tokens with @rill/server; the browser joins with @rill/client. There is no @rill/svelte package.
The first-party demo is apps/web (docker compose up). To try the React SDK sample locally, run examples/node-token-server/mint.ts and paste a token into examples/react — that mint is a dev sample, not the product path.
Your server Browser
─────────── ───────
POST /v1/calls → { id, recording } Rill.connect({ url, token })
save id speaker plays remote.microphone
token({ id, publish }) call.camera.enable()
end() disconnect() leaves; end() kills the slot
Both people receive each other’s media. Mute is enable() / disable(). A third person is call_full. If Rill restarts, calls are gone — create again and mint new tokens.
Reference: @rill/server · @rill/client · @rill/react
Server
Mint tokens only on the server. The API secret never goes to the browser.
import { Rill } from "@rill/server";
const rill = new Rill({
url: "https://rill.example.com",
apiKey: process.env.RILL_API_KEY!,
apiSecret: process.env.RILL_API_SECRET!,
});
const call = await rill.calls.create();
const token = call.token({
id: "alice",
name: "Alice",
publish: { microphone: true, camera: true, screen: false, data: true },
ttl: "15m",
});
console.log(call.id, token);publish is microphone, camera, screen, and data — all four keys required. data gates send; receive always works. Persist call.id in your app. Mint later with the stored id: rill.calls.token(id, opts).
JWT wire stays compact: sub, rid, nam, can: { a, v, s, d }. SDKs never expose those names. Default TTL 15 minutes, max 24 hours.
Two tokens (join, then accept)
import { Rill } from "@rill/server";
const rill = new Rill({
url: "https://rill.example.com",
apiKey: process.env.RILL_API_KEY!,
apiSecret: process.env.RILL_API_SECRET!,
});
const call = await rill.calls.create();
// Persist call.id in your app. Mint the first token when they join.
const waiting = call.token({
id: "alice",
publish: { microphone: true, camera: true, screen: false, data: true },
});
// Mint the second token only after your app accepts.
const accepted = rill.calls.token(call.id, {
id: "bob",
publish: { microphone: true, camera: true, screen: true, data: true },
});
console.log(waiting, accepted);Browser
examples/react and examples/vanilla are SDK samples (not served by compose).
import { Rill } from "@rill/client";
const video = document.querySelector("video")!;
async function mintFreshToken(): Promise<string> {
return "paste-jwt";
}
const call = await Rill.connect({
url: "https://rill.example.com",
token: "paste-jwt",
onToken: () => mintFreshToken(),
});
call.on("remote", (remote) => {
video.srcObject = remote?.camera ?? null;
});
call.on("error", ({ code }) => console.error(code));
call.on("ended", () => console.log("call ended"));
await call.microphone.enable();
await call.camera.enable();Do not attach remote.microphone to a second <audio> — the SDK speaker already plays it. Pass speaker: false to opt out. Do not stash join tokens across a destroyed page. Mint a fresh one. If the signaling socket drops but media is up, the SDK reconnects the socket only. The server holds media for RILL_SIGNAL_RECONNECT_GRACE (default 15s).
Data
Labels are ^[a-z][a-z0-9._-]{0,31}$. Max 8 open labels (not counting _rill). Max message 128 KiB — str0m cannot live-relay more than that across all labels on the association. Chunk large rrweb full snapshots in your app (60 KiB is a safe size and matches the recorder’s UDP chunks). Incremental mutations of a few KiB stream fine. send queues while the local channel is connecting (cap 32 per label) and resolves at dc.send(). A missing peer is not an SDK error; the SFU drops live relay. The first message after a label opens can drop while the peer channel is pairing — retry or send a tiny ping. The app owns the schema.
Streaming cobrowse (rrweb) and recording it: use a label such as rrweb. Each complete send is one JSONL line { t, encoding, payload } next to that participant’s MP4s. Rill does not parse rrweb. Keep chunks ≤128 KiB so live relay and the recording fork see the same messages. Labels starting with _ are SDK-internal and are not recorded.
await call.send("chat", "hello");
call.on("data", (msg) => {
if (msg.label === "chat" && typeof msg.data === "string") {
console.log(msg.data);
}
});React: do not subscribe in useCall; call call.on("data") yourself.
Hang up
| Call | Who | What |
|---|---|---|
call.disconnect() | Browser (@rill/client) | This page leaves. The other person stays. The slot remains. |
call.end() | Server (@rill/server) | Destroys the call. Both browsers get ended. |
Kick one participant with rill.calls.kick(callId, participantId) without ending the slot.
React
import { useCall } from "@rill/react";
export function Stage({ url, token }: { url: string; token: string }) {
const call = useCall({ url, token });
if (!call) {
return null;
}
return call.state;
}Bind <video> to call.camera.stream and call.remote?.camera. There is no provider and no video component.
HTTP
Prefer @rill/server for tokens. HTTP mint (POST /v1/calls/:id/tokens) is for CLI and curl. Full route list: HTTP.
--dev allows unauthenticated GET/DELETE. Production does not.
Webhooks
Set RILL_WEBHOOK_URL. POST JSON with X-Rill-Timestamp (unix seconds) and X-Rill-Signature (HMAC of timestamp.body with the API secret). Payload catalog: Webhooks.