Try a call

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.

sh
npm i @rill/server @rill/client

Until 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.

ts
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)

ts
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).

ts
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.

ts
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

CallWhoWhat
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

ts
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.

Type to search…

↑↓ navigate open esc close