Skip to content
Embed the call UI

Guides

Embed the call UI

One React component gives your product the whole call: the grid and stage, device menus, screen sharing, chat, raised hands, reactions, polls and Q&A, a shared whiteboard, a waiting room, moderation and recording.

What’s inside

Everything below is the real component’s look, running as a demo. Try the device menus (the arrows beside the microphone and camera), switch Layout in the top right, raise your hand, send a chat message, or open People for the moderator controls.

<CallRoom />, as a demoInteractive
Algebra 101

4 people here · live lesson

  • Pre-join screen with camera preview and device checks, so nobody joins with the wrong microphone. Everyone joins muted unless they turn it on there — except a host who joined with it on last time, who finds it on again.
  • “You’re muted”: someone heard talking with their microphone off is told so, with a button to unmute — the teacher who starts the lesson muted finds out before the class has to tell them.
  • Background blur and virtual backgrounds, chosen before joining or from the camera’s menu, done in each person’s browser so their real background never leaves it.
  • Sharp screen sharing: screens are sent at up to 1440p, as one full-size stream, so text stays readable — on a weak connection the picture updates less often rather than getting blurrier. On an iPad, iPhone or Android device, where no browser can share a screen, the button says so and points to a computer or the whiteboard.
  • Presenting from one screen: in Chrome and Edge, someone sharing their screen gets a small window that stays on top of whatever they present, with the chat (and a box to reply in), reactions, raised hands, their microphone and Stop sharing. It opens by itself where the browser allows, and from the top bar otherwise. In other browsers, the tab’s title counts new messages and raised hands, and the presenter can turn on notifications for them. The window needs the call on the page itself, not in an iframe.
  • Clear sound in big calls: the grid shows nine other people a page (four on a phone), hosts first, and only the cameras on screen are sent, so a class with every camera on doesn’t garble the teacher’s voice. Whoever starts talking comes onto the page. Someone on a weak connection or a struggling device gets fewer still, and is told so.
  • Four layouts — auto, grid, spotlight and sidebar — each person choosing their own. A screen share takes the stage automatically; pin anyone to keep them there.
  • Raised hands, in order, and a pinned note moderators can set for everyone.
  • Moderation: lock microphones, mute one person or everyone, let one person speak — and, from a right-click on anyone’s name, let them share their screen, run polls or manage Q&A, or remove them from the call.
  • Recording on the media server — it keeps going whoever leaves — with a list of past recordings in the call, and an option to record automatically when a host joins.
  • Reactions that float up the call and pop up on the sender’s tile.
  • Polls and Q&A. Moderators ask a multiple-choice question, a quiz, a star rating or an open question; it pops up for everyone else, and the asker sees the answers once everyone has replied — as bars, an average or a word cloud — and can share them with the room.
  • A shared whiteboard on an infinite canvas: pressure-sensitive pen, highlighter, shapes, arrows that stay attached, text, sticky notes, a laser pointer, live cursors, several boards per room, and presenting — everyone follows your view. Remembered between calls in the same room. A host on a laptop can draw from their iPad by scanning a QR code.
  • Breakout rooms. Split the call into up to 20 rooms — shuffled, assigned by hand, or picked by each person — with an optional time limit. Moderators move people, visit any room and message every room at once; everyone comes back when the rooms close.
  • A waiting room and a password, switched on or off by moderators during the call.
  • Honest errors. A blocked camera or an empty device list says why, and offers the fix.
  • Keyboard: m mutes, v toggles the camera, h raises your hand, l switches layout.

The whiteboard, polls and breakout rooms, on their own — teammates are drawing on this board with you, and every tool works:

The whiteboard, as a demoInteractive
Launch planning

4 people here · live

Saved

Colour

Size

A poll, from the host’s sideInteractive
Team all-hands

4 people here · live

Breakout rooms, from the host’s sideInteractive
Design workshop

4 people here · live

Install

npm install @inlinemeet/call-ui livekit-client

