View as markdown

LiveKitSession

The session lifecycle class. Manages the LiveKit room, transcript, audio/video tracks. Use this when you want full control over the UI but don't want to reimplement the connection layer.

ts
import { LiveKitSession } from "@livelayer/sdk";

const session = new LiveKitSession(
  { agentId: "agt_abc123", baseUrl: "https://app.livelayer.studio" },
  callbacks, // see Callbacks below
);

Constructor

ts
new LiveKitSession(options: SessionOptions, callbacks: SessionCallbacks)
ts
interface SessionOptions {
  agentId: string;
  baseUrl: string;
  apiKey?: string;
  sessionEndpoint?: string;
  sessionBody?: Record<string, unknown>;
  automanifest?: boolean;      // default true
  publishMicrophone?: boolean; // default true (0.9+)
}
NameTypeDescription
agentId*stringPublished agent ID.
baseUrl*stringAPI base URL — typically https://app.livelayer.studio.
apiKeystringAPI key for cross-origin auth (sent as x-api-key header).
sessionEndpointstringOverride the default /api/widget/session URL. Use when your server mints LiveKit tokens.
sessionBodyRecord<string, unknown>Extra body fields included in the session POST. Use for passing your auth context (userId, plan, etc.).
automanifestbooleanDefault true. Scans the page for form fields and shares them (and what the visitor typed) with the agent. Passwords, card fields, anything inside .ll-widget and anything marked data-ll-private are never shared. Set false for no DOM observation.
publishMicrophonebooleanDefault true: the session publishes the visitor's mic once connected. Set false only if your own code publishes the mic, so the room never carries two microphone tracks. With false, mic-permission errors are yours to report.

Lifecycle

ts
async connect(): Promise<void>
async connectWithToken(url: string, token: string): Promise<void>
disconnect(): void
canResume(): boolean
getRoom(): Room | null
NameTypeDescription
connect() => Promise<void>Fetch a session token from sessionEndpoint, join the LiveKit room, subscribe to tracks.
connectWithToken(url, token) => Promise<void>Skip token fetch — pass a pre-fetched LiveKit URL and token. Use for preview surfaces or custom auth flows.
disconnect() => voidDisconnect from the room and clear transcript. Safe to call when already disconnected.
canResume() => booleanTrue if the session can be resumed via connect() (within the 5-min RESUME_WINDOW).
getRoom() => Room | nullAccess the underlying livekit-client Room. Use for advanced track management.

Callbacks

Pass the callbacks as the constructor's second argument. The first eight are required; the rest are optional.

ts
const callbacks: SessionCallbacks = {
  onConnectionStateChange: (state) => {
    console.log("connection:", state);
    // "idle" | "connecting" | "connected" | "error" | "disconnected"
  },
  onAgentStateChange: (state) => {
    console.log("agent:", state);
    // "idle" | "listening" | "thinking" | "speaking"
  },
  onTranscript: (entries) => {
    for (const entry of entries) {
      if (entry.final) console.log(`[${entry.role}] ${entry.text}`);
    }
  },
  onAgentConfig: (config) => {
    // { name, avatarImageUrl, idleLoopUrl? }
    document.querySelector("#agent-name")!.textContent = config.name;
  },
  onAudioTrack: (audioElement) => {
    // Mount this somewhere in the DOM to play audio
    document.body.appendChild(audioElement);
  },
  onVideoTrack: (videoElement) => {
    // Mount this where you want the agent's avatar video
    document.querySelector("#avatar-container")!.appendChild(videoElement);
  },
  onVideoTrackRemoved: () => {
    // Track ended (the element is already detached); show your still image
  },
  onError: (message, code) => {
    console.error("session error:", code, message);
  },

  onDataMessage: (msg) => {
    // JSON the agent published without a topic (agent_state, captions,
    // avatar_active / avatar_idle, commands, ...)
    console.log("data:", msg);
  },
  onAvatarEvent: (msg, { topic, participantIdentity }) => {
    // The avatar worker's own events on the "lemonslice" topic
    // (bot_ready, idle_timeout, error, metric, ...). 0.9+
  },
  onAvatarDisconnected: ({ identity, reason }) => {
    // The avatar left; the session stays up and the agent carries on
    // voice-only. onVideoTrackRemoved has already fired. 0.9+
  },
  onResumabilityChange: (canResume) => {
    // Fires on connect, expiry, reset
  },
};

