@talkwith/embed
v0.3.3
Published
React embed SDK for **TalkWith** — 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
@talkwith/embed
React embed SDK for TalkWith — 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 "@talkwith/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 @talkwith/embed
# peers you already have in a React app:
npm i react react-domVite apps: import the stylesheet once
The package's client entry imports LiveKit's component styles for you, and Next.js /
webpack apps load them automatically. Vite is the exception: its dependency
pre-bundler (esbuild optimizeDeps) drops CSS imports inside pre-bundled deps, so
<VideoRoom> renders unstyled. Fix it with one import in your app entry (e.g.
src/main.tsx):
import "@talkwith/embed/styles.css";That file is the exact @livekit/components-styles build this release was compiled
against, shipped first-party — don't import the transitive @livekit/components-styles
package from app code (it isn't your dependency, and strict/pnpm installs will refuse
to resolve it).
The 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 (@talkwith/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/talkwith-token/route.ts (Next.js App Router)
import { createToken } from "@talkwith/embed/server";
export async function POST(req: Request) {
const { room, identity } = await req.json();
const { token, serverUrl } = await createToken({
endpoint: process.env.TALKWITH_API_URL + "/token", // control-plane token endpoint
publishableKey: process.env.TALKWITH_PUBLISHABLE_KEY!,
secretKey: process.env.TALKWITH_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 "@talkwith/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} />Knowing when the user actually joined
Minting a token proves authorization, not a call — the media connection can still fail.
onConnected fires when the room connection is really established, so use it (not the
token mint) for join analytics, "first call" stamps, or billing-adjacent counters:
<VideoRoom serverUrl={serverUrl} token={token} onConnected={() => analytics.track("call_joined")} />In a headless tree the same signal is useVideoRoom().isConnected.
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 @talkwith/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 "@talkwith/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 "@talkwith/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 "@talkwith/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 "@talkwith/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 (@talkwith/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 onConnected (fires when the media connection is actually established — the truthful "user joined a call" signal), optional onError/onMediaDeviceFailure (passed through to <LiveKitRoom>), optional children (headless). |
| useVideoRoom() | hook (@talkwith/embed) | { room, participants, isConnected }. Inside a <VideoRoom> context only. |
| useParticipantMedia(identity) / ParticipantMedia | hook (@talkwith/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 (@talkwith/embed) | { isCameraEnabled, isMicrophoneEnabled, setCameraEnabled(on), setMicrophoneEnabled(on) } for the local participant. Inside a <VideoRoom> context only. |
| ParticipantVideo / ParticipantVideoProps | client component (@talkwith/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 (@talkwith/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 (@talkwith/embed) | { canPlayAudio, startAudio() } — autoplay-blocked recovery; call startAudio from a user gesture. Inside a <VideoRoom> context only. |
| createToken / CreateTokenInput / TokenResult | server-only fn (@talkwith/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
@talkwith/embed and @talkwith/mcp are versioned in lockstep for now:
the MCP scaffolds code that imports this package (the <VideoRoom> page + @talkwith/embed/server
token route), so a given MCP release expects the matching embed release. Install the same
version of both (e.g. @talkwith/[email protected] with @talkwith/[email protected]); a mismatch may
scaffold imports this package doesn't expose. See CHANGELOG.md for what changed.
Upgrading from visuality-embed
This package was previously published as visuality-embed (≤ 0.2.1); that name is deprecated
in favour of @talkwith/embed. The API is unchanged — swap the dependency
(npm rm visuality-embed && npm i @talkwith/embed) and rewrite imports of visuality-embed
and @talkwith/embed/server to @talkwith/embed and @talkwith/embed/server.
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 @talkwith/embed version patch # or minor / major
git push --follow-tags # fires .github/workflows/release.yml → npm (provenance)The package is scoped (publishConfig.access: "public" keeps it public) and builds dist/
(ESM + types) via pnpm --filter @talkwith/embed build — the release workflow runs it before
publishing. Full flow in docs/release-process.md.
