LevelChatLevelChatDocs
Web

SDKs

@levelchat/web — Web SDK

Real WebRTC for browsers: perfect-negotiation, AV1 + SVC, SFrame-ready E2EE, recording, screen-share, chat. Same client for 1:1, meetings, webinars, and broadcasts.

The Web SDK ships as two packages:

  • @levelchat/web — framework-agnostic, ESM + CJS builds, ~80 kB gz, zero runtime deps.
  • @levelchat/web-react — React hooks + signature components on top of @levelchat/web.

Install

Vanilla Next.js Vite Svelte etc.
npm install @levelchat/web
React projects
npm install @levelchat/web @levelchat/web-react
pnpm yarn equivalents
pnpm add @levelchat/web
yarn add @levelchat/web

We follow semver; pin major. Latest version: 0.2.x.

Connect

TypeScript
import { LevelChat } from '@levelchat/web';

const lc = new LevelChat({ logLevel: 'info' });

// `joinLive` returns a `LiveStream` that wraps the underlying `Room` and
// exposes the lifecycle helpers most apps need.
const live = await lc.joinLive({
  token, // minted on your server
  role: 'broadcaster', // 'broadcaster' | 'viewer' — required
  roomType: 'meeting', // 'meeting' | '1to1' | 'live' | 'webinar'
});

// The SDK reads the room id straight out of the JWT and connects to the
// managed cloud automatically. Self-hosting? Pass `signalingUrl` here or
// in the `LevelChat` config.

Publish + subscribe

TypeScript
// Publish a camera with simulcast — three quality layers the SFU can pick
// from based on each subscriber's bandwidth.
const camera = await live.publishCamera({
  resolution: '720p',
  encodings: [
    { rid: 'h', maxBitrate: 1_500_000 },
    { rid: 'm', maxBitrate: 500_000 },
    { rid: 'l', maxBitrate: 150_000 },
  ],
  scalabilityMode: 'L1T3',
});

await live.publishMic();

// Subscribe to remote tracks as they arrive.
live.room.on('track-subscribed', (track) => {
  if (!track.mediaStreamTrack) return;
  const el = document.createElement('video');
  el.srcObject = new MediaStream([track.mediaStreamTrack]);
  el.autoplay = true;
  el.playsInline = true;
  document.body.appendChild(el);
});

live.room.on('participant-joined', (p) => console.log('hello', p.identity));
live.room.on('participant-left', (p) => console.log('goodbye', p.identity));

Screen share

TypeScript
const screen = await live.room.publishScreen({ frameRate: 15 });
// returns an array of TrackView (one for the picked surface, optionally one for system audio)

// Stop sharing:
await live.room.stopPublishing(screen[0].id);

Recording

TypeScript
const recording = await live.room.record({ compose: 'tracks' });
// `recording.id` is the recording id; surface it in your UI.

await live.room.stopRecording();

compose is one of 'tracks' (one file per participant per kind), 'grid' (evenly-tiled composed video), or 'speaker' (active-speaker switcher). room.record() with no arguments records everyone, tracks layout, mp4_av1 output. room.startRecording() is the pre-1.0 alias and still works.

Room API reference

joinRoom() / joinLive() hand you a Room (for joinLive, a LiveStream that wraps one — reach the room via live.room). The full public surface:

Properties

MemberTypeNotes
room.idRoomIdThe room id, decoded from the JWT.
room.participantsReadonlyMap<ParticipantId, ParticipantView>Live, reactive view of known participants. Await room.ready first.
room.localIdParticipantId | nullThe local participant id — set once the server acknowledges the join.
room.roomTypestringThe topology hint passed at join ('1to1', 'meeting', 'live', 'webinar').
room.devicesDeviceManagerCamera/mic/speaker enumeration + device-change events.
room.readyPromise<void>Resolves once the initial participant roster is populated.

Methods

MethodReturnsWhat it does
leave()Promise<void>Tears down every peer connection, stops local tracks, closes signaling.
publishCamera(opts?)Promise<TrackView>getUserMedia + publish a camera track (simulcast by default).
publishMic(opts?)Promise<TrackView>getUserMedia + publish a microphone track.
publishScreen(opts?)Promise<TrackView[]>getDisplayMedia + publish the screen surface (plus system audio if asked).
stopPublishing(trackId)Promise<void>Unpublish one local track without leaving the room.
subscribe(participantId, kind?)Promise<void>Manually subscribe to a remote participant's track(s).
unsubscribe(participantId, kind?)Promise<void>Stop receiving a remote participant's track(s).
setPreferredQuality('auto' | 'low' | 'high')voidBias which simulcast layer you receive for all remote tracks.
sendChat({ text, to? })voidSend a chat message — broadcast, or direct with to.
sendReaction({ kind })voidSend a reaction (e.g. { kind: 'thumbsup' }).
setEncryptionKey(material, keyId?)Promise<void>Install an E2EE key across the mesh. See E2EE.
record(opts?)Promise<RecordingView>Start a server-side recording. Canonical entry point.
stopRecording()Promise<RecordingView | null>Stop the in-flight recording; resolves null when none is running.
on(event, handler) / off(event, handler)RoomTyped event subscription — see Room events.

