Web calls with LiveKit
In the browser, poolse calls work like Google Meet. A conversation has a room that members drop into for voice or video while the chat carries on. Nobody's phone rings unless you ask for it. Phones join the same room, and web users can join a call that phones started.
The audio and video go through LiveKit. You install livekit-client
and hand it to the SDK. poolse issues the media tokens, so you never
run or configure a LiveKit server yourself.
Before you start, chat should already work with PoolseProvider. See
the Quickstart.
Install
npm install @poolse/sdk @poolse/react @poolse/react-ui livekit-client
livekit-client is passed in rather than bundled, so you choose its
version. The reference app uses livekit-client@^2.22.0.
Drop-in UI
useRoom is the room for one conversation. <RoomJoinBar> is the
"Start a room / Join" bar, and <RoomStage> shows everyone's video
and the controls once you are in.
'use client';
import * as livekit from 'livekit-client';
import { useRoom } from '@poolse/react';
import { ConversationView, RoomJoinBar, RoomStage } from '@poolse/react-ui';
import '@poolse/react-ui/styles.css';
export function ChatWithRoom({ conversationId, title }: { conversationId: string; title: string }) {
const room = useRoom(conversationId, { livekit });
return (
<div className="chat">
<RoomJoinBar room={room} />
{room.joined && <RoomStage room={room} title={title} />}
<ConversationView conversationId={conversationId} />
</div>
);
}
- With no call live,
<RoomJoinBar>offers to start a voice or video room. PassallowStart={false}to show only a "Join" banner while something is live. - While a call or room is live, it shows what kind it is, how many people are in it, and Join.
<RoomStage>has the video grid and microphone, camera and output controls. PasslabelFor={(userId) => name}to show names.- Switching
conversationIdleaves the previous room.
useRoom
const room = useRoom(conversationId, { livekit });
| Field | Meaning |
|---|---|
live | The call or room running on the conversation right now, whether or not this user is in it, or null. Same shape as call in call:started. |
presentIds | Poolse user ids in it right now. |
joined | This user is in it, or connecting. |
joining | A start or join is in flight. |
join(media?, { ring? }) | Start the room, or join whatever is live. media is 'audio' (default) or 'video'; video turns the camera on as you arrive. |
leave() | Leave. The room carries on for everyone else. |
media | The media side (see below). |
error | The last error, or null. |
join() calls POST /v1/conversations/:id/room, then connects to
the media server. If a phone-style call is already ringing on the
conversation, join() joins that call. A room ends on its own once it
is empty.
Ringing phones from the web
Rooms never ring. To ring the other members' phones (a support desk
calling a customer, for example), pass ring:
await room.join('audio', { ring: true }); // POST /v1/conversations/:id/calls
This places a real call: their phones ring through push and it ends like a call does. Web browsers are never rung. There is no incoming-call screen for the web.
The media: room.media
room.media (the same object useCallRoom returns) is what people
sound and look like:
| Field | Meaning |
|---|---|
status | idle, connecting, connected or error. |
participants | Everyone in the call, you first: { userId, name, isSelf, micEnabled, cameraEnabled, speaking, videoTrack }. |
micEnabled, cameraEnabled, facingMode | Your own state. |
setMicrophoneEnabled(on), toggleMic() | Microphone. |
setCameraEnabled(on), toggleCamera(), switchCamera() | Camera. Both can be turned on and off at any time. |
devices, activeDevices, selectDevice(kind, id), refreshDevices() | Cameras (videoinput), microphones (audioinput) and speakers (audiooutput). |
sfuAvailable | false if the deployment has no media server (503 sfu_not_configured). |
error | The last media error. |
Browsers only reveal device names once the page has camera or
microphone permission. Call refreshDevices() when you open a device
picker.
When the workspace has video off, every call is audio-only and
setCameraEnabled(true) cannot publish. Check room.live?.video_allowed
and hide the camera button.
Building your own UI
The pieces under <RoomStage> are exported too:
<CallVideoGrid participants={room.media.participants} labelFor={…} />draws each person's video, or their initial while the camera is off.<CallControlButton>and<CallDeviceMenu>are the round buttons and device menu.<LiveCallBanner call={room.live} inCall={room.joined} onJoin={() => room.join()} />is the banner alone.
To draw video yourself, attach a participant's videoTrack to a
<video> element with LiveKit's track.attach(element).
For full control without React, @poolse/sdk has the same parts:
const session = await poolse.rooms.start(conversationId, 'video'); // or rooms.ring(...)
const call = poolse.call(conversationId, { livekit, video: true });
await call.join(); // false when there is no media server
call.onParticipants((ps) => render(ps));
// …
call.leave();
await poolse.rooms.leave(session); // POST /v1/calls/:id/hangup with the control token
Joining from a conversation view
<ConversationView> can show the "Join" banner and call-log rows for
you:
<ConversationView
conversationId={conversationId}
onJoinCall={() => room.join()}
currentCallId={room.joined ? room.live?.id ?? null : null}
/>
Call-log rows ("Missed call", "Video call · 4:20") come from the
system message poolse writes when a call ends. See
the call log.
Limits and errors
| Situation | What happens |
|---|---|
The call is full (max_participants, default 16) | 409 call_full. Someone already in it can always rejoin. |
| The user is not a member | 403 forbidden. |
| The deployment has no media server | 503 sfu_not_configured; sfuAvailable becomes false. |
| The browser blocks the camera or microphone | room.media.error is set. Ask the user to allow access. |
Checklist
-
livekit-clientinstalled and passed as{ livekit }. -
@poolse/react-ui/styles.cssimported. - Camera button hidden when
video_allowedisfalse. - Device picker calls
refreshDevices()when it opens. - Decide whether the web should ever ring phones (
{ ring: true }).