It’s plain React and plain CSS — no Tailwind or design-system dependency — so it fits React 18 and 19 apps whatever they’re styled with, and any bundler: it ships as JavaScript with type definitions, and brings its own stylesheet (every rule is scoped under .inlinemeet-call, so it can’t touch your pages). There’s nothing to configure. To see it in a working app first, clone the examples.

How it talks to your server

The UI never holds your API key. It calls a handful of routes on your server, laid out like InlineMeet’s own API; your routes check your rules and forward each call with the key.

The UI callsYour route forwards toWho may
POST <base>/tokenPOST /v1/rooms/:code/tokensAnyone you let join
POST <base>/mutePOST /v1/rooms/:code/muteModerators
PATCH <base>/settingsPATCH /v1/rooms/:codeModerators
POST <base>/participants/:identity/microphonePOST /v1/rooms/:code/participants/:identity/microphoneModerators
POST <base>/participants/:identity/permissionsPOST /v1/rooms/:code/participants/:identity/permissionsModerators
POST <base>/participants/:identity/removePOST /v1/rooms/:code/participants/:identity/removeModerators
GET <base>/recordingsGET /v1/rooms/:code/recordingsAnyone in the room
POST <base>/recordingsPOST /v1/rooms/:code/recordingsWhoever you let record — moderators, below
POST <base>/recordings/:id/stop · abortthe same, under /v1/rooms/:codeWhoever you let record — not only whoever started it
GET <base>/recordings/:id/fileGET /v1/rooms/:code/recordings/:id/fileAnyone in the room
DELETE <base>/recordings/:idDELETE /v1/rooms/:code/recordings/:idModerators
GET <base>/lobby · POST lobby/admit · lobby/denythe same, under /v1/rooms/:codeModerators
GET · DELETE <base>/lobby/tickets/:ticketthe same, under /v1/rooms/:codeWhoever holds the ticket
GET <base>/whiteboardGET /v1/rooms/:code/whiteboardAnyone in the room
PATCH <base>/whiteboardPATCH /v1/rooms/:code/whiteboardAnyone in the room — with their own identity
POST <base>/whiteboard/snapshotsPOST /v1/rooms/:code/whiteboard/snapshotsModerators — set savedBy from your session
POST · DELETE <base>/breakouts · POST breakouts/move · breakouts/broadcastthe same, under /v1/rooms/:codeModerators
POST <base>/breakouts/joinPOST /v1/rooms/:code/breakouts/joinAnyone in the room — with their own identity
POST <base>/endPOST /v1/rooms/:code/endModerators
POST <base>/companions · companions/resume · companions/disconnectthe same, under /v1/rooms/:codeModerators — with their own identity
GET · POST <base>/streams · POST streams/:id/stop · streams/:id/destinations · DELETE streams/:id/destinations/:destinationId · streams/saved/:savedIdthe same, under /v1/rooms/:codeModerators — set startedBy from your session
POST <base>/webinar/startPOST /v1/rooms/:code/webinar/startModerators
GET · POST · DELETE <base>/ctathe same, under /v1/rooms/:codeModerators
POST <base>/cta/clickPOST /v1/rooms/:code/cta/clickAnyone in the room — with their own identity

The waiting-room, whiteboard, breakout, end, tablet and live streaming routes are only called when you turn them on (see backend options). When a room has a password, the pre-join screen asks for it and the UI sends it as password in the token request — forward it. When a room has a waiting room, the token request answers 202 with a ticket; return that to the browser as it is, and the UI waits and polls.

The token route

POST <base>/token is the one route every call needs; the quickstart writes it in Next.js, Express and FastAPI. It answers with InlineMeet’s token response plus whatever the UI should know about this person:

FieldDescription
urlrequiredstringThe media server to connect to — straight from the token response.
tokenrequiredstringThe join token.
identityrequiredstringThe identity you minted it for.
namestringDisplay name. Falls back to the name in the token.
role'moderator' | 'participant'Pass through the role you minted. Moderators get the moderation controls and a badge.
permissions{ microphone, camera, screenShare, record }Which buttons to show. Only the UI reads this; what a person can actually publish is enforced by the media server from the token. Moderators get everything regardless.
recordingNoticestringA line shown to whoever starts a recording — where it will end up, say. Also shown on the recording pill for anyone who may record.