room.startRecording() is the pre-1.0 alias of room.record() and still works. joinRoom() is subscribe-by-default — every remote track is subscribed automatically — so subscribe() / unsubscribe() are only needed when you want manual control over bandwidth.

Room events

Room is a typed event emitter (RoomEvents). Drive your UI from these rather than polling. Every platform SDK publishes the same event names (adapted to platform idiom — see the parity matrix).

EventHandler signatureFires when
participant-joined(p: ParticipantView) => voidA remote participant joins.
participant-left(p: ParticipantView, reason?: string) => voidA remote participant leaves.
track-published(t: TrackView) => voidA remote participant publishes a track (before you subscribe).
track-unpublished(t: TrackView) => voidA remote participant unpublishes a track.
track-subscribed(t: TrackView) => voidA remote track is ready to render — attach t.mediaStreamTrack.
track-unsubscribed(t: TrackView) => voidA remote track went away — detach it.
connection-quality(p: ParticipantId | 'local', q: QualityScore) => voidA quality score crossed a band threshold.
connection-state(state: 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'failed') => voidThe room-level connection state changed.
active-speaker(p: ParticipantId | null, level: number) => voidThe dominant speaker changed.
recording-started(r: RecordingView) => voidA recording started.
recording-stopped(r: RecordingView) => voidA recording stopped.
chat-message(m: ChatMessageView) => voidA chat message arrived.
reaction(r: ReactionView) => voidA reaction arrived.
error(e: unknown) => voidAn error reached the top of the SDK stack.
peer-state(state: RTCPeerConnectionState) => voidRaw RTCPeerConnection state — for diagnostic UIs only.
ice-state(state: RTCIceConnectionState) => voidRaw ICE connection state — for diagnostic UIs only.

For application logic prefer connection-state over peer-state / ice-state — the latter two are raw WebRTC states exposed for diagnostic HUDs.

Config + options types

The objects you pass into the SDK, with the fields you will actually set:

LevelChatConfig (new LevelChat(config)) — region (slug or full signaling URL), signalingUrl (explicit override), logLevel ('trace''silent'), appName (telemetry prefix), telemetry (onEvent / onQualityTransition / onError hooks), headless (omit DOM-coupled helpers for bots/SSR), apiKey (dev-only, for issueToken).

JoinRoomOptions (client.joinRoom(opts)) — token (required), device (cameraId / microphoneId / speakerId), preferredCodec, simulcast (default true for video), svc (SVC opt-in), e2ee, recording ({ enabled, layout }), signalingUrl, iceServers (add TURN for symmetric-NAT clients).

JoinLiveOptions (client.joinLive(opts)) — token (required), role ('viewer' | 'broadcaster', required), roomType ('1to1' | 'meeting' | 'live' | 'webinar'), preferredCodec, signalingUrl, iceServers.

PublishCameraOptionsdeviceId, resolution ('180p''1080p'), frameRate (default 30), encodings (override simulcast layers), scalabilityMode (SVC), preferredCodec, track (pre-acquired MediaStreamTrack, useful for bots/tests).

PublishMicOptionsdeviceId, sampleRate, echoCancellation, noiseSuppression, autoGainControl, track.

PublishScreenOptionswithAudio, resolution ('720p' | '1080p' | '1440p'), frameRate (default 15).

IssueTokenOptions (dev-only client.issueToken(opts)) — userId, roomId, capabilities, ttlSeconds (default 600, server max 3600), meta (displayName / picture), limits.

React bindings

The @levelchat/web-react package wraps the vanilla SDK with a context provider, hooks, and pre-styled components. Same WebRTC underneath; the React layer is purely ergonomic.

TypeScript (TSX)
import { LevelChatProvider, useLocalParticipant, ParticipantGrid } from '@levelchat/web-react';

// `autoJoin` takes the same options as `client.joinRoom` — pass the token
// your server minted. The provider joins on mount and tears the room down
// on unmount. Pass `config` too if you need a region or custom signaling URL.
export function Call({ token }: { token: string }) {
  return (
    <LevelChatProvider autoJoin={{ token }}>
      <ParticipantGrid />
      <Controls />
    </LevelChatProvider>
  );
}

function Controls() {
  // `useLocalParticipant` returns the room's publish helpers, scoped to the
  // local participant. They no-op until the provider has joined.
  const { publishCamera, publishMic, publishScreen } = useLocalParticipant();
  return (
    <>
      <button onClick={() => publishCamera()}>Share camera</button>
      <button onClick={() => publishMic()}>Share mic</button>
      <button onClick={() => publishScreen()}>Share screen</button>
    </>
  );
}

Hooks available: useLevelChat, useRoom, useParticipants, useLocalParticipant, useTrack, useConnectionQuality, useChat, useReactions. Components: VideoTile, ParticipantGrid, LiveBadge, NetworkIndicator, MicIndicator, ReactionOverlay, Chat.

End-to-end encryption

TypeScript
import { LevelChat } from '@levelchat/web';

