
Tuning Into Reality
Connecting to the world
Waiting for the first sceneโฆ
Waiting for the first sceneโฆ
Public chatShow chat
LivePrivate chat with
LivePrivate chat with
LivePrivate chat with
Give your characters a personality, a voice, and a face. Build text chat, natural voice conversations, or a live video avatar in your own app.
Uncaged Worlds uses the Sequencer API. Your account, API keys, workspaces, and balance are shared across both products.
https://api.sequencer.media/v1You need an Uncaged Worlds account, an API key, a workspace, an image hosted at a public HTTPS URL, and a trusted backend. Add balance before starting paid sessions.
/workspaces, then the workspace's preset voices. Save their IDs.liveCharacter.id.export UNCAGED_API_KEY="sk_your_api_key"
export UNCAGED_API_BASE="https://api.sequencer.media/v1"
curl --fail-with-body "$UNCAGED_API_BASE/workspaces" \
-H "x-api-key: $UNCAGED_API_KEY"Send your API key in the x-api-key header on every REST request. JSON bodies also need Content-Type: application/json.
x-api-key: sk_your_api_key
Content-Type: application/jsonCreate, list, and revoke keys in your account. Save the secret when it is created, it is shown once. A revoked key can take up to one minute to stop working across API instances.
Sequencer's signed-in clients can also use Authorization: Bearer <firebase_id_token>. Third-party server integrations should use an API key. Firebase ID tokens and session connection tokens are different credentials.
Characters and sessions belong to a workspace. GET /workspaces returns accessible workspaces in workspaces[]. Choose its id, then use it in every workspace-scoped URL.
# Use an id from workspaces[].id, not a workspace name or URL slug.
export UNCAGED_WORKSPACE_ID="your_workspace_id"
curl --fail-with-body "$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-character-voices/presets" \
-H "x-api-key: $UNCAGED_API_KEY"
# Select voices[].voiceId from the returned catalog.
export UNCAGED_VOICE_ID="voice_id_from_catalog"Your key has the account's workspace access. Listing resources requires read access. Use a workspace where your account can create, update, prepare, and launch characters.
| Field | Type | Details |
|---|---|---|
workspaceId | string | From workspaces[].id. Use the ID, not the name or public URL slug. |
characterId | string | From liveCharacter.id when creating or getting a character. |
voiceId | string | From voices[].voiceId. Pass it as providerVoiceId. |
sessionId | string | A new active connection and its billing lifecycle. Use for messages and end. |
conversationId | string | Identifier for this call context. Keep your own saved conversation ID on your backend. |
The integration has three parts: your browser UI, your authenticated backend, and the Sequencer API. Your backend holds the key and manages sessions for your visitors. The browser connects directly to the returned WebSocket or LiveKit room.
Set UNCAGED_API_KEY, UNCAGED_WORKSPACE_ID, UNCAGED_CHARACTER_ID, and APP_ORIGIN on your server. Set APP_ORIGIN to your web app's exact origin, including scheme and port. Use it as the session-start Origin so the browser connects from the matching origin.
// avatar-api.js, server only, Node.js 20+
const BASE = 'https://api.sequencer.media/v1';
const key = process.env.UNCAGED_API_KEY;
const workspace = process.env.UNCAGED_WORKSPACE_ID;
const character = process.env.UNCAGED_CHARACTER_ID;
if (!key || !workspace || !character) throw new Error('Missing avatar configuration');
const wsPath = '/workspaces/' + encodeURIComponent(workspace);
export async function avatarApi(path, { method = 'GET', body, origin } = {}) {
const response = await fetch(BASE + wsPath + path, {
method,
headers: {
'x-api-key': key,
'Content-Type': 'application/json',
// For sessions, set this to your configured web app origin.
...(origin ? { Origin: origin } : {})
},
...(body ? { body: JSON.stringify(body) } : {})
});
const data = await response.json();
if (!response.ok) {
const error = new Error(data.error || 'Avatar API request failed');
error.status = response.status;
error.retryAfter = response.headers.get('Retry-After');
throw error;
}
return data;
}
export function startAvatar(userId, channels, conversationHistory = [], startToken) {
return avatarApi('/live-characters/' + encodeURIComponent(character) + '/sessions', {
method: 'POST',
origin: process.env.APP_ORIGIN, // e.g. https://your-app.example
body: {
launchMode: 'webrtc', sessionMode: 'conversation',
channels, endUserId: userId, maxSessionSeconds: 300,
saveHistory: false,
...(conversationHistory.length ? { conversationHistory } : {}),
...(startToken ? { startToken } : {})
}
});
}
export function endAvatar(sessionId) {
return avatarApi('/live-character-sessions/' + encodeURIComponent(sessionId) + '/end', {
method: 'POST', body: { reason: 'USER_CLOSED' }
});
}Install Express and mount this router at /api/avatar. Replace your-app-auth.js with your app's authentication middleware and durable session ownership and conversation stores. This import is an integration point, not an included library. Add per-user quotas and your error handler.
// Express example, mount these on your own backend.
import express from 'express';
import { startAvatar, endAvatar } from './avatar-api.js';
import { requireSignedInUser, sessionStore, historyStore } from './your-app-auth.js';
const router = express.Router();
router.use(express.json(), requireSignedInUser);
const modes = {
text: { text: true, voice: false, video: false },
voice: { text: true, voice: true, video: false },
video: { text: true, voice: true, video: true }
};
router.post('/session', async (req, res, next) => {
try {
const channels = modes[req.body.mode];
if (!channels) return res.status(400).json({ error: 'Unknown mode' });
// req.user.id comes from your authentication middleware.
// historyStore is your own database, scoped by authenticated user and avatar.
const history = await historyStore.recent({
userId: req.user.id, workspaceId: process.env.UNCAGED_WORKSPACE_ID,
characterId: process.env.UNCAGED_CHARACTER_ID, limit: 60, maxBytes: 65536
});
const session = await startAvatar(req.user.id, channels, history);
try {
await sessionStore.save(session.sessionId, { userId: req.user.id });
} catch (error) {
await endAvatar(session.sessionId);
throw error;
}
res.json(session); // Session credentials only, never your API key.
} catch (error) { next(error); }
});
router.post('/session/:id/end', async (req, res, next) => {
try {
const owned = await sessionStore.get(req.params.id);
if (owned?.userId !== req.user.id) return res.sendStatus(404);
res.json(await endAvatar(req.params.id));
} catch (error) { next(error); }
});
export default router; // app.use('/api/avatar', router)
// Add your app's error handler to return error.status and a safe message.Create one character and reuse it across text, voice, and video. A character needs an image, a voice, and a system prompt even when its first experience is text-only.
curl --fail-with-body -X POST \
"$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-characters" \
-H "x-api-key: $UNCAGED_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Avery\",
\"avatarImageUrl\": \"https://your-cdn.example/avery.jpg\",
\"voiceSource\": \"preset\",
\"providerVoiceId\": \"$UNCAGED_VOICE_ID\",
\"systemPrompt\": \"You are Avery, a friendly, playful guide. Speak casually and keep replies to one or two sentences.\",
\"runtimeProvider\": \"sequencer\",
\"animaMode\": \"fast\",
\"language\": \"en\",
\"maxSessionMinutes\": 5
}"
# Save liveCharacter.id from the response.
export UNCAGED_CHARACTER_ID="your_character_id"The response wraps the character in liveCharacter. Save liveCharacter.id. A new Sequencer character starts as a draft. Text and preset-voice sessions can use the lightweight conversation service without preparing video assets.
| Field | Type | Details |
|---|---|---|
name | string, required | Display name. |
avatarImageUrl | HTTPS URL, required | Publicly retrievable image. Imported into workspace-owned storage. |
systemPrompt | string, required | Role, personality, tone, response length, and behavior. |
voiceSource | preset | custom | Preset needs providerVoiceId. Custom needs reference URL and description. |
providerVoiceId | string | Required for a preset, use a voiceId from the catalog. |
runtimeProvider | sequencer | runway | Use sequencer for the examples in this guide. |
animaMode | fast | max | Video quality. Start with fast and read returned rates for costs. |
maxSessionMinutes | number | Character duration limit. Defaults to 5 minutes. |
language | string | Language code, for example en. Defaults to en. |
Use a clear, well-lit portrait with one unobstructed face and room around the head and shoulders. A straight-on pose is a good starting point. JPEG, PNG, and WebP are practical choices. Image imports allow up to 25 MB.
The URL must return the file itself over HTTPS with a supported content type, without cookies or login. Private network URLs are rejected. Public API integrations supply source URLs; direct image and voice file uploads are a first-party interface feature.
Preset voices use the hosted Cartesia catalog. You do not need a separate Cartesia key. Match the character's accent, age, energy, and delivery by reading the description and listening to its preview.
curl --fail-with-body "$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-character-voices/presets" \
-H "x-api-key: $UNCAGED_API_KEY"
# Returns audio bytes. Save and listen locally.
curl --fail-with-body \
"$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-character-voices/presets/$UNCAGED_VOICE_ID/preview" \
-H "x-api-key: $UNCAGED_API_KEY" \
-o voice-preview.wavThe response includes voices[], total, and source. Each voice has voiceId, name, and a description, with language and accent metadata when available. Pass voiceId as providerVoiceId.
Provide a clean reference at a public HTTPS URL and describe its delivery. Common formats include WAV, MP3, M4A, FLAC, OGG, and WebM, up to 10 MB. Use a voice you have permission to use. Allow time for provisioning before launch.
// POST /workspaces/{workspaceId}/live-characters
// Or PATCH /workspaces/{workspaceId}/live-characters/{characterId}
{
"voiceSource": "custom",
"voiceReferenceUrl": "https://your-cdn.example/avery-voice.wav",
"voiceDescription": "A warm, casual American English voice. Relaxed pace and playful delivery."
}For a create request, include these fields alongside name, image, and prompt. After changing a video character's image or voice, prepare it again and check readiness.
Use PATCH to update the fields you want to change. Define behavior in systemPrompt, add reference facts in knowledgeText, and tune personality with Cerebro traits.
curl --fail-with-body -X PATCH \
"$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-characters/$UNCAGED_CHARACTER_ID" \
-H "x-api-key: $UNCAGED_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"systemPrompt": "You are Avery. Be warm, witty, and concise. Ask one natural follow-up question at a time.",
"knowledgeText": "Support hours: Monday to Friday, 9am to 5pm Pacific.",
"openingLines": ["Hey, what brings you here?", "Hi! What are we getting into today?"],
"cerebro": {
"personalityTraits": { "extraversion": 75, "playfulness": 80 }
},
"maxSessionMinutes": 5
}'| Field | Type | Details |
|---|---|---|
knowledgeText | string | Reference facts and context. |
openingLine / openingLines | string / string[] | Supplied greetings. openingLines takes priority, up to 12 entries. These fields are not an automatic generated-opener setting. |
cerebro.personalityTraits | object | 0 to 100 for openness, conscientiousness, extraversion, agreeableness, emotionalStability, and playfulness. Supply at least one trait. |
orientation | square | vertical | horizontal | Video framing. Defaults to square. |
avatarAnimationSettings | object | enabled, visualStability, motionFreedom. Numeric controls accept 0.5 to 0.99. |
moderationSettings.safetyFilters | object | harassment, hateSpeech, adultContent, dangerousContent strengths from 0 to 3. |
moderationSettings.internetAccess | off | limited | open | limited uses allowedUrls, an HTTPS allowlist. |
moderationSettings.cannotReveal | string | Private topics or information to withhold. |
maxSessionSeconds | number | Duration in seconds. Takes priority over maxSessionMinutes. |
Sequencer prompt, Cerebro, moderation, greeting, language, and duration changes can use current prepared assets. Image, voice, runtime, and visual identity changes can create a draft that needs preparation again.
Preparation builds visual and voice assets. Run it before the first video session and after changes that invalidate prepared assets. Text and preset-voice conversations skip video preparation.
curl --fail-with-body --max-time 900 -X POST \
"$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-characters/$UNCAGED_CHARACTER_ID/prepare" \
-H "x-api-key: $UNCAGED_API_KEY"
# Get the latest configuration and preparation status.
curl --fail-with-body \
"$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-characters/$UNCAGED_CHARACTER_ID" \
-H "x-api-key: $UNCAGED_API_KEY"Allow a long request timeout. Check the returned character, then poll GET every few seconds while work is in progress. Stop when ready or failed. Avoid repeated prepare requests while one is running.
{
"liveCharacter": {
"id": "your_character_id",
"status": "active",
"runtimeStatus": "ready",
"prepStatus": "ready"
}
}Use the actual response for progress and errors. If preparation fails, check the image and voice before retrying. Fast and Max have different preparation and runtime requirements.
Use sessionMode: "conversation" and an explicit channels object. Enable at least one channel. Your selection changes the transport, so use the connection fields returned for that session.
| Experience | Text, voice, video | Connection |
|---|---|---|
| Text chat | true, false, false | conversationStream WebSocket |
| Voice with text | true, true, false | conversationStream WebSocket |
| Full video conversation | true, true, true | LiveKit audio and video |
| Type to a video avatar | true, false, true | LiveKit, typed input via REST |
| Own voice agent | false, true, true | LiveKit, audio_stream mode |
The voice channel enables microphone conversation. Video includes spoken output even when microphone input is disabled. Omitted channels default to the complete avatar experience.
Text chat uses a lightweight WebSocket conversation service. Create the character, start a text session on your backend, and stream replies in the browser. This mode needs no video preparation.
curl --fail-with-body -X POST \
"$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-characters/$UNCAGED_CHARACTER_ID/sessions" \
-H "x-api-key: $UNCAGED_API_KEY" \
-H "Origin: https://your-app.example" \
-H "Content-Type: application/json" \
-d '{
"launchMode": "webrtc",
"sessionMode": "conversation",
"channels": {
"text": true,
"voice": false,
"video": false
},
"endUserId": "visitor_123",
"saveHistory": false,
"maxSessionSeconds": 300
}'| Field | Type | Details |
|---|---|---|
sessionId | string | Save on your backend for ownership and cleanup. |
conversationId | string | Identifier for this call context. Your backend owns saved history. |
saveHistory | boolean | False for new API conversations. Your backend stores transcripts. |
conversationMessages | array | Supplied context: id, role, text, and final state. |
conversationStream.url | wss:// URL | Browser WebSocket connection. |
conversationStream.token | string | Send in the first WebSocket JSON message. |
channels | object | Accepted channel selection. |
billing | object | usdPerMinute and reservedUsd for this session. |
Create conversation.js with this helper. Authenticate the socket, then wait for ready before sending messages.
// conversation.js, browser
export function connectConversation(session, onEvent, onClose = () => {}) {
return new Promise((resolve, reject) => {
const socket = new WebSocket(session.conversationStream.url);
let ready = false;
const timer = setTimeout(() => {
reject(new Error('Conversation connection timed out'));
socket.close();
}, 15000);
socket.onopen = () => socket.send(JSON.stringify({
token: session.conversationStream.token, sampleRate: 48000
}));
socket.onmessage = ({ data }) => {
const event = JSON.parse(data);
if (event.type === 'ready') {
ready = true;
clearTimeout(timer);
resolve(socket);
}
if (event.type === 'error' && !ready) {
clearTimeout(timer);
reject(new Error(event.message));
socket.close();
}
onEvent(event);
};
socket.onerror = () => {
if (!ready) { clearTimeout(timer); reject(new Error('WebSocket connection failed')); }
};
socket.onclose = () => {
clearTimeout(timer);
if (!ready) reject(new Error('Connection closed before ready'));
onClose();
};
});
}Use the backend routes above and the HTML elements listed in this example. Load the code as a browser module. Transcript updates replace the message by id, the text contains its current content. final: true confirms completion.
// HTML: <button id="start">Start</button>
// <form id="chat"><input id="message" required><button>Send</button></form>
// <div id="transcript"></div><button id="end">End chat</button>
import { connectConversation } from './conversation.js';
let session, socket, starting = false, ending;
const messages = new Map();
function upsert(message) {
messages.set(message.id, message);
const lines = [...messages.values()].map(m => m.role + ': ' + m.text);
document.querySelector('#transcript').textContent = lines.join('\n');
}
async function endChat() {
if (ending) return ending;
const current = session;
session = undefined;
socket?.close(); socket = undefined;
ending = current
? fetch('/api/avatar/session/' + current.sessionId + '/end', { method: 'POST' })
: Promise.resolve();
try { await ending; } finally { ending = undefined; }
}
document.querySelector('#start').onclick = async () => {
if (session || starting || ending) return;
starting = true;
try {
const response = await fetch('/api/avatar/session', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'text' })
});
if (!response.ok) throw new Error('Could not start session');
session = await response.json();
messages.clear();
socket = await connectConversation(session, event => {
if (event.type === 'ready') event.messages.forEach(upsert);
if (event.type === 'transcript') upsert(event); // Replace by id as text streams.
if (event.type === 'error') console.error(event.message);
}, () => { void endChat().catch(console.error); });
} catch (error) { await endChat(); throw error; }
finally { starting = false; }
};
document.querySelector('#chat').onsubmit = event => {
event.preventDefault();
const input = document.querySelector('#message');
const text = input.value.trim();
if (!text || socket?.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ type: 'text_turn', id: crypto.randomUUID(), text }));
input.value = '';
};
document.querySelector('#end').onclick = endChat;Disable Start while a request is pending. Show errors in your UI and end any allocated session if connection fails. Keep a server timeout fallback for abandoned clients.
Add speech recognition and spoken replies to the same WebSocket flow. Use HTTPS or localhost, request microphone permission, and start audio from a visitor's click. Headphones help prevent feedback.
curl --fail-with-body -X POST \
"$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-characters/$UNCAGED_CHARACTER_ID/sessions" \
-H "x-api-key: $UNCAGED_API_KEY" \
-H "Origin: https://your-app.example" \
-H "Content-Type: application/json" \
-d '{
"launchMode": "webrtc",
"sessionMode": "conversation",
"channels": {
"text": true,
"voice": true,
"video": false
},
"endUserId": "visitor_123",
"saveHistory": false,
"maxSessionSeconds": 300
}'Serve this file at /pcm-capture.js from your app. It converts microphone input into mono, little-endian signed PCM16. Send binary frames after microphone_ready. Encoded MediaRecorder WebM blobs use a different format.
// public/pcm-capture.js, served from your app's origin
class PcmCapture extends AudioWorkletProcessor {
process(inputs) {
const channels = inputs[0];
if (!channels?.length) return true;
const count = channels[0].length;
const pcm = new ArrayBuffer(count * 2);
const view = new DataView(pcm);
for (let i = 0; i < count; i++) {
let mono = 0;
for (const channel of channels) mono += channel[i] / channels.length;
mono = Math.max(-1, Math.min(1, mono));
view.setInt16(i * 2, mono < 0 ? mono * 32768 : mono * 32767, true);
}
this.port.postMessage(pcm, [pcm]);
return true;
}
}
registerProcessor('pcm-capture', PcmCapture);Reuse conversation.js from the text guide. Call startVoiceChat() from Start and save its cleanup function for End. This example queues returned audio chunks in order and clears playback on interruption.
// Start from a click. Reuse connectConversation from the Text chat guide.
import { connectConversation } from './conversation.js';
export async function startVoiceChat() {
const context = new AudioContext({ sampleRate: 48000 });
await context.resume();
const activeAudio = new Set();
let nextAudio = 0, microphoneReady = false, session, socket, mic, capture, closing = false;
const clearAudio = () => {
for (const source of activeAudio) source.stop();
activeAudio.clear(); nextAudio = 0;
};
const cleanup = async () => {
if (closing) return;
closing = true;
microphoneReady = false;
capture?.disconnect();
mic?.getTracks().forEach(track => track.stop());
socket?.close(); clearAudio();
await context.close();
if (session) await fetch('/api/avatar/session/' + session.sessionId + '/end', { method: 'POST' });
};
try {
// Request permission before allocating a paid session.
mic = await navigator.mediaDevices.getUserMedia({ audio: {
channelCount: 1, echoCancellation: true, noiseSuppression: true
} });
await context.audioWorklet.addModule('/pcm-capture.js');
const response = await fetch('/api/avatar/session', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'voice' })
});
if (!response.ok) throw new Error('Could not start voice session');
session = await response.json();
socket = await connectConversation(session, event => {
if (event.type === 'microphone_ready') microphoneReady = true;
if (event.type === 'transcript') console.log(event.role, event.text, event.final);
if (event.type === 'error') console.error(event.message);
if (event.type === 'interrupt') clearAudio();
if (event.type !== 'audio') return;
const bytes = Uint8Array.from(atob(event.pcm), char => char.charCodeAt(0));
const view = new DataView(bytes.buffer);
const buffer = context.createBuffer(1, bytes.length / 2, event.sampleRate);
const samples = buffer.getChannelData(0);
for (let i = 0; i < samples.length; i++) samples[i] = view.getInt16(i * 2, true) / 32768;
const source = context.createBufferSource();
source.buffer = buffer; source.connect(context.destination);
const when = Math.max(context.currentTime + 0.02, nextAudio);
nextAudio = when + buffer.duration;
activeAudio.add(source);
source.onended = () => activeAudio.delete(source);
source.start(when);
}, () => { void cleanup().catch(console.error); });
socket.send(JSON.stringify({ type: 'microphone_start', sampleRate: context.sampleRate }));
const input = context.createMediaStreamSource(mic);
capture = new AudioWorkletNode(context, 'pcm-capture');
capture.port.onmessage = ({ data }) => {
if (microphoneReady && socket.readyState === WebSocket.OPEN) {
if (socket.bufferedAmount > 192000) { socket.close(); return; }
socket.send(data); // Mono little-endian PCM16 binary, not encoded WebM.
}
};
const silent = context.createGain(); silent.gain.value = 0;
input.connect(capture).connect(silent).connect(context.destination);
return cleanup; // Call on your End button, including on errors.
} catch (error) { await cleanup(); throw error; }
}| Field | Type | Details |
|---|---|---|
ready | server | Authenticated connection, messages, channels, expiresAtMs. |
text_turn | client JSON | type, id (unique turn ID), text. Requires text channel. |
microphone_start | client JSON | sampleRate: 16000, 24000, 44100, or 48000. |
microphone_ready | server | Recognition is ready, send PCM16 binary frames. |
microphone_stop | client JSON | Stop recognition when muted. Restart with microphone_start. |
transcript | server | id, role, text, final, optional interrupted state. Upsert by id. |
audio | server | id, pcm (base64 PCM16), sampleRate (24000 Hz). Queue playback. |
audio_complete | server | All chunks sent for this turn. Playback may still be queued. |
interrupt | server / client | Clear playback when received. Send to request interruption. |
error | server | message describes failure. Surface it and clean up. |
On socket close, stop the microphone and audio and end the session. Obtain fresh credentials to reconnect and supply recent messages from your backend. In production, buffer microphone capture into short chunks and monitor backpressure; this example closes a backed-up stream.
Video uses a prepared character and a LiveKit room. The hosted service handles speech recognition, conversation, voice synthesis, and animation. The browser subscribes to remote tracks and publishes its microphone.
curl --fail-with-body -X POST \
"$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-characters/$UNCAGED_CHARACTER_ID/sessions" \
-H "x-api-key: $UNCAGED_API_KEY" \
-H "Origin: https://your-app.example" \
-H "Content-Type: application/json" \
-d '{
"launchMode": "webrtc",
"sessionMode": "conversation",
"channels": {
"text": true,
"voice": true,
"video": true
},
"endUserId": "visitor_123",
"saveHistory": false,
"maxSessionSeconds": 300
}'| Field | Type | Details |
|---|---|---|
streamTransport | livekit | Use the LiveKit browser client. |
livekitUrl | wss:// URL | Use exactly the returned server URL. |
livekitClientToken | string | Short-lived room credential for room.connect. |
roomName | string | Assigned room, the token is already scoped to it. |
expiresAt | ISO timestamp | Connection expiry. |
sessionId / conversationId | string | Connection lifecycle and context IDs. |
billing.usdPerMinute | number | Accepted rate for this video mode. |
LiveKit fields are also inside stream for compatibility. Text and voice-only sessions use conversationStream. Use the direct browser connection below for your custom website.
Install livekit-client. Add an avatar container and Start and End buttons. Load this code with your browser bundler and subscribe to both video and audio to see and hear the character.
// HTML: <button id="start">Start</button><div id="avatar"></div>
// <button id="end">End call</button>
import { Room, RoomEvent, Track } from 'https://sequencer.media/realtime/client-v1.js';
let room, session, starting = false, ending = false;
const container = document.querySelector('#avatar');
async function endCall() {
if (ending) return;
ending = true;
try {
await room?.disconnect(); // Also unpublishes the microphone.
container.replaceChildren();
if (session) await fetch('/api/avatar/session/' + session.sessionId + '/end', { method: 'POST' });
room = session = undefined;
} finally { ending = false; }
}
document.querySelector('#start').onclick = async () => {
if (session || starting || ending) return;
starting = true;
room = new Room();
room.on(RoomEvent.TrackSubscribed, track => {
if (track.kind !== Track.Kind.Video && track.kind !== Track.Kind.Audio) return;
const element = track.attach();
if (track.kind === Track.Kind.Video) {
element.playsInline = true; element.style.width = '100%';
}
container.appendChild(element);
});
room.on(RoomEvent.TrackUnsubscribed, track => {
track.detach().forEach(element => element.remove());
});
room.on(RoomEvent.Disconnected, () => { void endCall().catch(console.error); });
try {
const response = await fetch('/api/avatar/session', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'video' })
});
if (!response.ok) throw new Error('Could not start video session');
session = await response.json();
await room.connect(session.livekitUrl, session.livekitClientToken);
await room.startAudio();
await room.localParticipant.setMicrophoneEnabled(true);
} catch (error) { await endCall(); throw error; }
finally { starting = false; }
};
document.querySelector('#end').onclick = endCall;
// Provide an Enable audio button that calls room.startAudio() if autoplay is blocked.
// For typed input, use your backend's messages route from Send a text turn.Handle room disconnection and network failure by stopping local tracks and ending through your backend. Reconnect with a fresh session. Embedded pages also need the host's microphone and autoplay permissions.
Use sessionMode: "audio_stream" when your own agent generates speech. This mode returns your audio synchronized with avatar video and bypasses hosted speech recognition, reply generation, and voice synthesis.
// Start the session on your backend, with the normal session endpoint.
{
"launchMode": "webrtc",
"sessionMode": "audio_stream",
"channels": { "text": false, "voice": true, "video": true },
"endUserId": "visitor_123",
"maxSessionSeconds": 300
}
// Browser: connect to LiveKit using the Connect with LiveKit guide.
// Feed your own agent's speech into a Web Audio destination.
const context = new AudioContext();
await context.resume();
const output = context.createMediaStreamDestination();
// Connect your speech-producing AudioNode to output.
// Do not also connect that speech to speakers, or it will play twice.
const speechTrack = output.stream.getAudioTracks()[0];
await room.localParticipant.publishTrack(speechTrack, {
source: Track.Source.Microphone, dtx: false
});
// Keep this track alive through pauses. Let returned audio finish before ending.
// On End: disconnect the room, close the AudioContext, and call End Session.Use a prepared Sequencer character. Publish one persistent microphone-source track with dtx: false, feed mono speech from your audio pipeline, keep the track alive through silence, and let returned playback finish before ending.
Import Track from livekit-client. The example assumes a connected room and an audio-producing node from your agent. REST text turns and configured greetings belong to conversation mode.
Your backend stores each user's conversation history. Supply recent conversationHistory when starting a text, voice, or video call, then save completed transcript events in your own database.
// Default: your backend owns and supplies history for the authenticated user.
{
"saveHistory": false,
"endUserId": "visitor_123",
"conversationHistory": [
{ "role": "user", "content": "My dog is named Luna." },
{ "role": "assistant", "content": "How is Luna doing?" }
]
}
// Add the usual channels and session fields. Save final transcript events in
// your backend and supply the relevant recent messages when starting again.
// Maximum: 60 messages, 16000 characters per message, 64 KiB total UTF-8 content.
// New API conversations use client-managed history. Your backend chooses
// its retention policy. Hosted history creation is reserved for Sequencer's
// own products. Existing legacy hosted conversation IDs can still resume.Keep messages grouped by authenticated user, workspace, and character. The same history can continue across text, voice, and video. Each new call returns fresh session credentials.
// On your backend: load your own saved user/assistant messages.
// These stores and your app authentication are integration points you provide.
import { historyStore } from './your-app-auth.js';
import { startAvatar } from './avatar-api.js';
const history = await historyStore.recent({
userId: user.id, workspaceId: process.env.UNCAGED_WORKSPACE_ID,
characterId: process.env.UNCAGED_CHARACTER_ID, limit: 60, maxBytes: 65536
});
const session = await startAvatar(
user.id, { text: true, voice: true, video: false }, history
);
// Each call returns a new session and fresh credentials.
// Save completed transcript events in your own history store after checking
// session ownership. Preserve your app's conversation ID across calls.Supply at most 60 user or assistant messages, 16000 characters per message, and 64 KiB total UTF-8 content. Imported context is temporary and expires with the call. Your backend chooses how long to retain its saved transcripts.
Enable the text channel. Text and voice-only clients can send text_turn on their WebSocket. For video, forward typed input through your authenticated backend to the REST endpoint.
curl --fail-with-body -X POST \
"$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-character-sessions/{sessionId}/messages" \
-H "x-api-key: $UNCAGED_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "message": "Hey Avery, what should we try first?" }'Read the response for reply text and runtime status. A video turn can be accepted before spoken playback finishes. Check runtimeTurnError when present and keep observing the stream.
Give every socket turn a unique ID. WebSocket text input accepts up to 1,200 characters. Avoid duplicate sends while a reply is active and rate-limit input in your app.
Close the room or WebSocket, stop microphone and audio, and ask your backend to end the session. End reconciles connected usage and releases the unused reserve. It is safe to repeat.
curl --fail-with-body -X POST \
"$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-character-sessions/{sessionId}/end" \
-H "x-api-key: $UNCAGED_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "USER_CLOSED" }'
# For an abandoned start that has not returned a sessionId:
curl --fail-with-body -X POST \
"$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-character-session-starts/{startToken}/cancel" \
-H "x-api-key: $UNCAGED_API_KEY"For video startup, create and save a unique startToken before starting. If startup is abandoned before a session ID returns, cancel with that token. If a session ID has returned, end it instead.
Upload a reusable motion, prepare it, and trigger it in an active video conversation. Upload uses multipart form data with a file field.
# Upload a source clip and describe when to use it.
curl --fail-with-body -X POST \
"$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-characters/$UNCAGED_CHARACTER_ID/custom-animations/upload" \
-H "x-api-key: $UNCAGED_API_KEY" \
-F "file=@wave.mp4" -F "name=Wave" -F "trigger=Wave when greeting a visitor"
# Prepare the returned customAnimation.id.
curl --fail-with-body -X POST \
"$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-characters/$UNCAGED_CHARACTER_ID/custom-animations/{animationId}/prepare" \
-H "x-api-key: $UNCAGED_API_KEY"
# Once its status is ready, trigger it in an active video conversation.
curl --fail-with-body -X POST \
"$UNCAGED_API_BASE/workspaces/$UNCAGED_WORKSPACE_ID/live-character-sessions/{sessionId}/animations/trigger" \
-H "x-api-key: $UNCAGED_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "animationId": "your_animation_id" }'Save customAnimation.id. Check its status on the character until ready. Main character assets must be prepared too. Motion preparation can be deferred while the character is busy. Update its name or trigger guidance with PATCH, or remove it with DELETE.
Your API uses the shared account balance. Manage billing before launching. Use billing.usdPerMinute and billing.reservedUsd from the start response for cost estimates.
const maxCost = session.billing.reservedUsd;
const estimatedCost = session.billing.usdPerMinute * (connectedSeconds / 60);
// Final settlement comes from the API. This is an estimate.Video pricing depends on Fast or Max and session mode. Website demo or per-message display prices are separate from the API session meter. Read the returned rate before quoting costs.
Current default limits are 120 general requests per minute per account, with tighter groups for generation (20/minute) and uploads (10/minute). Character creation, updates, preparation, starts, and REST messages use the generation group. Limits are shared across keys for an account. Runtime capacity and turn limits also apply.
Poll at measured intervals, respect Retry-After, and reduce repeated status requests.
Check HTTP status before using fields. REST errors contain error and may include code or retryAfterSeconds. Log status, endpoint, and session ID, while keeping keys and tokens out of logs.
| Status | What to check |
|---|---|
400 | Invalid body, source URL, voice ID, or channels. Correct input before retrying. |
401 | Missing, revoked, or invalid key. Check x-api-key. |
402 | Balance cannot cover the reserve. Add balance or lower duration. |
403 | Account lacks workspace or operation access. |
404 | Wrong resource ID, or history belongs to a different user or character. |
409 | State conflict. Check readiness and existing active sessions. |
429 | Rate limit. Respect Retry-After and back off. |
503 | Runtime capacity unavailable. Respect Retry-After and offer a retry. |
500 | Unexpected startup or provider failure. Check readiness and clean up an ambiguous start. |
Subscribe to remote audio and allow playback from a click. Check microphone permission. Voice-only input must be PCM16 at the declared rate, sent after microphone_ready. Decode returned audio at its sampleRate and queue chunks in order.
Use the returned URL and token. Send the token in the first JSON message within 10 seconds. Match the session-start Origin to the browser's origin. Expired credentials need a new session.
Confirm readiness and attach the remote video track. Check network restrictions and your content security policy. Use GET /workspaces/{workspaceId}/live-character-sessions/{sessionId}/metrics?live=true on your backend for diagnostics.
Open the URL without login. Check HTTPS, actual file content, content type, and size. Sharing pages and expired signed links are unsuitable sources.
Load the correct user and avatar history from your backend and supply it as conversationHistory when starting the call.
Paths are relative to https://api.sequencer.media/v1 and require API authentication. Replace brace-delimited IDs. Responses are JSON except voice previews, which return audio bytes. Motion upload uses multipart form data.
/v1/workspacesList accessible workspaces
/v1/workspaces/{workspaceId}/live-character-voices/presetsList preset voices
/v1/workspaces/{workspaceId}/live-character-voices/presets/{voiceId}/previewListen to a voice, returns audio bytes
/v1/workspaces/{workspaceId}/live-charactersList characters, liveCharacters[]
/v1/workspaces/{workspaceId}/live-charactersCreate a character, liveCharacter.id
/v1/workspaces/{workspaceId}/live-characters/{characterId}Get configuration and readiness
/v1/workspaces/{workspaceId}/live-characters/{characterId}Update supplied configuration fields
/v1/workspaces/{workspaceId}/live-characters/{characterId}/preparePrepare or rebuild video assets
/v1/workspaces/{workspaceId}/live-characters/{characterId}Archive a character, soft delete
/v1/workspaces/{workspaceId}/live-characters/{characterId}/sessionsStart a session, returns 201
/v1/workspaces/{workspaceId}/live-character-session-starts/{startToken}/cancelCancel an abandoned pending start
/v1/workspaces/{workspaceId}/live-character-sessions/{sessionId}/messagesSend a typed conversation turn
/v1/workspaces/{workspaceId}/live-character-sessions/{sessionId}/endEnd and reconcile session billing
/v1/workspaces/{workspaceId}/live-character-sessions/{sessionId}/metrics?live=trueGet usage and live runtime diagnostics
/v1/workspaces/{workspaceId}/live-characters/{characterId}/custom-animations/uploadUpload a motion clip, multipart file
/v1/workspaces/{workspaceId}/live-characters/{characterId}/custom-animations/{animationId}Update motion name or trigger guidance
/v1/workspaces/{workspaceId}/live-characters/{characterId}/custom-animations/{animationId}/preparePrepare a motion
/v1/workspaces/{workspaceId}/live-characters/{characterId}/custom-animations/{animationId}Remove a motion
/v1/workspaces/{workspaceId}/live-character-sessions/{sessionId}/animations/triggerTrigger a prepared motion
The shared Sequencer reference has complete character schemas, animation options, and other platform endpoints. Use it to add media generation, workflows, or live worlds alongside avatars.