Get started
You make a project, a link and a key in Console, write a browser page that connects with the link, publishes its camera and microphone and plays everyone else's, and open it in two browsers. Then you add a backend that signs tokens, for when your app decides who someone is and what they may do.
The hosted service is not open yet. Its Console and Relay do not exist, so these steps cannot be followed there yet. Before the hosted service opens shows how to follow them on your own machine.
1. Sign in and create a project
Open Console's sign-in screen and sign in with GitHub. On the Projects screen, create a project by name. Its Project screen shows the project's ID and the Relay URL your apps connect to.
2. Make a link
A link lets whoever holds its ID connect, with no backend, with the root and rights it was made with. On the Links screen, create one with:
| Field | Value | Meaning |
|---|---|---|
| Root | empty | The whole project. A path such as rooms/42 limits the link to what lies beneath it |
| Publish | {id}/** |
Each person publishes only under their own ID: {id} stands for the joiner's ID |
| Subscribe | ** |
Everyone reads everything under the root |
Copy the link's ID (lk_…). Anyone who has it can connect with these rights until you delete the link, so share it only as widely as your page should be open.
3. A page that connects with the link
The SDK is the package tablebox.io. It is not on a package registry yet: build it from the Tablebox repository with npm run build (the workspace packages/sdk).
The browser samples take the Relay URL from relay.ts. Put there the URL the Project screen shows or, before the hosted service opens, a Relay of the local stack with its certificate's SHA-256:
// The Relay URL; for a local Relay with a self-signed certificate, also its SHA-256 (Get started).
export const relay = { url: 'https://relay.example/', serverCertificateHashes: [] as string[] };// Get started: connect with the link in the page's address (page.html#link={id}), publish the
// camera and microphone at {identity}/camera.hang, and play everyone else's.
import { connect, moq, publish, watch } from 'tablebox.io';
import { relay } from './relay.ts';
const link = new URLSearchParams(location.hash.slice(1)).get('link') ?? '';
const connection = await connect({ ...relay, link });
const me = connection.identity!;
connection.status.subscribe(status => {
document.getElementById('status')!.textContent = `${me}: ${status} over ${connection.transport.peek() ?? '…'}`;
});
addEventListener('pagehide', () => connection.close());
const camera = new publish.Source.Camera();
const microphone = new publish.Source.Microphone();
const video = new publish.Video.Capture({ source: new publish.Signals.Computed(e => e.get(camera.out.source)?.video) });
const audio = new publish.Audio.Capture({ source: new publish.Signals.Computed(e => e.get(microphone.out.source)?.audio) });
const broadcast = new publish.Broadcast({ origin: connection.origin, name: moq.Path.from(`${me}/camera.hang`), display: video.out.display });
new publish.Video.Encoder('video/hd', { broadcast, capture: video });
new publish.Audio.Encoder('audio', { broadcast, capture: audio });
const people = new Map<string, { item: HTMLLIElement; player: watch.Player }>();
for await (const { kind, prefix } of connection.list('')) {
const who = prefix.slice(0, prefix.indexOf('/'));
if (!prefix.endsWith('/camera.hang') || who === me) continue;
if (kind === 'retracted') {
people.get(who)?.player.close();
people.get(who)?.item.remove();
people.delete(who);
} else if (!people.has(who)) {
const item = document.getElementById('people')!.appendChild(document.createElement('li'));
item.textContent = who;
const canvas = item.appendChild(document.createElement('canvas'));
people.set(who, { item, player: new watch.Player({ origin: connection.origin, name: prefix, canvas }) });
}
}The page reads the link's ID from its address fragment, which stays in the browser. On a link, the SDK makes a secret once per browser and keeps it, and Relay derives the person's ID from the secret, so a reload connects as the same person. The page publishes the camera and microphone at {id}/camera.hang with the official publish elements, lists what is published, and plays every other camera with the official player as it comes and goes (Publishing and reading). The status line shows the transport: webtransport, or websocket in Safari and wherever WebTransport is missing (Connecting).
Bundle it for the browser, for example with esbuild, and load it from a page that has #status and #people:
npx esbuild page.ts --bundle --format=esm --outfile=page.js<!doctype html>
<meta name="viewport" content="width=device-width, initial-scale=1">
<p id="status"></p>
<ul id="people"></ul>
<script type="module" src="page.js"></script>4. Open it in two browsers
Serve the page over https:// (or from localhost): browsers give the camera and WebTransport to secure pages only. Open page.html#link={link id} in two browsers. Each plays the other's camera and sound. Close one, and its picture leaves the other.
5. A backend that signs tokens
A link gives everyone the same rights and an ID from their browser. When your app decides who someone is, your backend signs tokens instead. On the Keys screen, create a key and copy its ID and secret: the secret is shown this once. A token is a JWT in the moq-auth format, signed with HS256 by the key's secret, with the key's ID as kid (Connecting). This sample signs one with the package jose (npm install jose):
// Signs tokens with a project key, from TABLEBOX_KEY_ID and TABLEBOX_KEY_SECRET (npm install jose).
import { SignJWT } from 'jose';
export interface Rights { root?: string; publish?: string[]; subscribe?: string[] }
/** A token for `sub` with `rights`, valid for ten minutes. */
export function sign(sub: string, rights: Rights): Promise<string> {
return new SignJWT({ ...rights })
.setProtectedHeader({ alg: 'HS256', typ: 'JWT', kid: process.env.TABLEBOX_KEY_ID! })
.setSubject(sub)
.setIssuedAt()
.setExpirationTime('10m')
.sign(new TextEncoder().encode(process.env.TABLEBOX_KEY_SECRET!));
}This backend gives a user a token that publishes under the user's name and reads everything:
// Your backend on port 8000: gives each user a token that publishes under their own name and reads everything.
import { createServer } from 'node:http';
import { sign } from './token.ts';
createServer(async (request, response) => {
// Decide here who is asking, with your own sign-in; this sample trusts the query.
const user = new URL(request.url!, 'http://localhost').searchParams.get('user') ?? '';
if (!/^[\w-]{1,64}$/u.test(user)) return void response.writeHead(400).end();
response.end(await sign(user, { publish: [`${user}/**`], subscribe: ['**'] }));
}).listen(8000);In the page, connect with getToken, so that the SDK asks the backend for a fresh token before every connection attempt:
// Connects as `user` with a token from your backend, which the SDK asks for before every attempt.
import { connect } from 'tablebox.io';
import { relay } from './relay.ts';
export async function connectAs(user: string) {
const getToken = () => fetch(`/token?user=${encodeURIComponent(user)}`).then(reply => reply.text());
const connection = await connect({ ...relay, getToken });
connection.status.subscribe(status => {
console.log(`${user}: ${status}`, connection.transport.peek() ?? '', connection.reason.peek()?.name ?? '');
});
return connection;
}Before the hosted service opens
The Tablebox repository runs Manager and two Relays on your machine. npm run local starts them on loopback with Console, creates a project with a key and a link, and prints Manager's and Console's addresses, the Relay URLs, the SHA-256 of their self-signed certificate, test tokens and the link. Since that certificate is not signed by a certificate authority, give its SHA-256 to connect as serverCertificateHashes (in Rust, server_certificate_hashes). Console signs in through Manager's loopback-only development sign-in, at the address the stack prints; the repository's ops/local/README.md shows the steps.
Every sample on these pages runs with values you give in one place. Browser samples take the Relay URL and the SHA-256 from relay.ts (above). Node samples take them from the environment through this relay.ts, and the key through token.ts; the Rust sample reads the same variables.
| Variable | Value |
|---|---|
TABLEBOX_RELAY_URL |
The Relay URL: the hosted one, or one the local stack prints |
TABLEBOX_RELAY_CERT_SHA256 |
The local stack's certificate SHA-256; unset for the hosted service |
TABLEBOX_KEY_ID, TABLEBOX_KEY_SECRET |
A key of the project |
TABLEBOX_TOKEN |
A token, for the Rust sample |
TABLEBOX_MANAGER_URL, TABLEBOX_PROJECT |
The Manager API address and the project's ID, for the management sample |
// The Relay URL, and for a local Relay its certificate's SHA-256, from the environment.
const hash = process.env.TABLEBOX_RELAY_CERT_SHA256;
export const relay = { url: process.env.TABLEBOX_RELAY_URL!, serverCertificateHashes: hash ? [hash] : [] };Node 22.18 or later runs the TypeScript samples directly (node publish.ts). The Rust sample is a main.rs on a tokio runtime with the crates tablebox, serde_json and tokio.