Meet API Docs Dashboard
On this page

Headless Client SDK

@respiresoft/meet-client, for building your own call screen.

Publishing. The package is not on npm yet. For a call you can ship today use the embed SDK.

For a first call see Custom UI with the headless client.

Reference from the package README#

A call object for Meet with no interface in it. Give it a meeting token, and it joins the room, publishes your microphone and camera, hands you every other person's tracks, and keeps the call alive when the network blips or Meet restarts. You draw the screen.

Use the embed (@respiresoft/meet-embed) when you want Meet's own screen. Use this when you want yours.

Install#

text
npm install @respiresoft/meet-client

ESM only, browser first. It depends on mediasoup-client and socket.io-client; your bundler installs one copy of each. Your server mints the meeting token (see the server SDKs); the browser never sees an API key.

Example#

html
<div id="tiles"></div>
<script type="module">
import { MeetClient } from '@respiresoft/meet-client';

const call = new MeetClient({ baseUrl: 'https://meet.example.com', token: await (await fetch('/my-token')).text() });
const tiles = document.getElementById('tiles');

call.on('track-started', ({ participant, source, kind, stream }) => {
  const el = document.createElement(kind === 'video' ? 'video' : 'audio');
  el.id = `${participant.id}-${source}`;
  el.srcObject = stream;
  el.autoplay = true;
  el.playsInline = true;
  tiles.append(el);
  if (kind === 'video') call.setVideoQuality(participant.id, 'medium'); // tile size, so the engine picks the layer
});
call.on('track-stopped', ({ participant, source }) => document.getElementById(`${participant.id}-${source}`)?.remove());
call.on('state', ({ state, reason }) => console.log('call is', state, reason ?? ''));
call.on('chat', (m) => console.log(m.name, m.text));

await call.join({ audio: true, video: true });
document.getElementById('mute').onclick = () => call.setMic(!call.localParticipant.micEnabled);
document.getElementById('leave').onclick = () => call.leave();
</script>

Methods#

