Voice & video calls
Description
1:1 and group calls backed by LiveKit, an SFU. Ko-Lab handles signalling, call state and notifications; LiveKit carries the media. Supports mute, camera toggle and screen sharing.
This is the best-tested feature in the codebase — 11 test files.
Setup
Self-hosted LiveKit
docker compose -f docker-compose.livekit.yml up -d
./scripts/smoke-test-livekit.sh
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
LIVEKIT_HOST=http://localhost:7880
NEXT_PUBLIC_LIVEKIT_URL=ws://localhost:7880
Dev defaults — never use these in production.
LiveKit Cloud
LIVEKIT_HOST=https://your-project.livekit.cloud
NEXT_PUBLIC_LIVEKIT_URL=wss://your-project.livekit.cloud
LIVEKIT_API_KEY=<from dashboard>
LIVEKIT_API_SECRET=<from dashboard>
Note the split: LIVEKIT_HOST is the server-side HTTP URL; the
NEXT_PUBLIC_ one is the browser's WebSocket URL. Getting these crossed is a
common misconfiguration.
Verify:
curl -s http://localhost:3000/api/calls/health | jq
:::caution HTTPS in production
Browsers only grant camera and microphone access on secure origins.
localhost is exempt; any other host needs TLS.
:::
Integration
Caller Backend WebSocket LiveKit
────── ─────── ───────── ───────
initiate ──────────► POST /api/calls/initiate
creates CallRoom
status = RINGING
└──────────────► call_offered ──► Callee
(+ Web Push)
Callee accepts ────► PATCH /api/calls/[id]
status = ONGOING
└──────────────► call_accepted ─► Caller
Both ──────────────► POST /api/calls/token
AccessToken, ttl 10m
─────────────────────────────────► join room
(media)
LiveKit events ────► POST /api/calls/livekit-webhook
No media crosses the WebSocket service — it carries signalling only. Media goes browser ↔ LiveKit directly.
Data model
| Model | Purpose |
|---|---|
CallRoom | One call: livekitRoom, type, status, initiator, timestamps |
CallParticipant | Per-user state: joined/left, muted, video off, screen sharing |
CallRecording | Schema only — recording is not implemented |
enum CallStatus { RINGING ONGOING ENDED MISSED REJECTED }
How it works
Access tokens are short-lived
apps/web/app/lib/livekit.ts:15:
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
identity: userId,
ttl: "10m",
});
at.addGrant({ roomJoin: true, room: roomName /* … */ });
A 10-minute TTL means a token cannot be replayed long after issue. Clients re-request rather than caching one.
ensureLiveKitRoom and deleteLiveKitRoom manage room lifecycle through
RoomServiceClient.
Webhooks reconcile state
The browser is not a reliable reporter — a user can close a tab mid-call.
POST /api/calls/livekit-webhook receives authoritative participant-joined
and participant-left events from LiveKit and updates CallParticipant, so
the database reflects reality even when a client disappears.
Missed calls
If nobody accepts, the call transitions to MISSED and produces a
notification. Web Push (/api/calls/push-subscribe) can alert a callee who
does not have the tab open.
Testing
Automated — 11 files
cd apps/web && npx vitest run __tests__/api/calls
| File | Covers |
|---|---|
initiate.test.ts | Call creation |
token.test.ts | Token minting and grants |
accept.test.ts, reject.test.ts, end.test.ts | State transitions |
livekit-webhook.test.ts | Webhook handling |
push-subscribe.test.ts | Push registration |
health.test.ts | Config check |
livekit-lib.test.ts | The LiveKit wrapper |
env-validation.test.ts | Clean failure when unconfigured |
prisma-models.test.ts | Model shape |
Plus four component files: CallUI, CallProvider, CallComponents,
CallResponsiveness.
env-validation.test.ts is a good pattern to copy — it asserts the feature
fails cleanly rather than cryptically when keys are absent.
LiveKit is always mocked:
vi.mock("livekit-server-sdk", () => ({
AccessToken: vi.fn(() => ({
addGrant: vi.fn(),
toJwt: vi.fn(() => "fake.jwt.token"),
})),
RoomServiceClient: vi.fn(() => ({
createRoom: vi.fn(),
deleteRoom: vi.fn(),
})),
}));
Gaps
Token authorization is thinner than token generation. Worth adding:
it("refuses a token for a call the user is not part of", async () => {
const res = await POST(tokenRequest({ callId: "c1", userId: "stranger" }));
expect(res.status).toBe(403);
});
Manual — unavoidable
Media cannot be meaningfully automated. Two devices, or two browser profiles with real camera and microphone.
1:1
- A calls B → B sees incoming UI and hears a ring
- B accepts → two-way audio and video within a few seconds
- A mutes → B sees the muted indicator and hears nothing
- A turns off camera → B sees the placeholder
- A shares screen → B sees it; stopping restores the camera
- Either ends → both return cleanly, status
ENDED
Group
- A starts a group call, B and C join
- All three see each other
- One leaves → the other two continue, layout reflows
- Last participant leaves → the room is cleaned up
Rejection and missed
- B declines → A sees it, status
REJECTED - Nobody answers → status
MISSED, notification created - B has the app closed → Web Push arrives (needs HTTPS)
Network
- Disable A's wifi mid-call → B sees a connection warning
- Re-enable → reconnects, or fails with a clear message
- Throttle to 3G in devtools → video degrades, audio should survive
- Close A's tab abruptly → B sees A leave within seconds. This specifically exercises the webhook reconciliation path
Devices and permissions
- Deny camera permission → a clear prompt, not a crash
- No microphone attached → handled gracefully
- Switch input device mid-call
- Join from mobile → check the responsive layout
Configuration
- Unset
LIVEKIT_API_KEY→/api/calls/healthreports the problem and the UI degrades rather than throwing - Cross
LIVEKIT_HOSTandNEXT_PUBLIC_LIVEKIT_URL→ confirm the error is diagnosable
Not implemented
CallRecording exists in the schema and the MINIO_* variables are present,
but recording is not built. See
Known limitations.
Demo
:::info Video coming soon A 1:1 call with screen sharing, then a three-person group call. :::