Moderation and recording routes

Everything else can be one catch-all route that checks the caller and forwards the request. The UI hides moderator buttons from everyone else, but only your route enforces that:

route.ts
// app/api/rooms/[code]/[...path]/route.ts
// Everything the call UI needs besides its token: moderation and recording.
// Each request is checked against your rules, then forwarded with your key.
import { NextResponse } from 'next/server';
import { getCurrentUser, canJoin, isModerator } from '@/lib/auth'; // yours

const API = 'https://api.inlinemeet.com/v1';
const headers = {
  Authorization: `Bearer ${process.env.INLINEMEET_API_KEY}`,
  'Content-Type': 'application/json',
};

type Ctx = { params: Promise<{ code: string; path: string[] }> };

async function forward(req: Request, { params }: Ctx) {
  const { code, path: parts } = await params;
  const user = await getCurrentUser(req);
  if (!user || !(await canJoin(user, code))) {
    return NextResponse.json({ error: { message: 'Not allowed.' } }, { status: 403 });
  }

  const path = parts.map(encodeURIComponent).join('/');
  const startsRecording = path === 'recordings' && req.method === 'POST';
  // Start, stop and abort. Anyone who may record may stop a recording, even one someone else started.
  const controlsRecording = path.startsWith('recordings') && req.method === 'POST';
  // The waiting room's list, admit and deny; its ticket routes are the waiting person's own.
  const lobbyControl = path === 'lobby' || path === 'lobby/admit' || path === 'lobby/deny';
  // Opening breakout rooms, moving people and messaging every room; breakouts/join is anyone's, as themselves.
  const breakoutControl = path === 'breakouts' || path === 'breakouts/move' || path === 'breakouts/broadcast';
  // Saving a copy of the whiteboard, to show after the call.
  const savesWhiteboard = path === 'whiteboard/snapshots' && req.method === 'POST';
  // Ending the call for everyone, when a moderator leaves.
  const endsCall = path === 'end';
  // Pairing a drawing tablet, taking it back after a reload, disconnecting it.
  const tablet = path === 'companions' || path.startsWith('companions/');
  // Going live, and everything about a live stream — reading it too: it lists the saved destinations.
  const streams = path === 'streams' || path.startsWith('streams/');
  // Starting a webinar from backstage, and the call to action — though pressing it (cta/click) is anyone's.
  const broadcast = path === 'webinar/start' || path === 'cta';
  const moderatorOnly =
    path === 'mute' || path === 'settings' || path.startsWith('participants/') || lobbyControl || breakoutControl ||
    controlsRecording || savesWhiteboard || endsCall || tablet || streams || broadcast ||
    (req.method === 'DELETE' && !path.startsWith('lobby/tickets/'));

  // The UI only shows these buttons to moderators — this is what enforces it.
  if (moderatorOnly && !(await isModerator(user, code))) {
    return NextResponse.json({ error: { message: 'Only moderators can do that.' } }, { status: 403 });
  }

  let body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await req.text();
  // Who started a recording comes from your session, never from the browser.
  if (startsRecording || (path === 'streams' && req.method === 'POST')) {
    body = JSON.stringify({ ...JSON.parse(body || '{}'), startedBy: user.id, startedByName: user.name });
  }
  // So does who saved the whiteboard — and anything you want to find it by later.
  if (savesWhiteboard) body = JSON.stringify({ savedBy: user.id, savedByName: user.name });
  // A locked whiteboard takes moderators' edits only, a breakout move goes by the mover's own role, a tablet joins
  // the call as whoever paired it, and a press of the call to action is counted for whoever made it — all decided by
  // this identity, so it must be the caller's own. (Here identities are minted as `${user.id}:<tab>`.)
  if ((path === 'whiteboard' && req.method === 'PATCH') || path === 'breakouts/join' || tablet || path === 'cta/click') {
    const edit = JSON.parse(body || '{}');
    if (!String(edit.identity ?? '').startsWith(`${user.id}:`)) {
      return NextResponse.json({ error: { message: 'Not your identity.' } }, { status: 403 });
    }
  }

  const room = encodeURIComponent(code);
  // The UI's "PATCH settings" is the room itself on InlineMeet.
  const target = path === 'settings' ? `${API}/rooms/${room}` : `${API}/rooms/${room}/${path}`;
  const res = await fetch(target, { method: req.method, headers, body });
  return new NextResponse(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}

export const GET = forward;
export const POST = forward;
export const PATCH = forward;
export const DELETE = forward;

In Next.js, a specific route like token/route.ts wins over the [...path] catch-all, so the two live side by side under app/api/rooms/[code]/.

Render it

The component fills its parent, so give it a height. Build the backend once per room with useMemo:

LessonCall.tsx
'use client';
import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { CallRoom, createFetchBackend } from '@inlinemeet/call-ui';

export default function LessonCall({ code, lessonTitle }: { code: string; lessonTitle: string }) {
  const router = useRouter();
  const backend = useMemo(
    () => createFetchBackend({ basePath: `/api/rooms/${code}`, settings: true, lobby: true, whiteboard: true, breakouts: true }),
    [code],
  );

  return (
    <div style={{ height: 'calc(100vh - 64px)' }}>
      <CallRoom
        backend={backend}
        title={lessonTitle}
        subtitle="Live lesson"
        className="my-app-call"
        onLeave={() => router.push('/lessons')}
      />
    </div>
  );
}

Layouts

Everyone arranges the call their own way, from Layout in the top bar — changing yours never changes anyone else’s. The choice is remembered in their browser for the next call.

<CallRoom defaultLayout="sidebar" />, as a demoInteractive
Algebra 101

4 people here · live lesson

LayoutWhat it shows
autoThe default. A grid, until someone shares their screen or is pinned — then that takes the stage, with everyone else in a strip beneath it.
gridEveryone the same size, always. A shared screen is one more tile; someone pinned moves to the front.
spotlightOne thing fills the call: whoever is pinned, else a shared screen, else whoever is talking. It follows the conversation, but only cuts to someone once they’ve spoken for a moment, never to you.
sidebarThe same main picture as spotlight, with everyone else in a column beside it — beneath it on a phone.

Set where a call starts with defaultLayout — spotlight suits a lecture, grid a class discussion — or pass layoutMenu={false} to keep everyone in it. In any layout, clicking someone in the strip (or double-clicking them in the grid) pins them; Unpin hands the choice back to the layout.

Props

PropDescription
backendrequiredCallBackendWhere the UI gets its token and sends moderation and recording calls. Usually createFetchBackend(…).
titlerequiredstringShown in the call’s top bar and on the pre-join screen.
subtitlestringA second line under the title, after the head count.
autoJoinbooleanSkip the pre-join screen and connect straight away, muted. Default false.
onLeave(reason: 'left' | 'removed' | 'ended' | 'disconnected') => voidCalled once the call is over for this person — usually navigation back out of the call. Says why: they left, a moderator removed them, the call ended for everyone, or the connection went.
recordingsListbooleanList past recordings inside the call. On by default when recording is possible; turn it off if you show recordings elsewhere.
brandingbooleanThe small “powered by InlineMeet” mark. On by default; set false on the Business and Enterprise plans.
nameTags'pill' | 'tab' | 'shadow' | 'below'How each person’s name sits on their picture: a small pill in the corner (the default), a tab flush in the corner, white text with no box, or under the picture so it covers nothing. Try them in the theme playground.
controlBar'floating' | 'docked' | 'bare'How the control bar sits at the foot of the call: a raised bar (the default), a band across the bottom, just the buttons, or overlay — over the bottom of the video, taking no room, and out of sight until someone moves the mouse (or, on a touch screen, taps the video). Round or square buttons are the --im-control-radius token.
controlBarSize'regular' | 'compact'Smaller buttons, closer together, for a call short of room. regular by default.
defaultLayout'auto' | 'grid' | 'spotlight' | 'sidebar'How tiles are arranged until the person picks otherwise. Left out, the room’s own layout setting — a template’s, say — decides, else auto. See Layouts.
layoutMenubooleanThe Layout menu and the l key. On by default; each person’s choice is remembered in their browser. Off, everyone sees defaultLayout.
fullscreenbooleanThe full-screen button in the top bar, and the f key: the call fills the screen. On by default. Where the browser won’t do that — on an iPhone, whatever the browser — the call fills the browser window instead. On an iPad, where Safari leaves full screen at any downward drag, a stroke on the whiteboard included, the call then goes on filling the window, and the button puts it full screen again. Not shown in an iframe without allow="fullscreen".
logobooleanThe workspace’s logo, top right of the top bar — where recordings and streams show it too — when the room’s brand has one and its hosts haven’t turned it off (Settings → Branding). On by default; turn it off when your own page already shows your logo.
pictureInPicturebooleanThe picture-in-picture button in the top bar: the call’s main video — someone else’s shared screen, else whoever is pinned or talking, never the person themselves — in the small window the browser keeps on top of other apps and tabs, following whoever is talking. Someone with their camera off shows as a card with their name. On by default, where the browser can: Chrome, Edge and Safari, on computers and phones; not Firefox. Chrome and Edge on a computer open it by themselves when someone switches tab during the call; on a phone it takes a tap.
liveStreamingbooleanGoing live from the Live tab, for moderators, where the backend has streams and the plan has it. On by default; off hides it. To decide per room instead — and have the API refuse it too — set the room’s allowLiveStreaming.
webinarbooleanThe switch in Settings that makes the call a webinar, where the plan has them. On by default; off hides it (a call that is a webinar can still be made a meeting again). Per room: allowWebinar.
settingsSectionsstring[]Which sections the Settings tab offers moderators, where the backend has settings: webinar, joining (waiting room, password), recording, branding, whiteboard, microphones. All of them by default — name fewer where your backend forwards fewer settings.
reactionsbooleanThe reaction button. On by default.
pollsbooleanPolls and Q&A. On by default; only moderators can ask.
whiteboardbooleanThe shared whiteboard. On by default. Kept between calls when the backend has whiteboard routes; otherwise shared live and empty at the start of each call.
breakoutsbooleanBreakout rooms, for moderators. On by default when the backend has breakout routes.
backgroundEffectsbooleanBackground blur and virtual backgrounds for each person’s camera. On by default, in browsers that can do it (current Chrome, Edge, Firefox and Safari).
backgroundImages{ id: string; label: string; src: string; thumbnail?: string }[]Pictures to offer as virtual backgrounds, in place of the built-in ones. Same-origin, data: or CORS-enabled URLs. People can still upload their own.
backgroundAssets{ tasksVisionFileSet?: string; modelAssetPath?: string }Where the background effect’s runtime (~9 MB) and model (~16 MB) load from. By default, cdn.jsdelivr.net and storage.googleapis.com, the first time someone picks an effect, then from the browser’s cache — serve them yourself if your Content Security Policy doesn’t allow those.
echoGatebooleanWhen someone’s microphone is sending the call back out of their speakers — their device’s echo cancelling isn’t coping — it’s turned down while others talk, and they’re told headphones fix it. Their own voice still gets through. On by default, in Chrome, Edge and Firefox. A Content Security Policy needs blob: in script-src for it; without that it simply stays off.
participantNoiseReductionbooleanStarts “Reduce background noise” on, in the microphone’s menu, for anyone who hasn’t chosen — it takes keyboards, fans and traffic out of their microphone. Whoever switches it on or off is remembered on their device, and that wins. Off by default, because it can soften the breathy letters of someone reciting, and music. Chrome, Edge, Firefox and Safari (iOS 14.5 on); its model, about 1.6 MB, downloads only for people who have it on.
protectVoicebooleanVoice and screen first, for a teacher on a busy Wi-Fi. While someone shares their screen their camera is sent small. When their own upload can’t carry everything — their browser’s estimate of it under 1 Mb/s, their voice queuing behind their video, or a tenth of what they send lost, for most of 15 seconds — their video gives way: the shared screen at half its size and 5 frames a second, the camera and the whiteboard’s picture for the recording capped too. After half a minute with their voice clear it tries full again. The media server’s rating of their connection plays no part. Nothing is paused, and they see a “Weak connection” label while their video is sent smaller. On by default.
gatherReviewsAfterCallboolean | { noun?: string; host?: string; intro?: ReactNode; askAfterLeaving?: number | false }When a host ends the call for everyone, ask everyone else how it went: sound, video and board, connection and overall, Bad to Great — or “Everything worked well” in one tap — and, for anything rated OK or below, what went wrong and a note. Someone who leaves by themselves is asked too, once they’ve been in the call 30 minutes (askAfterLeaving, in minutes; false never), with a way back in beside it. Answers go straight to inlinemeet with the person’s device, rough location and connection figures, and appear under Call reviews in your dashboard, which opens as soon as a host joins a call with it on. noun and host name your calls and who leads them (“lecture”, “teacher”); intro is shown above the questions. Pass a contact with the join token to be able to reach people about their answers. Off by default.
upgrade{ href: string; label?: string }Where to upgrade, for a room whose plan doesn’t include the whiteboard, polls or breakout rooms: moderators see those buttons locked, with this link. Without it they’re hidden. Rooms made with an API key always include them — see paid features.
checkUrlstringA page on your site that renders MediaCheck — "/check", say. Moderators who see someone who can’t use their microphone can copy its link for them.
audiencebooleanThis person joins a webinar’s audience: the pre-join screen asks for no camera or microphone. The room’s own rules decide once they’re in.
classNamestringAdded to the root, for theming.

Backend options

createFetchBackend builds a backend from routes laid out as in the table above:

OptionDescription
basePathrequiredstringWhere your routes live, e.g. /api/rooms/algebra-101.
recordingsbooleanSet false when the call can’t be recorded: the record button never appears. Default true.
moderationbooleanSet false if you have no moderation routes: moderators get no controls. Default true.
removalbooleanOffer moderators Remove from call. Needs POST <base>/participants/:identity/remove. Default false.
permissionsbooleanLet moderators give one person screen sharing, polls, Q&A or the locked whiteboard. Needs POST <base>/participants/:identity/permissions. Default false.
settingsbooleanThe moderators’ Settings tab — waiting room, password, auto-record, whiteboard lock. Your PATCH settings route must forward every field. Default false.
lobbybooleanThe waiting room. Needs the lobby routes. Default false.
whiteboardbooleanRemember whiteboards between calls. Needs the whiteboard routes. Default false.
whiteboardSnapshotsbooleanGive moderators Save a copy for after the call in the whiteboard’s menu. Needs whiteboard and POST <base>/whiteboard/snapshots. Default false.
breakoutsbooleanBreakout rooms. Needs the breakout routes. Default false.
endbooleanAsk a moderator who leaves whether to end the call for everyone — disconnecting them all and stopping the recording. Needs POST <base>/end. Default false.
companionsbooleanLet moderators draw from a tablet: the tablet button on the whiteboard pairs one with a QR code. Needs the three companions routes. Default false.
streamsbooleanGo live: moderators stream the call to YouTube, Facebook, Instagram and the rest from the Live tab. Needs the streams routes, for moderators only. Default false.
webinarbooleanA webinar that opens backstage gets a Start the webinar bar for moderators. Needs POST <base>/webinar/start, for moderators only. Default false.
ctabooleanThe call to action: moderators put a button up for everyone from the Live tab, and see how often it’s pressed. Needs the cta routes — cta/click for anyone in the call. Default false.
headersRecord<string, string>Merged into every request — an auth header, for instance. Cookies are sent already.

Drawing from a tablet

A teacher in a lesson on their laptop can draw on the whiteboard with the iPad and Apple Pencil they already own. With companions: true, moderators get a tablet button on the whiteboard: it shows a QR code, and scanning it with the tablet’s camera (or typing its code at inlinemeet.com/pen) puts the tablet in the call to draw for them. It has no camera or microphone, plays no sound, and nobody sees it as another person — it is the teacher’s pen. Their laptop follows what it draws, so a recording of the call shows it too. Its Present button is the teacher’s own, the same on both devices — and while they share their screen as well, presenting the board, or stopping, moves everyone (and the recording) between the board and the screen.

The tablet’s side is a page InlineMeet serves — the code says which call it is — so there is nothing to build for it. Your app adds three routes, each forwarding to /v1/rooms/:code/companions, and must check the caller is a moderator sending their own identity: a code makes a device part of the call as that person.

  • A pen draws, a palm doesn’t. Once a pen has drawn on a device, only the pen draws there. One touch does nothing — on a tablet it’s far more often a resting palm than a finger — and two fingers landing together move and zoom the board; a hand coming down while the pen writes, or just after, is ignored. This works in any call, with or without pairing a tablet, and a paired tablet starts this way. For a full-screen board, the tablet’s full-screen button explains — for whichever browser it’s in, with one tap where the browser can — adding it to the Home Screen, where it opens as an app with no browser bars. (On an iPad that’s the only way: Safari’s own full screen closes at the first downward stroke, and no website can stop it.)
  • One device per code. An unused code works for five minutes; the first device to use it is the only one that can, and rejoins with it after a reload. Pairing again ends the last code — one tablet per moderator.
  • It goes where its moderator goes — into a breakout room and back — and leaves if they don’t come back within two minutes. A moderator who reloads takes their tablet back automatically.
  • It takes a place in the call on the media server, so a call at its maxParticipants has no room for one. It counts toward nobody’s minutes, and the participants list marks it with companionOf.

Whiteboards after the call

A room’s whiteboard carries on from one call to the next. To keep what one lesson drew, a moderator picks Save a copy for after the call from the whiteboard’s menu (with whiteboardSnapshots: true), or your server calls POST /v1/rooms/:code/whiteboard/snapshots itself — when a class ends, say. Label it as you save it and find it again like a recording.

WhiteboardView shows a saved copy anywhere, read-only, with the call’s own drawing: a tab per board, drag to move around, pinch or ⌘/Ctrl + scroll to zoom, dark or light paper, PNG and SVG download. It comes from its own entry point, @inlinemeet/call-ui/viewer, which needs no stylesheet and leaves LiveKit out of the page. Fetch the copy on your server, where the key is, and pass the JSON on as it is:

// app/lessons/[id]/board/page.tsx — a saved whiteboard on the lesson's page, no call needed.
import { LessonBoard } from './LessonBoard';

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const headers = { Authorization: `Bearer ${process.env.INLINEMEET_API_KEY}` };
  const list = await fetch(`https://api.inlinemeet.com/v1/whiteboard/snapshots?label.lessonId=${id}`, { headers }).then((r) => r.json());
  const latest = list.snapshots[0];
  if (!latest) return <p>Nothing was saved from this lesson’s whiteboard.</p>;
  const saved = await fetch(`https://api.inlinemeet.com/v1/whiteboard/snapshots/${latest.id}`, { headers }).then((r) => r.json());
  return <LessonBoard saved={saved} />;
}

