Controlled session
By default, <AvatarWidget> creates and owns its own LiveKit session. This is fine for 99% of cases. For two specific scenarios, you'll want to manage the session yourself and pass it in via controlledSession:
- Cross-page persistence — keep one session alive across routes that mount/unmount the widget
- Shared rooms — multiple widgets, one session (e.g., agent in two places on the same page)
When you need it
| Scenario | Default | Controlled |
|---|---|---|
| Single page, single widget | ✓ | unnecessary |
| SPA, agent should keep talking across routes | drops on unmount | survives |
| One agent, two surfaces (sidebar + main panel) | two sessions, two billings | one session |
| Custom auth flow (your server mints tokens) | works via sessionEndpoint | works either way |
Building a ControlledSession
The ControlledSession interface is a small contract. Implement it however suits your app — most teams build it on top of the useLiveKitSession hook.
type ControlledSession = {
agentId: string;
baseUrl?: string;
connectionState: ConnectionState;
agentState: AgentState;
transcript: TranscriptEntry[];
agentConfig: AgentConfig | null;
videoElement: HTMLVideoElement | null;
audioElement: HTMLAudioElement | null;
canResume: boolean;
error: string | null;
connect(): Promise<void>;
disconnect(): void;
sendData?(data: Record<string, unknown>): Promise<void>;
subscribeToDataMessages?(handler: (msg: Record<string, unknown>) => void): () => void;
};
Pattern: cross-page session via React Context
Build a provider once at the app root, consume it anywhere:
"use client";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { useLiveKitSession, type ControlledSession } from "@livelayer/react";
const Ctx = createContext<ControlledSession | null>(null);
export function AgentSessionProvider({
agentId,
children,
}: {
agentId: string;
children: ReactNode;
}) {
const session = useLiveKitSession({ agentId });
const controlled: ControlledSession = useMemo(
() => ({
agentId,
connectionState: session.connectionState,
agentState: session.agentState,
transcript: session.transcript,
agentConfig: session.agentConfig,
videoElement: session.videoElement,
audioElement: session.audioElement,
canResume: session.canResume,
error: session.error,
connect: session.connect,
disconnect: session.disconnect,
sendData: async (data) => {
const room = session.getRoom();
if (!room) return;
const encoder = new TextEncoder();
await room.localParticipant.publishData(
encoder.encode(JSON.stringify(data)),
{ reliable: true },
);
},
}),
[session, agentId],
);
return <Ctx.Provider value={controlled}>{children}</Ctx.Provider>;
}
export function useAgentSession() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useAgentSession used outside provider");
return ctx;
}
Then mount the provider once in your root layout:
"use client";
import { AgentSessionProvider } from "./agent-session-provider";
import { AvatarWidget } from "@livelayer/react";
import { useAgentSession } from "./agent-session-provider";
import { usePathname, useRouter } from "next/navigation";
function ConnectedWidget() {
const session = useAgentSession();
const pathname = usePathname();
const router = useRouter();
return (
<AvatarWidget
agentId={session.agentId}
controlledSession={session}
pathname={pathname}
onNavigate={(href) => router.push(href)}
/>
);
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<AgentSessionProvider agentId="agt_abc123">
{children}
<ConnectedWidget />
</AgentSessionProvider>
</body>
</html>
);
}
The widget can mount/unmount with route changes; the session in the provider keeps running.
Pattern: shared session, two surfaces
One agent, two render surfaces (e.g., a corner bubble AND an embedded panel):
function App() {
const session = useLiveKitSession({ agentId: "agt_abc123" });
const controlled: ControlledSession = {
agentId: "agt_abc123",
connectionState: session.connectionState,
agentState: session.agentState,
transcript: session.transcript,
agentConfig: session.agentConfig,
videoElement: session.videoElement,
audioElement: session.audioElement,
canResume: session.canResume,
error: session.error,
connect: session.connect,
disconnect: session.disconnect,
};
return (
<>
{/* Embedded in the hero */}
<AvatarWidget
controlledSession={controlled}
agentId="agt_abc123"
experienceMode="EMBEDDED"
/>
{/* AND a corner bubble */}
<AvatarWidget
controlledSession={controlled}
agentId="agt_abc123"
experienceMode="WIDGET"
position="bottom-right"
persistKey="ll-widget-corner"
/>
</>
);
}
Both widgets share the same session — the agent speaks once and is reflected on both surfaces. persistKey differs so each widget remembers its own minimized/expanded state.
Caveats
- Mic publishing:
useLiveKitSessiondoes NOT publish the mic by default. If you're consuming the hook directly, calluseMicrophoneStateand passroomfromsession.getRoom(). (The default<AvatarWidget>wires this for you; controlled mode means you take over.) - Lifecycle: when the controlled session is your responsibility, you decide when to call
connect()anddisconnect(). The widget will not auto-disconnect on unmount. - One agent per session: a single LiveKit room handles one agent at a time. To support team switching across routes, recreate the session when the agent changes.
Read next
useLiveKitSession— the underlying hookAvatarWidgetprops —controlledSessionand other advanced props