WORLDSTREAMS

Tuning Into Reality

Connecting to the world

Uncaged Worlds
Overview
Uncaged Worlds/Developers
REST API ยท v1

Live Avatar API

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.

Base URLhttps://api.sequencer.media/v1

Quickstart

You 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.

  1. Create your key. Open API keys in your account, copy the key once, and save it as a server environment variable.
  2. Choose a workspace and voice. List /workspaces, then the workspace's preset voices. Save their IDs.
  3. Create your character. Send an image URL, voice ID, and personality prompt. Save liveCharacter.id.
  4. Start your experience. Follow the text, voice, or video guide. Video requires preparation first.
  5. Connect and clean up. Give the browser session credentials, then end the session through your backend when the visitor finishes.
Your first requestbash
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"

Authentication

Send your API key in the x-api-key header on every REST request. JSON bodies also need Content-Type: application/json.

Request headershttp
x-api-key: sk_your_api_key
Content-Type: application/json

Create, 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.

Workspaces and resource IDs

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.

Choose your workspace and voicebash
# 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.

FieldTypeDetails
workspaceIdstringFrom workspaces[].id. Use the ID, not the name or public URL slug.
characterIdstringFrom liveCharacter.id when creating or getting a character.
voiceIdstringFrom voices[].voiceId. Pass it as providerVoiceId.
sessionIdstringA new active connection and its billing lifecycle. Use for messages and end.
conversationIdstringIdentifier for this call context. Keep your own saved conversation ID on your backend.

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.jsJavaScript
// 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' }
  });
}
Example app routes for the browser walkthroughs

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.

avatar-routes.jsJavaScript
// 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 a character

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.

Create Averybash
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.

FieldTypeDetails
namestring, requiredDisplay name.
avatarImageUrlHTTPS URL, requiredPublicly retrievable image. Imported into workspace-owned storage.
systemPromptstring, requiredRole, personality, tone, response length, and behavior.
voiceSourcepreset | customPreset needs providerVoiceId. Custom needs reference URL and description.
providerVoiceIdstringRequired for a preset, use a voiceId from the catalog.
runtimeProvidersequencer | runwayUse sequencer for the examples in this guide.
animaModefast | maxVideo quality. Start with fast and read returned rates for costs.
maxSessionMinutesnumberCharacter duration limit. Defaults to 5 minutes.
languagestringLanguage code, for example en. Defaults to en.

Choose an image that works on camera

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.

Choose a voice

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.

List and preview voicesbash
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.wav

The 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.

Use a custom voice

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.

Custom voice fieldsJSON
// 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.

Personality and settings

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.

Update character behaviorbash
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
  }'
FieldTypeDetails
knowledgeTextstringReference facts and context.
openingLine / openingLinesstring / string[]Supplied greetings. openingLines takes priority, up to 12 entries. These fields are not an automatic generated-opener setting.
cerebro.personalityTraitsobject0 to 100 for openness, conscientiousness, extraversion, agreeableness, emotionalStability, and playfulness. Supply at least one trait.
orientationsquare | vertical | horizontalVideo framing. Defaults to square.
avatarAnimationSettingsobjectenabled, visualStability, motionFreedom. Numeric controls accept 0.5 to 0.99.
moderationSettings.safetyFiltersobjectharassment, hateSpeech, adultContent, dangerousContent strengths from 0 to 3.
moderationSettings.internetAccessoff | limited | openlimited uses allowedUrls, an HTTPS allowlist.
moderationSettings.cannotRevealstringPrivate topics or information to withhold.
maxSessionSecondsnumberDuration 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.

Prepare for video

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.

Prepare and check readinessbash
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.

Video readiness fields (response excerpt)JSON
{
  "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.

Choose your channels

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.

ExperienceText, voice, videoConnection
Text chattrue, false, falseconversationStream WebSocket
Voice with texttrue, true, falseconversationStream WebSocket
Full video conversationtrue, true, trueLiveKit audio and video
Type to a video avatartrue, false, trueLiveKit, typed input via REST
Own voice agentfalse, true, trueLiveKit, 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.

Build a text chat

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.

1. Start a text session

Text session requestbash
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
}'
FieldTypeDetails
sessionIdstringSave on your backend for ownership and cleanup.
conversationIdstringIdentifier for this call context. Your backend owns saved history.
saveHistorybooleanFalse for new API conversations. Your backend stores transcripts.
conversationMessagesarraySupplied context: id, role, text, and final state.
conversationStream.urlwss:// URLBrowser WebSocket connection.
conversationStream.tokenstringSend in the first WebSocket JSON message.
channelsobjectAccepted channel selection.
billingobjectusdPerMinute and reservedUsd for this session.

2. Connect and wait for ready

Create conversation.js with this helper. Authenticate the socket, then wait for ready before sending messages.

conversation.jsJavaScript
// 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();
    };
  });
}

3. Send turns and render replies

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.

text-chat.jsJavaScript
// 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.

Build a voice chat

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.

1. Start with voice enabled

Voice session requestbash
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
}'

2. Capture microphone PCM

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.jsJavaScript
// 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);

3. Stream input and play replies

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.