Method What it does
new MeetClient({ baseUrl, token, ... }) token is a meeting token string, or a function returning a fresh one (called at every rejoin). Options: joinRetries (first join waits out an engine restart, default 30 tries), reconnect, reconnectWindowMs, speakerPollMs, mobile, guestId, getUserMedia, getDisplayMedia.
join({ audio, video, name, call, takeover, language }) Enter the room. Resolves when you are in and your media is flowing. Rejects with a MeetError. call: false stays in the lobby until enterCall(); takeover continues a meeting open on another device; language is what you speak. Without a token, pass roomSlug (and roomKey) to the constructor to join by link as a guest.
leave() Leave on purpose. Never reconnects.
setMic(on), setCamera(on) Mic pauses (the engine and others see "muted"); camera off closes the track so the light goes out. The first true asks the browser for permission.
getPreflightStream({ audio, video, audioInput, videoInput }), useStream(stream), releasePreflight() The pre-join preview. getPreflightStream captures with the call's own constraints (a failing camera falls back to the microphone alone) and keeps the tracks; useStream hands over a stream you captured yourself. join, setMic(true) and setCamera(true) then publish those tracks, so the permission is asked once and the light does not blink. What nobody took is stopped on leave() or releasePreflight().
selectDevices({ audioInput, videoInput, audioOutput }) By device id. A running mic or camera switches at once; a device picked here is asked for exactly (exact), while the first capture only prefers a saved id (ideal). audioOutput is kept in call.devices.audioOutput; call element.setSinkId(...) yourself.
startScreenShare(), stopScreenShare() Needs the screen permission. Screen audio is sent when the browser gives it.
sendChat(text), react(emoji), raiseHand(on) Chat message (resolves with the stored message), floating reaction, hand.
setCaptions(on) Tell the room you are reading captions. Caption text arrives as caption events. sendCaption({ text, isFinal, lang }) publishes text your own speech-to-text made.
setInterpreterLanguage(lang) The language you want to read and hear in. setSpokenLanguage(lang) says which language you speak.
setVideoQuality(participantId, 'low' | 'medium' | 'high' | px | null) Report a tile's size. The engine sends the matching simulcast layer and, in a big room, only the cameras that matter (last-N). null when the tile is off screen.
pinParticipant(id, on) Always receive this person's camera (up to four).
muteParticipant(id, muted), disableParticipantCamera(id), removeParticipant(id), lowerParticipantHand(id), endMeeting() Host actions, for a token with role host. Others throw forbidden before asking the engine.
startRecording(), stopRecording() Host, or a token with record.
uploadFile(file), sendChat(text, { attachment }), reactToMessage(messageId, emoji) Chat files (10 MB, POST /meet/api/upload) and message reactions.
lockRoom(on), updateRoomOptions(options), updateRoom(patch) Host: shut the door, require admission, mute on join. client.room follows the engine's room-options.
listPending(), admitParticipant(id), rejectParticipant(id) Host: the waiting room (link guests; a meeting token never waits). Knocks arrive as admission-request.
pinForEveryone(id | null) Host: spotlight one person for all (room-pin, client.pinnedPeerId). pinParticipant stays your own last-N pin.
allowToTalk(id, allowed), revokeSpeakingGrant(key), stepDown() Stage: host lets a person talk, takes it back, or a speaker gives up the floor.
nudge(id), extendHostWait() Buzz a person; keep the room open while the host is away.
leaveCall(), enterCall({ audio, video }) Step out of the call but stay listening, and back in.
listDevices() Cameras, microphones and speakers by id.
listenToInterpreter({ lang }) Opens the translated voice (/meet/interpreter/listen). Returns a listener that emits frame (Opus or PCM16 with the speaker's peer id), speech, skip, governor, capacity. Playing it: createInterpreterPlayback from @respiresoft/meet-client/playback (see Browser helpers).
startIngest({ targetLangs, sourceLangHints }) Opens the speaker side: sendAudio(pcm16 16 kHz), get caption events (relayed to the room unless relay: false). Captions pass the SPA's language gate: the first utterance waits for a stated language, a final, or three matching guesses (ingest.lockedLanguage). ingest.close(true) flushes: the socket stays up a few seconds for the last final captions (draining, then drained). When the room ends (room-ended) or you leave(), the client keeps its engine socket for the drain the engine names and relays those finals, so the last sentence reaches the transcript.
setSpeakTranslation(on), setTranslationVoice(voice) Tell the room you want spoken translation, and the voice yours is spoken with.

Properties: state (idle, joining, joined, reconnecting, left), participants (Map of everybody else, by id), localParticipant, messages, recording, devices, role, permissions, canHost. Also room, pinnedPeerId, pendingRequests, speakingGrants, hostAway, transcript, canRecord. A participant is { id, name, host, inCall, micEnabled, camEnabled, screenSharing, handRaised, captionsEnabled, language, tracks, videoPaused, quality, stageInvite, speakTranslation, voice }. tracks holds incoming media for others and your own camera, mic and screen for localParticipant.

Events#

client.on(name, fn), client.off(name, fn), client.once(name, fn).

Event Payload
state { state, previous, reason? }. left carries why: left, removed, room-ended, superseded, connection-lost, or the engine's refusal code.
participant-joined, participant-left { participant } (participant-left also has reason)
participant-updated { participant, changed: [field names] }
track-started { participant, source, kind, track, stream, local? }; source is audio, video, screen, screen-audio. local: true for your own tracks.
track-stopped { participant, source, kind, local?, reason? }; for a remote track reason is closed, trackended (the track ended on its own; the client asks the engine what is live and consumes it again, up to 3 times per producer) or transportclose.
active-speaker { participant, level }, the loudest incoming audio (read from stats; the engine has no such event)
chat the message { id, name, text, ... } (yours too)
reaction { emoji, peerId, name, participant }
hand { participant, raised }
caption { text, isFinal, lang, translations, participant, ... }
recording { active, id?, since? }
room, room-pin the room's options changed; the host's spotlight changed
admission-request, waiting host: someone knocked; this person is held at the door
stage-invite, speaking-grants the floor was given or taken; the host's list of who may talk
host-away, host-back the host left (countdown) or came back
nudged, notice, chat-updated, transcript-line a buzz, a broadcast notice, a message's reactions changed, a transcript line
join-waiting { waiting, attempt, retryAfterMs? }, the first join is waiting out an engine restart
server-restarting { phase: 'draining' | 'final' }, a notice only; the client handles it
error a MeetError (code, message) for failures that no call is waiting on

Capture options#

new MeetClient({ mediaConstraints: { audio: {...}, video: {...} } }) adds to the constraints the SPA uses (echo cancellation, noise suppression, mono; 1280x720 at 30 fps, or 640x480 at 24 fps with auto gain off when mobile is true). buildGetUserMediaConstraints({ micEnabled, camEnabled, audioInputId, videoInputId, strictDevice, mobile }) is the same function, for an app that captures its own preview.

Browser helpers#

Optional, only for a page. Copy the two worklets from node_modules/@respiresoft/meet-client/worklets/ to your site (a page whose CSP has no blob: cannot load a worklet made on the fly) and pass their URLs.

js
import { startMicCapture } from '@respiresoft/meet-client/capture';
const ingest = await call.startIngest({ targetLangs: ['nl'], sourceLangHints: ['en'], sourceLangExplicit: true });
const mic = await startMicCapture({ track: call.localParticipant.tracks.get('audio'), workletUrl: '/pcm16-capture.js',
  onFrame: (frame) => ingest.sendAudio(new Uint8Array(frame)) });   // 100 ms of 16 kHz PCM16; mic.setTrack(t) after a device change

import { createInterpreterPlayback, opusDecodeSupported } from '@respiresoft/meet-client/playback';
const listener = await call.listenToInterpreter({ lang: 'nl', audioFormat: opusDecodeSupported() ? 'opus' : 'pcm16' });
const playback = await createInterpreterPlayback(listener, { workletUrl: '/interpreter-playback.js' });
audio.srcObject = playback.stream; audio.play();                    // or { output: 'direct' } to use the speakers at once

playback decodes Opus with WebCodecs, feeds the SPA's playout worklet (jitter buffer, time scaling, per-speaker pan), follows skip, and has setVolume, mutePeer(peerId, on), reset(), stop() and an onStats option. If its onError fires (the Opus decoder failed), reopen the listener with audioFormat: 'pcm16'. The worklet files are the SPA's own, byte for byte; a unit test keeps the copies equal.

Reconnecting#

If the connection drops, or Meet restarts (a drain), the state becomes reconnecting and the client rejoins on its own: same room, same name, your microphone and camera tracks republished, mute state kept. While Meet is draining it answers server_restarting; the client waits and retries. It stops after reconnectWindowMs (10 minutes), or at once for an answer that will not change (room_closed, revoked, bad_token...), and ends in left.

After a rejoin everybody's id is new, including yours: expect participant-left and participant-joined for each person, and track-started again. Key your UI on participant.name or your own user id if you need continuity. If the token may expire during a long call, pass token as a function.

Node#

Works in Node 20+ with @roamhq/wrtc (set RTCPeerConnection and friends on globalThis, pass handlerName: 'Chrome111' and your own getUserMedia). test/integration.mjs does exactly that.