// LessonBoard.tsx
'use client';
import { WhiteboardView } from '@inlinemeet/call-ui/viewer';

export function LessonBoard({ saved }: { saved: unknown }) {
  return <WhiteboardView whiteboard={saved} style={{ height: 520, borderRadius: 12 }} />;
}

Give it a height; it fills its box. paper sets the paper it starts on, board and onBoardChange choose the board, controls={false} hides the buttons, and scrollPans makes the scroll wheel pan when the viewer fills the screen. For a thumbnail, <BoardImage board={viewableBoards(saved)[0]} /> draws one board as a still picture.

A custom backend

Routes shaped differently, a GraphQL API, a typed client? Implement CallBackend directly — it’s a small interface:

import type { CallBackend } from '@inlinemeet/call-ui';

// Your app already has an API client? Implement the contract with it instead.
const backend: CallBackend = {
  getCredentials: () => api.post(`/classes/${classId}/call-token`),
  moderation: {
    mute: (identity) => api.post(`/classes/${classId}/call/mute`, { identity }),
    setParticipantsCanUnmute: (allowed) => api.patch(`/classes/${classId}/call`, { participantsCanUnmute: allowed }),
    allowToSpeak: (identity, allowed) => api.post(`/classes/${classId}/call/speakers`, { identity, allowed }),
  },
  // recordings: { list, start, stop, abort?, file, remove? }
};

