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.
import { LiveKitSession } from "@livelayer/sdk";
const session = new LiveKitSession(
{ agentId: "agt_abc123", baseUrl: "https://app.livelayer.studio" },
callbacks, // see Callbacks below
);
Constructor
new LiveKitSession(options: SessionOptions, callbacks: SessionCallbacks)
interface SessionOptions {
agentId: string;
baseUrl: string;
apiKey?: string;
sessionEndpoint?: string;
sessionBody?: Record<string, unknown>;
automanifest?: boolean; // default true
publishMicrophone?: boolean; // default true (0.9+)
}
| Name | Type | Description |
|---|---|---|
| agentId* | string | Published agent ID. |
| baseUrl* | string | API base URL — typically https://app.livelayer.studio. |
| apiKey | string | API key for cross-origin auth (sent as x-api-key header). |
| sessionEndpoint | string | Override the default /api/widget/session URL. Use when your server mints LiveKit tokens. |
| sessionBody | Record<string, unknown> | Extra body fields included in the session POST. Use for passing your auth context (userId, plan, etc.). |
| automanifest | boolean | Default 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. |
| publishMicrophone | boolean | Default 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
async connect(): Promise<void>
async connectWithToken(url: string, token: string): Promise<void>
disconnect(): void
canResume(): boolean
getRoom(): Room | null
| Name | Type | Description |
|---|---|---|
| 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 | () => void | Disconnect from the room and clear transcript. Safe to call when already disconnected. |
| canResume | () => boolean | True if the session can be resumed via connect() (within the 5-min RESUME_WINDOW). |
| getRoom | () => Room | null | Access 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.
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:
| Code | Meaning |
|---|---|
key_wrong_org | API key doesn't match the agent's organization |
key_expired | API key revoked or expired |
agent_has_no_org | Agent isn't published yet |
MIC_PERMISSION_DENIED | Visitor denied mic access |
MIC_NOT_FOUND | No mic detected on the visitor's device |
AGENT_TIMEOUT | Agent didn't respond in time (server-side issue) |
ROOM_DISCONNECT | Network or server disconnected the room |
Minimal end-to-end example
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:
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:
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):
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);
}
Read next
<livelayer-widget>— UI on top ofLiveKitSessionLiveLayerTracker— visitor tracking- TypeScript types — all session-related types