This builds a working two-person video call from scratch. Every file is shown in full — nothing is elided, and there's no starter template to clone. At the end you'll have two browser tabs seeing and hearing each other.
You need Node 20+, a Raven control plane running locally (see Installing from source), and about fifteen minutes.
What you're building
your-app/
├── server.js Express — holds the API key, mints tokens
└── public/
└── index.html The call UI — never sees the API key
The split matters and isn't ceremony: the API key is a permanent project-wide credential. If it reaches the browser, anyone can mint a token as any user. So the server mints; the browser receives.
Step 1 — Set up the project
mkdir raven-call && cd raven-call
npm init -y
npm install expressThen link the SDKs from your Raven checkout — they aren't published yet:
npm install file:../Raven/packages/server-sdk file:../Raven/packages/sdkAdjust ../Raven to wherever you cloned it. This gives you
@corvidhq/server (backend) and @corvidhq/rtc (browser).
Step 2 — Get an API key
raven login
raven projects create my-first-call
raven keys create --name local-dev --environment developmentThe key prints exactly once:
rvk_dev_8Kd2nQxwYtLm.aG9wZXlvdWFyZWhhdmluZ2Fsb3ZlbHlkYXk
Export it — never paste it into a file you might commit:
export RAVEN_API_KEY="rvk_dev_8Kd2nQxwYtLm.aG9wZXlvdWFyZWhhdmluZ2Fsb3ZlbHlkYXk"Step 3 — The server
Create server.js:
import express from 'express';
import { Raven } from '@corvidhq/server';
const app = express();
app.use(express.json());
app.use(express.static('public'));
const raven = new Raven({
apiKey: process.env.RAVEN_API_KEY,
baseUrl: 'http://localhost:4100', // your local control plane
});
// Create the room once at startup. In a real app you'd create rooms in
// response to something — a meeting being scheduled, a support chat
// opening — and store the id alongside whatever that thing is.
let roomId;
app.listen(3000, async () => {
const room = await raven.rooms.create({ name: 'tutorial-room' });
roomId = room.id;
console.log('Ready on http://localhost:3000');
});
app.post('/api/token', async (req, res) => {
// In a real app, `identity` comes from YOUR session — req.user.id, a
// decoded JWT, a database lookup. Never from the request body, which
// is exactly what the client controls. Taking it from the body here
// is the tutorial's one shortcut, and it is not safe in production.
const identity = req.body.identity;
if (!identity) {
return res.status(400).json({ error: 'identity is required' });
}
const token = await raven.tokens.create({
room: roomId,
identity,
permissions: { join: true, publish: true, subscribe: true },
expiresIn: 3600,
});
// Forward the whole response — token, endpoint, and iceServers are
// all needed by the browser, and iceServers especially must never be
// hand-constructed.
res.json(token);
});Add "type": "module" to your package.json so the import syntax
works.
Step 4 — The browser
Create public/index.html:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Raven call</title>
<style>
body { font-family: system-ui; max-width: 800px; margin: 2rem auto; }
video { width: 320px; background: #000; border-radius: 8px; margin: 0.5rem; }
button { padding: 0.5rem 1rem; margin-right: 0.5rem; }
</style>
</head>
<body>
<h1>Raven call</h1>
<p>
<input id="name" placeholder="Your name" />
<button id="join">Join</button>
<button id="leave" disabled>Leave</button>
</p>
<p>Status: <strong id="status">not joined</strong></p>
<div>
<h2>You</h2>
<div id="local"></div>
<h2>Others</h2>
<div id="remote"></div>
</div>
<script type="module" src="/call.js"></script>
</body>
</html>Step 5 — The call logic
Create public/call.js. This is the whole thing:
import { createRTCClient } from '@corvidhq/rtc';
const statusEl = document.getElementById('status');
const localEl = document.getElementById('local');
const remoteEl = document.getElementById('remote');
let client;
let room;
document.getElementById('join').onclick = async () => {
const identity = document.getElementById('name').value.trim();
if (!identity) return alert('Enter a name first');
// 1. Ask YOUR server for a token. The browser never talks to Raven's
// control plane directly, and never holds an API key.
const res = await fetch('/api/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity }),
});
const auth = await res.json();
// 2. Build a client from exactly what the server returned.
client = createRTCClient({
token: auth.token,
endpoint: auth.endpoint,
iceServers: auth.iceServers,
});
// 3. Join. Events live on the room, not the client — wire them up
// immediately after, before doing anything else.
room = await client.join(auth.roomName);
room.on('connectionStateChanged', (state) => {
statusEl.textContent = state;
});
room.on('error', (error) => {
console.error(error.code, error.message);
});
// Someone else's track arrived — attach it to a <video>.
room.on('trackSubscribed', (track, participant) => {
const el = track.attach();
el.id = `track-${participant.identity}-${track.kind}`;
remoteEl.appendChild(el);
});
room.on('trackUnsubscribed', (track) => {
track.detach().forEach((el) => el.remove());
});
room.on('participantLeft', (participant) => {
// Clean up anything still on screen for them.
remoteEl
.querySelectorAll(`[id^="track-${participant.identity}-"]`)
.forEach((el) => el.remove());
});
// 4. Anyone already in the room when you joined won't fire
// trackSubscribed — they're available synchronously instead.
for (const participant of room.remoteParticipants) {
for (const track of participant.tracks) {
remoteEl.appendChild(track.attach());
}
}
// 5. Publish your own camera and mic.
const camera = await room.enableCamera();
await room.enableMicrophone();
if (camera) {
const el = camera.attach();
el.muted = true; // never play your own audio back at yourself
localEl.appendChild(el);
}
document.getElementById('join').disabled = true;
document.getElementById('leave').disabled = false;
};
document.getElementById('leave').onclick = async () => {
await room?.leave();
localEl.innerHTML = '';
remoteEl.innerHTML = '';
statusEl.textContent = 'not joined';
document.getElementById('join').disabled = false;
document.getElementById('leave').disabled = true;
};Because this uses a bare import specifier, serve it through a bundler
(Vite, esbuild) or change the import to a path your browser can resolve.
The fastest way to try it as-is:
npx vite public --port 3001Step 6 — Run it
node server.jsOpen two browser tabs at http://localhost:3000, enter a different
name in each, and click Join in both. Each tab should show its own
camera under "You" and the other tab's under "Others".
What just happened
Tab A Your server Raven Tab B
│ POST /api/token │ │ │
├─────────────────────────►│ │ │
│ │ tokens.create() │ │
│ ├────────────────────►│ │
│ │◄────────────────────┤ │
│◄─────────────────────────┤ token │ │
│ │ │
│ join(room) — token only, never the API key │ │
├───────────────────────────────────────────────►│ │
│ │◄─────────────────┤
│◄────────────── media flows between tabs ──────────────────────────┤
Your server held the API key. Each tab got a token scoped to one identity, one room, and an explicit permission set, expiring in an hour.
Common problems
"Cannot find module '@corvidhq/rtc'" — the packages need building
first: pnpm --filter "./packages/*" run build in your Raven checkout.
Camera works, but the other tab sees nothing — check that both tabs used different identities. Two participants with the same identity in one room conflict.
Black video with no error — a denied camera permission. Browsers don't always throw; check the address-bar permission icon.
Works locally, fails over a network — you dropped iceServers.
Without TURN, connections fail across most real NATs. Always forward it.
Next
- Add chat to this call — the same pattern, a chat token instead of an RTC one.
- Diagnostics — RTT, jitter, and packet loss for a live call.
- React SDK — the same thing with hooks, far less code.
- Authentication — what to fix before this goes near production.