voice-chat.jsJavaScript
// 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; }
}
FieldTypeDetails
readyserverAuthenticated connection, messages, channels, expiresAtMs.
text_turnclient JSONtype, id (unique turn ID), text. Requires text channel.
microphone_startclient JSONsampleRate: 16000, 24000, 44100, or 48000.
microphone_readyserverRecognition is ready, send PCM16 binary frames.
microphone_stopclient JSONStop recognition when muted. Restart with microphone_start.
transcriptserverid, role, text, final, optional interrupted state. Upsert by id.
audioserverid, pcm (base64 PCM16), sampleRate (24000 Hz). Queue playback.
audio_completeserverAll chunks sent for this turn. Playback may still be queued.
interruptserver / clientClear playback when received. Send to request interruption.
errorservermessage 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.

Build a video avatar

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.

  1. Prepare the character and confirm readiness.
  2. Start a session on your backend with video enabled.
  3. Connect to its LiveKit room, enable audio, and publish the microphone.
  4. Disconnect and end through the API when finished.
Video session requestbash
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
}'
FieldTypeDetails
streamTransportlivekitUse the LiveKit browser client.
livekitUrlwss:// URLUse exactly the returned server URL.
livekitClientTokenstringShort-lived room credential for room.connect.
roomNamestringAssigned room, the token is already scoped to it.
expiresAtISO timestampConnection expiry.
sessionId / conversationIdstringConnection lifecycle and context IDs.
billing.usdPerMinutenumberAccepted 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.

Connect with LiveKit

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.

video-avatar.jsJavaScript
// 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.

Bring your own voice agent

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 and publish generated speechJavaScript / JSON
// 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.

Conversation history

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.

Supply recent historyJSON
// 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.

Continue with your saved messagesJavaScript
// 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.

Send a text turn

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.

Send a message, server onlybash
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.

End and cancel sessions

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.

End or cancelbash
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.

Custom animations

Upload a reusable motion, prepare it, and trigger it in an active video conversation. Upload uses multipart form data with a file field.

Upload, prepare, and trigger a motionbash
# 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.

Billing and session limits

Your API uses the shared account balance. Manage billing before launching. Use billing.usdPerMinute and billing.reservedUsd from the start response for cost estimates.

  • Start: reserves the maximum configured session cost.
  • Connected time: meters usage. Text and voice-only API sessions are time-based.
  • End: settles actual usage and refunds unused reserve.
  • Duration: request a lower limit with maxSessionSeconds or maxSessionMinutes, bounded by the character's limit.
Estimate from your returned sessionJavaScript
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.

Rate limits

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.

Errors and troubleshooting

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.

StatusWhat to check
400Invalid body, source URL, voice ID, or channels. Correct input before retrying.
401Missing, revoked, or invalid key. Check x-api-key.
402Balance cannot cover the reserve. Add balance or lower duration.
403Account lacks workspace or operation access.
404Wrong resource ID, or history belongs to a different user or character.
409State conflict. Check readiness and existing active sessions.
429Rate limit. Respect Retry-After and back off.
503Runtime capacity unavailable. Respect Retry-After and offer a retry.
500Unexpected startup or provider failure. Check readiness and clean up an ambiguous start.

Connected, but no voice

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.

WebSocket closes before ready

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.

Video stays blank

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.

Image or voice source is rejected

Open the URL without login. Check HTTPS, actual file content, content type, and size. Sharing pages and expired signed links are unsuitable sources.

History is missing

Load the correct user and avatar history from your backend and supply it as conversationHistory when starting the call.

Endpoint reference

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.

GET/v1/workspaces

List accessible workspaces

GET/v1/workspaces/{workspaceId}/live-character-voices/presets

List preset voices

GET/v1/workspaces/{workspaceId}/live-character-voices/presets/{voiceId}/preview

Listen to a voice, returns audio bytes

GET/v1/workspaces/{workspaceId}/live-characters

List characters, liveCharacters[]

POST/v1/workspaces/{workspaceId}/live-characters

Create a character, liveCharacter.id

GET/v1/workspaces/{workspaceId}/live-characters/{characterId}

Get configuration and readiness

PATCH/v1/workspaces/{workspaceId}/live-characters/{characterId}

Update supplied configuration fields

POST/v1/workspaces/{workspaceId}/live-characters/{characterId}/prepare

Prepare or rebuild video assets

DELETE/v1/workspaces/{workspaceId}/live-characters/{characterId}

Archive a character, soft delete

POST/v1/workspaces/{workspaceId}/live-characters/{characterId}/sessions

Start a session, returns 201

POST/v1/workspaces/{workspaceId}/live-character-session-starts/{startToken}/cancel

Cancel an abandoned pending start

POST/v1/workspaces/{workspaceId}/live-character-sessions/{sessionId}/messages

Send a typed conversation turn

POST/v1/workspaces/{workspaceId}/live-character-sessions/{sessionId}/end

End and reconcile session billing

GET/v1/workspaces/{workspaceId}/live-character-sessions/{sessionId}/metrics?live=true

Get usage and live runtime diagnostics

POST/v1/workspaces/{workspaceId}/live-characters/{characterId}/custom-animations/upload

Upload a motion clip, multipart file

PATCH/v1/workspaces/{workspaceId}/live-characters/{characterId}/custom-animations/{animationId}

Update motion name or trigger guidance

POST/v1/workspaces/{workspaceId}/live-characters/{characterId}/custom-animations/{animationId}/prepare

Prepare a motion

DELETE/v1/workspaces/{workspaceId}/live-characters/{characterId}/custom-animations/{animationId}

Remove a motion

POST/v1/workspaces/{workspaceId}/live-character-sessions/{sessionId}/animations/trigger

Trigger a prepared motion

Related API documentation

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.