Experience API
Programmatic control over Avatar Experience sessions.
Overview
Experience is the primary JavaScript API. Use it when you need programmatic control
beyond the component — custom containers, event handling, presenter sequencing, or browser-owned
conversation logic.
Start a session
import { Experience } from '@liforma/client';
const experience = await Experience.startSession({
experienceId: 'exp_01DEMO1SPANISHCAFE'
});
await experience.attach({ container: '#avatar' }); Session inputs
| Input | Use case |
|---|---|
{ experienceId } | Public embed — SDK calls /v1/public-sessions |
{ manifest } | Server-minted manifest passed to the client |
{ experienceId, sessionEndpoint } | Authenticated — SDK POSTs to your same-origin route |
Public configuration
Pass these options to Experience.startSession(). They are forwarded when the SDK
mints a public session.
| Option | Values | Purpose |
|---|---|---|
mode | conversation | presenter | Managed conversation or host-scripted presentation. |
speechInputMode | auto | manual | off | Automatic turn detection, explicit listening boundaries, or no microphone. |
conversationProcessor | Browser function | Replace the managed LLM with your own logic. The SDK mints responseMode: 'custom' and never serializes the function. See Custom Conversation Processor. |
onUserTranscript | Callback | Observe partial and final STT updates. Observation only — returned values do not become speech automatically. |
startButton | Button label, accessibility, placement, variant, and appearance tokens | Customizes the player-owned startup control. |
Conversation sessions default to conversation, managed responses, and auto input. Presenter sessions default to presenter, manual responses,
and manual input.
Do not pass responseMode directly for browser processors — supply conversationProcessor instead. Registered server processors (opaque processorId) are a separate upcoming capability.
Player-owned startup
The embedded player always renders and owns the actual startup click. Browsers require user activation inside the cross-origin player frame before it can unlock audio, so a button in the host page cannot replace this interaction.
Use the startButton property on StartSessionOptions to adapt the
built-in control to your interface. Supported placement values are center, bottom-center, bottom-left, and bottom-right. Supported
variants are primary, secondary, and minimal.
const experience = await Experience.startSession({
experienceId: 'exp_01DEMO1SPANISHCAFE',
mode: 'presenter',
startButton: {
label: 'Begin lesson',
ariaLabel: 'Begin the guided lesson',
placement: 'bottom-center', // center | bottom-center | bottom-left | bottom-right
variant: 'primary', // primary | secondary | minimal
appearance: {
backgroundColor: '#635bff',
textColor: '#ffffff',
borderColor: '#8179ff',
borderRadiusPx: 12,
size: 'large',
shadow: 'soft'
}
}
});
experience.on('started', async ({ mode }) => {
if (mode === 'presenter') {
await experience.speak({ text: 'Welcome to the lesson.' });
}
});
await experience.attach({
container: document.querySelector('#avatar')
}); started fires after the player button is clicked and audio is unlocked. In presenter
mode, it is the safe point for host code to call speak(). Calling speak(), startListening(), stopListening(), or listenOnce() before started throws a clear error. Button appearance is
limited to the documented tokens; arbitrary CSS is not supported across the player frame.
Speech API
Call attach() first, wait for started, then use the methods below. See Events for characterSpeechStarted, userTranscript, userSpeechStarted, and related handlers.
Prerequisites
Speech methods throw if the session has not started. Register experience.on('started', …) (or the onStart option on startSession) and only call speech methods after
the player-owned start button unlocks audio.
speak()
Queue animated character speech without invoking the managed LLM. Resolves when playback
completes. An interrupted active or queued call rejects with AbortError.
const result = await experience.speak({
text: 'Repeat after me: Buenos días.',
characterId: 'char_…', // optional — defaults to activeCharacterId
behavior: 'enqueue' // optional — enqueue (default) or interrupt
});
console.log(result.turnId, result.durationMs); | Option | Type | Description |
|---|---|---|
text | string | Line for the character to speak. |
characterId | string (optional) | Defaults to activeCharacterId from the manifest. |
behavior | enqueue | interrupt | enqueue waits for current speech (default). interrupt stops
active playback and clears the queue before speaking. |
Returns SpeechResult: speechId, turnId, characterId, text, and durationMs.
startListening() and stopListening()
Open and close a logical microphone gate. The player acquires the physical microphone once during
startup; stopListening() finalizes the utterance without stopping the media track.
Use with speechInputMode: 'manual' so learner pauses do not end the utterance.
await experience.startListening();
// Learner speaks; pauses do not end the utterance in manual mode.
const utterance = await experience.stopListening();
console.log(utterance.utteranceId, utterance.text); stopListening() returns UtteranceResult with utteranceId and text. In managed conversation mode, a finalized manual
utterance still triggers the configured processor. In presenter mode, the host decides what to do
with the text.
listenOnce()
Convenience for automatic end-of-speech capture. Requires speechInputMode: 'auto'.
Opens one listening gate, waits for VAD to finalize a single utterance, then resolves with UtteranceResult. Use for quiz answers, voice forms, or presenter flows where the host
owns the turn loop but does not want Start/Stop buttons.
const experience = await Experience.startSession({
experienceId: 'exp_01DEMO1SPANISHCAFE',
mode: 'presenter',
speechInputMode: 'auto'
});
experience.on('started', async () => {
await experience.speak({ text: 'What is your party size?' });
const answer = await experience.listenOnce({ timeoutMs: 15_000 });
console.log(answer.utteranceId, answer.text);
});
await experience.attach({ container: '#avatar' }); | Option | Type | Description |
|---|---|---|
timeoutMs | number (optional) | Reject if no finalized utterance within this window (default 30s). |
signal | AbortSignal (optional) | Abort the wait; rejects with AbortError. |
Only one listenOnce() may be active at a time. Timeout, abort, and permission failures
reject without adding a final user message to conversation history. See Listen Once Capture.
conversationProcessor
Supply a browser function on Experience.startSession() to replace Liforma's managed
LLM for that session. The player still orchestrates turns in conversation mode with speechInputMode: 'auto'; your function decides what the character should say next.
const experience = await Experience.startSession({
experienceId: 'exp_01DEMO1SPANISHCAFE',
mode: 'conversation',
speechInputMode: 'auto',
conversationProcessor: async ({ text, conversation, signal }) => {
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
if (text.toLowerCase().includes('checkout')) {
return 'Checkout is at 11am. Need a late checkout?';
}
if (text.toLowerCase().includes('breakfast')) {
return 'Breakfast is served until 10 in the lounge.';
}
return 'I can help with checkout, breakfast, or directions.';
}
});
experience.on('conversationProcessorError', ({ utteranceId, message }) => {
console.error('Processor failed', utteranceId, message);
});
await experience.attach({ container: '#avatar' }); The processor receives an immutable conversation snapshot, the finalized user text, utteranceId, and an AbortSignal cancelled when speak({ behavior: 'interrupt' }) or destroy() aborts in-flight work.
Return a string, an object with text (and optional characterId, behavior), or an AsyncIterable<string> for streamed replies.
conversationProcessor: async function* ({ text, signal }) {
const chunks = buildStoryChunks(text);
for (const chunk of chunks) {
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
yield chunk;
}
} Processor failures emit conversationProcessorError and do not fall back to the managed
LLM. Full walkthrough: Custom Conversation Processor.
getConversation() and getLastTurn()
Read the flat in-session history as an ordered ConversationMessage[]. Each message
has messageId, turnId, role (user or assistant), text, source (user, llm, speak, or opening), status, timestamp, and optional characterId for assistant lines.
const history = experience.getConversation();
const lastTurn = experience.getLastTurn();
experience.on('conversationUpdate', (conversation) => {
console.log('History length', conversation.length);
}); getConversation() returns the current snapshot. getLastTurn() returns
messages sharing the latest turnId. Subscribe to conversationUpdate for push updates when history changes.
For scripted lessons with manual Start/Stop, see Guided Scripted Practice and the guided-practice example on examples.liforma.ai.
Rendering is separate
Session creation and rendering configuration are independent. Attach the experience to a container to mount the player, display the avatar, and present its startup control.