Without React

The call UI is optional. A join token is a standard LiveKit access token, so any LiveKit client SDK — web, iOS, Android, Flutter, React Native — can connect with the url and token your route returns. Permissions are still enforced by the media server, whatever the client.

import { Room, RoomEvent } from 'livekit-client';

// Same token route as before: your server mints, the browser connects.
const { url, token } = await fetch('/api/rooms/algebra-101/token', { method: 'POST' }).then((r) => r.json());

const room = new Room({ adaptiveStream: true, dynacast: true });
room.on(RoomEvent.TrackSubscribed, (track) => document.body.append(track.attach()));
await room.connect(url, token);
await room.localParticipant.setMicrophoneEnabled(true);

Good to know

What late joiners see

Raised hands, the pinned note, who is recording, who the moderators are, the room’s settings and whether microphones are locked all reach people who join late — so does an open poll question, the whiteboard, and which breakout room they were assigned (they’re taken straight there). Chat and reactions are live only: someone joining halfway doesn’t see earlier ones.

A room’s plan decides whether its call offers the whiteboard, polls and breakout rooms; the server tells every browser in the room metadata, and the call UI offers — and accepts — only what is listed. API keys come with paid plans, so rooms you create through the API always have all three. The whiteboard and breakout routes answer 402 plan_required for a room without them.

