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 widget✓unnecessary
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.