Which data messages arrive (0.9+)

onDataMessage only receives JSON objects that the agent participant published without a topic. The avatar worker's packets on the lemonslice topic go to onAvatarEvent instead, so the avatar's own idle_timeout is never mistaken for the agent ending the session. Everything else (other participants, other topics) is dropped, so nobody but the agent can drive agent commands. A packet that arrives before its sender has joined is held for up to 5 seconds and then delivered by the same rules.

If you need data on your own topic, listen on the room directly: session.getRoom()?.on(RoomEvent.DataReceived, ...).

When the session ends (0.9+)

The session ends when the agent leaves the room (or you call disconnect()). The avatar leaving does not end it: you get onVideoTrackRemoved, then onAvatarDisconnected, and the conversation continues as voice only.

Error codes

onError receives an optional code:

CodeMeaning
key_wrong_orgAPI key doesn't match the agent's organization
key_expiredAPI key revoked or expired
agent_has_no_orgAgent isn't published yet
MIC_PERMISSION_DENIEDVisitor denied mic access
MIC_NOT_FOUNDNo mic detected on the visitor's device
AGENT_TIMEOUTAgent didn't respond in time (server-side issue)
ROOM_DISCONNECTNetwork or server disconnected the room

Minimal end-to-end example

ts
import { LiveKitSession } from "@livelayer/sdk";

const transcript = document.querySelector("#transcript")!;
const avatar = document.querySelector("#avatar")!;

const session = new LiveKitSession(
  { agentId: "agt_abc123", baseUrl: "https://app.livelayer.studio" },
  {
    onConnectionStateChange: () => {},
    onAgentStateChange: () => {},
    onAgentConfig: () => {},
    onError: (message) => console.error(message),
    onTranscript: (entries) => {
      transcript.innerHTML = entries
        .filter((e) => e.final)
        .map((e) => `<p><strong>${e.role}:</strong> ${e.text}</p>`)
        .join("");
    },
    onAudioTrack: (audio) => document.body.appendChild(audio),
    onVideoTrack: (video) => avatar.appendChild(video),
    onVideoTrackRemoved: () => {},
  },
);

document.querySelector("#talk")!.addEventListener("click", () => {
  session.connect();
});

document.querySelector("#end")!.addEventListener("click", () => {
  session.disconnect();
});

Mic publishing

LiveKitSession publishes the visitor's mic as part of connecting (it asks for permission then, and reports MIC_PERMISSION_DENIED / MIC_NOT_FOUND through onError). Mute and unmute it through the room:

ts
await session.getRoom()?.localParticipant.setMicrophoneEnabled(false);

Don't publish a second mic track yourself on top of that. If your code owns the mic (custom device handling, a push-to-talk gate), pass publishMicrophone: false (0.9+) and publish it once connected:

ts
import { createLocalAudioTrack } from "livekit-client";

const session = new LiveKitSession(
  { agentId: "agt_abc123", baseUrl: "https://app.livelayer.studio", publishMicrophone: false },
  {
    ...callbacks,
    onConnectionStateChange: async (state) => {
      if (state !== "connected") return;
      const room = session.getRoom();
      if (!room) return;
      const micTrack = await createLocalAudioTrack({ echoCancellation: true, noiseSuppression: true });
      await room.localParticipant.publishTrack(micTrack);
    },
  },
);

@livelayer/react's <AvatarWidget> does exactly this: its own session runs with publishMicrophone: false and its mic hook publishes and gates the one track (0.26+).

Persistence

The SDK includes helpers to save and resume sessions across page loads (within a 5-minute window):

ts
import { saveSession, loadSession, clearSession } from "@livelayer/sdk";

// On disconnect — save state for resume
session.onConnectionStateChange = (state) => {
  if (state === "connected") {
    const room = session.getRoom();
    if (room) saveSession({ token: "...", roomName: room.name, ts: Date.now() });
  }
  if (state === "disconnected") {
    clearSession();
  }
};

// On boot — try to resume
const saved = loadSession();
if (saved) {
  // Within 30s grace period
  await session.connectWithToken(saved.url, saved.token);
}