const lc = new LevelChat();
const live = await lc.joinLive({ token, role: 'broadcaster', roomType: 'meeting' });

// Provide a 32-byte key from your own keying system (KMS / MLS group key /
// per-room derived secret). The SFU never sees this key.
await live.room.setEncryptionKey(crypto.getRandomValues(new Uint8Array(32)));

The SDK uses Insertable Streams (Chrome/Edge/Safari 16.4+) to encrypt frames before they leave the device. The SFU sees routing metadata — never plaintext.

Network resilience

The SDK auto-reconnects on transient network loss with exponential backoff (250 ms → 8 s, ±25 % jitter) and restarts ICE on resume. You can listen in:

TypeScript
live.room.on('connection-state', (s) => {
  // s ∈ 'connected' | 'reconnecting' | 'disconnected' | 'failed'
});

live.room.on('connection-quality', (id, q) => {
  // q.label ∈ 'excellent' | 'good' | 'fair' | 'poor' | 'disconnected'
});

Error handling

TypeScript
import { LevelChatError } from '@levelchat/web';

try {
  await live.publishCamera();
} catch (err) {
  if (err instanceof LevelChatError) {
    if (err.code === 'media/permission-denied') promptForCamera();
    if (err.code === 'transport/ice-failed') retryWithTurn();
    if (err.code === 'room/full') showFullRoomMessage();
  }
}

Every error is a LevelChatError with .code, .message, and .retryable. The code namespace matches what the signaling server emits in error frames, so end-to-end log correlation is trivial. The full, stable LevelChatErrorCode enum:

CodeSubclassretryableWhen it fires
internalLevelChatErrornoAn unexpected SDK-internal failure.
config/invalidConfigErrornoBad config passed to new LevelChat(...) or joinRoom(...).
config/missing-api-keyConfigErrornoissueToken() called without an apiKey (it must run server-side).
token/invalidTokenErrornoThe room JWT is malformed or fails signature verification.
token/expiredTokenErroryesThe room JWT is past its expiry — mint a fresh one and retry.
token/unauthorizedTokenErrornoThe token's caps don't permit the attempted action.
signaling/connect-failedSignalingErroryesCould not open the signaling WebSocket.
signaling/closedSignalingErroryesThe signaling socket closed unexpectedly.
signaling/timeoutSignalingErroryesA signaling operation exceeded its deadline.
signaling/protocolSignalingErroryesThe server sent a frame the SDK could not parse.
signaling/send-queue-overflowSignalingErroryesOutbound signaling backed up beyond the queue cap.
signaling/rpc-errorSignalingErroryesAn SFU control-plane RPC returned an error frame.
signaling/rpc-timeoutSignalingErroryesAn SFU control-plane RPC got no reply before its deadline.
transport/ice-failedTransportErroryesICE never reached a connected state — usually needs TURN.
transport/dtls-failedTransportErroryesThe DTLS handshake failed.
transport/unsupportedTransportErroryesThe browser lacks a required WebRTC transport capability.
media/permission-deniedMediaErrornoThe user denied camera/mic permission.
media/device-not-foundMediaErrornoThe requested camera/mic/speaker id does not exist.
media/unsupportedMediaErrornoThe browser cannot satisfy the requested media capability.
media/constraint-not-satisfiedMediaErrornogetUserMedia constraints could not be met.
room/fullRoomErrornoThe room is at its participant cap.
room/endedRoomErrornoThe meeting has ended — joinLive / joinRoom / issueToken refuse a finished room. Terminal: start a new room.
room/kickedRoomErrornoThe local participant was removed by a moderator.
room/cap-deniedRoomErrornoThe action exceeds the plan's quota.
codec/unsupportedLevelChatErrornoNo mutually supported codec for a track.
encryption/key-missingEncryptionErrornoA frame arrived but no decryption key is set.
encryption/unsupportedEncryptionErrornoThe browser lacks Insertable Streams support.
recording/not-permittedRecordingErroryesThe token's caps don't permit recording.
recording/backend-failedRecordingErroryesThe recording service rejected or failed the request.
network/unreachableLevelChatErroryesThe LevelChat API/edge is unreachable.
network/rate-limitedLevelChatErroryesThe client is being rate-limited — back off and retry.

Use the isLevelChatError(x) helper when an error may have crossed a worker boundary (it checks the structural shape, not just instanceof).

Bundle size

The vanilla SDK is tree-shakeable — importing only LevelChat ships ~52 kB gz. @levelchat/web/headless (no DOM dependencies, used by bots and SSR) is 30 kB gz. @levelchat/web/noise-suppression is a separate entry point that lazy-loads ~250 kB of RNNoise WASM only when the operator flips on AI noise gating.

Browser support

BrowserMinimum versionNotes
Chrome / Chromium100+Full feature set including Insertable Streams
Edge100+Same as Chrome
Firefox115+All except SVC encoding (browser limitation)
Safari (macOS / iOS)16.4+Insertable Streams + AV1 decode (where hardware allows)
WebView (mobile in-app)Chrome 100+ engineTest the host app's WebView version
@levelchat/web — TypeScript SDK for browsers — LevelChat Docs