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 publishes the visitor's mic when it connects (publishMicrophone defaults to true), and in controlled mode the widget's mic button mutes that track through getRoom(). If your host publishes its own mic instead, pass publishMicrophone: false so the room carries one microphone track, not two (0.26+).
  • Data messages: useLiveKitSession only forwards JSON the agent published without a topic (0.26+, via @livelayer/sdk 0.9). If you feed subscribeToDataMessages from your own Room, apply the same filter: the avatar worker's lemonslice-topic events include its own idle_timeout, which is not the agent ending the session.
  • 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.