Breakout rooms and recording

Each breakout is a media-server room of its own, so a recording — which is of the main room — doesn’t include what’s said in them. Each has its own whiteboard for as long as it’s open. Moving between rooms takes a second or so, and keeps everyone’s microphone and camera as they were.

One identity per tab

Joining with an identity that’s already connected disconnects the older connection. Mint a fresh identity per join — your user id plus a random suffix — unless kicking the old tab out is what you want.

Devices and permissions

Browsers only list devices once the page may use them, so the device menus explain an empty list — needs permission, blocked, none found, in use by another app — and offer Allow access where a click can help.

When the browser won’t share the microphone

The pre-join screen never springs the browser’s permission box on anyone: it says what the box will ask and to choose Allow, and shows it when they press the button (or the microphone). Someone who closes the box can bring it back; someone who pressed Block can’t from a page — so the call tells the refusals apart and shows the way past each, for the browser they’re in:

  • Blocked in the browser. In Chrome and Edge, the browser’s own Use microphone button (the <microphone> and <usermedia> elements), which can ask again where nothing else can; elsewhere, the steps for that browser, with a picture of where to look. The page notices the change by itself.
  • Blocked by the computer or phone — the browser itself isn’t allowed the microphone, so the site’s setting looks fine — the steps for that system’s privacy settings.
  • Opened inside another app (Facebook, Instagram…), whose built-in browser can’t use a microphone: how to open the page in Chrome or Safari.
  • None found, or won’t start (another app may have it) — what to check, and Try again.

In the call, unmuting into one of these opens the same help, and unmutes once it’s fixed. Moderators see who can’t use their microphone, and why, in the People list.

A browser’s permission belongs to one website, so the page that checks a microphone has to be on yours. Render MediaCheck on a page of its own and pass its path as checkUrl; send its link to anyone before a first lesson. It checks the microphone (with a level meter), camera and speakers, and the devices picked there are the ones the call uses.

app/check/page.tsx
'use client';

import { MediaCheck } from '@inlinemeet/call-ui';

export default function CheckPage() {
  return <MediaCheck done={{ href: '/lessons', label: 'Back to your lessons' }} />;
}