View as markdown

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:

  1. Cross-page persistence — keep one session alive across routes that mount/unmount the widget
  2. Shared rooms — multiple widgets, one session (e.g., agent in two places on the same page)

When you need it

ScenarioDefaultControlled
Single page, single widgetunnecessary
SPA, agent should keep talking across routesdrops on unmountsurvives
One agent, two surfaces (sidebar + main panel)two sessions, two billingsone session
Custom auth flow (your server mints tokens)works via sessionEndpointworks 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.

ts
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:

tsx
"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:

tsx
"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):

tsx
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: useLiveKitSession does NOT publish the mic by default. If you're consuming the hook directly, call useMicrophoneState and pass room from session.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() and disconnect(). 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.