visuality-embed
v0.2.1
Published
React embed SDK for **Visuality** — a prebuilt `<VideoRoom />`, headless hooks, and a **server-only** token helper. It wraps LiveKit's tested components (including their Safari/iOS workarounds) so you get a working call with a token and a server URL.
Readme
visuality-embed
React embed SDK for Visuality — a prebuilt <VideoRoom />, headless hooks, and a
server-only token helper. It wraps LiveKit's tested components (including their
Safari/iOS workarounds) so you get a working call with a token and a server URL.
The client contract (start here)
The <VideoRoom> component needs exactly two strings: { token, serverUrl }. That's the
whole contract. Get them from your own authenticated endpoint — whatever proves who
the caller is in your app — and hand them to the component:
"use client";
import { VideoRoom } from "visuality-embed";
export function Call({ token, serverUrl }: { token: string; serverUrl: string }) {
return <VideoRoom serverUrl={serverUrl} token={token} onLeave={() => history.back()} />;
}Minting that token is entirely up to you and is framework-agnostic: any runtime that
can make an authenticated HTTP POST to the control plane's token endpoint works —
Node, Deno, Bun, a Cloudflare Worker, or a Python/Go/Rails service that calls the same
endpoint. This package ships a small createToken helper for the POST-from-Node case
(see below), but it is a convenience, not a requirement — you can call the token
endpoint however you like, from whatever server you already have.
Usually you don't hand-wire any of this: point your coding agent at the setup MCP and it scaffolds a
<VideoRoom />page + a Next.js token route into your app for you (see the top-level README). The Next.js route below is that scaffold's convenience implementation of "an authenticated endpoint that calls the token endpoint" — not the only valid one. This doc is the manual reference for what the SDK exposes.
Install
npm i visuality-embed
# peers you already have in a React app:
npm i react react-domThe two halves: server mints the token, client joins
A publishable key can ship to the browser; the secret key must not. So token minting runs on your server, and the component runs in the browser with the resulting token.
1 · Server — mint a scoped token
createToken exchanges your project's secret key for a short-lived, room-scoped LiveKit
token. It throws if called in a browser — keep it in a route handler / server action.
createToken lives in a separate server entry (visuality-embed/server) so importing it
never pulls the client <VideoRoom> (and @livekit/components-react) into your server layer —
that would throw createContext only works in Client Components and 500 the route.
// app/api/visuality-token/route.ts (Next.js App Router)
import { createToken } from "visuality-embed/server";
export async function POST(req: Request) {
const { room, identity } = await req.json();
const { token, serverUrl } = await createToken({
endpoint: process.env.VISUALITY_API_URL + "/token", // control-plane token endpoint
publishableKey: process.env.VISUALITY_PUBLISHABLE_KEY!,
secretKey: process.env.VISUALITY_SECRET_KEY!, // server-only — never NEXT_PUBLIC_*
room,
identity,
template: "group", // a RoomTemplate from @vt/shared: "one_to_one" | "group" | "recording"
});
return Response.json({ token, serverUrl });
}This is one implementation of "an authenticated endpoint" — Next.js isn't required.
Any server that can call the control plane's token endpoint with your secret key and
return { token, serverUrl } to the client satisfies the contract above.
2 · Client — drop in the room
"use client";
import { VideoRoom } from "visuality-embed";
export function Call({ token, serverUrl }: { token: string; serverUrl: string }) {
return <VideoRoom serverUrl={serverUrl} token={token} onLeave={() => history.back()} />;
}<VideoRoom> connects with audio+video and renders LiveKit's prebuilt VideoConference
UI by default.
Controlling camera & mic on join
<VideoRoom> publishes both by default. To join with the camera off (no permission
prompt, no frame published), pass video={false}:
<VideoRoom serverUrl={serverUrl} token={token} audio={micOn} video={cameraOn} />Handling errors
<VideoRoom> accepts onError and onMediaDeviceFailure, passed straight through to
LiveKit's <LiveKitRoom>. Without them, connection errors and camera/mic acquisition
failures are only visible in the console:
<VideoRoom
serverUrl={serverUrl}
token={token}
onError={(error) => toast.error(`Call error: ${error.message}`)}
onMediaDeviceFailure={(failure) => toast.error(`Camera/mic error: ${failure}`)}
/>createToken (server side) also surfaces more detail on a failed token request: if the
control plane's error response has a JSON body with a string error field, the thrown
message includes it — e.g. token request failed: 401 (revoked_key) — so you can log or
branch on the specific failure instead of just the HTTP status.
For full control over the room, render LiveKit's <LiveKitRoom> directly (it is a
direct dependency of this package) and use createToken from visuality-embed/server
for the token — the control plane, not the wrapper, is the product.
Going headless
Pass children to replace the prebuilt UI, and read live state with useVideoRoom()
(must be used inside a <VideoRoom>):
import { VideoRoom, useVideoRoom } from "visuality-embed";
function Roster() {
const { participants, isConnected } = useVideoRoom();
return <div>{isConnected ? `${participants.length} on the call` : "connecting…"}</div>;
}
<VideoRoom serverUrl={serverUrl} token={token}>
<Roster />
</VideoRoom>;For a real custom UI you'll want per-participant media — tracks, camera/mic state,
active speaker — without importing LiveKit yourself. useParticipantMedia(identity)
gives you that, and <ParticipantVideo track={...}> renders a track anywhere in
your tree (an avatar, a portaled tile) with no provider ancestor required:
import { VideoRoom, useVideoRoom, useParticipantMedia, ParticipantVideo } from "visuality-embed";
function FaceTile({ identity }: { identity: string }) {
const { cameraTrack, isMicrophoneEnabled, isSpeaking } = useParticipantMedia(identity);
return (
<div className={isSpeaking ? "tile speaking" : "tile"}>
{/* 48px avatar, a portal, wherever — ParticipantVideo needs no LiveKit context */}
<ParticipantVideo track={cameraTrack} muted style={{ width: 48, height: 48, borderRadius: "50%" }} />
{isMicrophoneEnabled ? null : <span aria-label="muted">🔇</span>}
</div>
);
}
function Grid() {
const { participants } = useVideoRoom();
return <>{participants.map((p) => <FaceTile key={p.identity} identity={p.identity} />)}</>;
}Audio. A headless tree renders no <VideoConference>, so nothing plays remote audio
until you add it. Drop <RoomAudio /> in once (hidden <audio> elements for every remote
participant), and use useAudioPlayback for the browser autoplay block — audio can't start
before a user gesture:
import { RoomAudio, useAudioPlayback } from "visuality-embed";
function AudioUnlock() {
const { canPlayAudio, startAudio } = useAudioPlayback();
if (canPlayAudio) return null;
return (
<button type="button" onClick={() => void startAudio()}>
Tap for sound
</button>
);
}
<VideoRoom serverUrl={serverUrl} token={token}>
<RoomAudio />
<AudioUnlock />
<Grid />
</VideoRoom>;Local device controls (join muted, build your own mic/camera buttons):
import { useLocalDevices } from "visuality-embed";
function MicButton() {
const { isMicrophoneEnabled, setMicrophoneEnabled } = useLocalDevices();
return (
<button type="button" onClick={() => void setMicrophoneEnabled(!isMicrophoneEnabled)}>
{isMicrophoneEnabled ? "Mute" : "Unmute"}
</button>
);
}API
| Export | Kind | Notes |
|---|---|---|
| VideoRoom / VideoRoomProps | client component (visuality-embed) | serverUrl, token, optional onLeave, optional theme (defaults to the bundled "default" LiveKit theme), optional audio/video (both default true; set false to join with mic/camera off), optional onError/onMediaDeviceFailure (passed through to <LiveKitRoom>), optional children (headless). |
| useVideoRoom() | hook (visuality-embed) | { room, participants, isConnected }. Inside a <VideoRoom> context only. |
| useParticipantMedia(identity) / ParticipantMedia | hook (visuality-embed) | { participant, cameraTrack, microphoneTrack, screenShareTrack, isCameraEnabled, isMicrophoneEnabled, isSpeaking }, updated on room events (shallow-compared — no re-render churn). Inside a <VideoRoom> context only. |
| useLocalDevices() / LocalDevices | hook (visuality-embed) | { isCameraEnabled, isMicrophoneEnabled, setCameraEnabled(on), setMicrophoneEnabled(on) } for the local participant. Inside a <VideoRoom> context only. |
| ParticipantVideo / ParticipantVideoProps | client component (visuality-embed) | Renders one video track: track (a Track or TrackPublication; null renders an empty element), optional muted/className/style. Context-free — works anywhere in the tree, no LiveKit provider needed. |
| RoomAudio | client component (visuality-embed) | Plays every remote participant's audio (hidden <audio> elements) — required in a headless tree, since nothing else plays sound. Inside a <VideoRoom> context only. |
| useAudioPlayback() / AudioPlayback | hook (visuality-embed) | { canPlayAudio, startAudio() } — autoplay-blocked recovery; call startAudio from a user gesture. Inside a <VideoRoom> context only. |
| createToken / CreateTokenInput / TokenResult | server-only fn (visuality-embed/server) | Returns { token, serverUrl }. Throws in a browser. On a non-2xx response, best-effort includes the server's error code in the thrown message. fetchImpl is injectable for tests. |
Security
- Never expose the secret key to the client — no
NEXT_PUBLIC_/VITE_prefix, no bundling it into client code.createTokenthrows if it detects a browser (window), but that runtime guard is only a backstop — it can't catch every bundler/edge runtime. The reliable guarantee is to keepcreateTokenin a server-only module: addimport "server-only";at the top of the file that calls it (Next.js), so a build fails loudly if that module is ever pulled into a client bundle. Treat the secret key as server env only. - The publishable key (
x-vt-key) is safe in the browser; it identifies the project, it doesn't authorize minting.
Versioning & compatibility
visuality-embed and visuality-mcp are versioned in lockstep for now:
the MCP scaffolds code that imports this package (the <VideoRoom> page + visuality-embed/server
token route), so a given MCP release expects the matching embed release. Install the same
version of both (e.g. [email protected] with [email protected]); a mismatch may
scaffold imports this package doesn't expose. See CHANGELOG.md for what changed.
Releasing
This package is versioned deliberately (not on every merge to main) so consumers'
package.json don't move under them. Cut a release with a v* tag:
pnpm --filter visuality-embed version patch # or minor / major
git push --follow-tags # fires .github/workflows/release.yml → npm (provenance)The package is unscoped (public by default) and builds dist/ (ESM + types) via
pnpm --filter visuality-embed build — the release workflow runs it before publishing. Full flow
in docs/release-process.md.
