AI Twin Player Integration
Two integration paths under one guide: @streamoji/aitwin (^0.6.0) for React, or the AiTwinWidget script tag for plain HTML/JS sites.
Video walkthrough
Watch how to install the player SDK, generate auth tokens, and render an AI twin with TTS lipsync.
Choose your integration
The AI Twin Player supports two integration paths. Pick the one that matches your stack, both render the same avatar with TTS lipsync and optional voice connect.
React integration
Install the npm package, render AiTwin in your React app, and control speak / voice via props and ref methods.
Interactive demo
Generate an auth token, tune <AiTwin /> props, and watch a live player update below. Use faceId + voiceId + ttsEngineId (and optional brainId or brainUrl, see Using your own brain), or just id (twin id). Apply uses the group marked Active.
For testing only. In production, call getAiTwinAuthToken from your backend and pass the token to your iframe URL.
Player props
Choose one path: faceId + voiceId + ttsEngineId (brainId / brainUrl optional), or id (twin id). Apply uses the group marked Active. Paste a copied ttsEngineId:voiceId into voiceId if you copied it from Create Twin.
Assets mode
InactiveTwin id mode
ActiveLive preview
Generate a token, set props, and click Apply to render <AiTwin />.
Status: idle
Prerequisites
You need a React 18 or 19 project with Node.js and npm (or pnpm/yarn). The package renders a canvas-based AI twin face with TTS lipsync, it runs in the browser only.
Peer dependencies: react and react-dom. If you use Next.js or enforce a strict Content Security Policy, see Integration notes in this section.
Install the package
Add @streamoji/aitwin and its peer dependencies to your project:
npm install @streamoji/aitwinRender your twin
Provide id (cloud twin slug, e.g. olivia). Use stable useCallback handlers for onReady and onError:
Or skip the cloud lookup and pass faceId, voiceId, ttsEngineId, and optional language. Standard (xAI) voices use ttsEngineId visemetts; premium Cartesia voices and custom clones use eng_c9b1e6d4. Copy Voice ID in Create Twin copies ttsEngineId:voiceId, for example visemetts:eve, which you split into the two props. Set language as an AiTwin prop for the default TTS/Pipecat language, or override it per call in speakText().
function TwinDemo() {
const twinRef = useRef<AiTwinHandle>(null);
return (
<AiTwin
ref={twinRef}
id="olivia"
onReady={() => console.log("face ready")}
onStatusChange={(status) => console.log("status", status)}
onError={(message) => console.error(message)}
/>
);
}<AiTwin
faceId="64-char-hex-face-id"
voiceId="eve"
ttsEngineId="visemetts"
language="es-MX"
authToken={authToken}
/>
{/* Premium Cartesia / custom clone */}
<AiTwin
faceId="64-char-hex-face-id"
voiceId="2f251ac3-89a9-4a77-a452-704b474ccd01"
ttsEngineId="eng_c9b1e6d4"
language="fr"
authToken={authToken}
/>Speak on demand
Control speech imperatively via the ref handle. Call speakText with any string; use stop() to interrupt playback.
Override voice, engine, language, and rate per call, for example a standard xAI voice in French:
<button
type="button"
onClick={() => void twinRef.current?.speakText("Hi, how are you?")}
>
Speak
</button>
<button
type="button"
onClick={() =>
void twinRef.current?.speakText("Bonjour", {
voiceId: "eve",
ttsEngineId: "visemetts",
language: "fr",
speakingRate: 0.85,
})
}
>
Speak (voice override)
</button>
<button
type="button"
onClick={() => twinRef.current?.stop()}
>
Stop
</button>Realtime voice (connect)
For twins with a knowledge base, open a mic + WebSocket session to wss://<api-host>/ws/voice. The avatar lip-syncs from server avatar_audio_chunk messages. Mount <AiTwin>, wait for onReady (or twinRef.current?.isReady()), then call connect() from a user gesture (button click), it unlocks AudioContext and requests mic access.
Use the same authToken JWT as speakText (client_… Bearer token). In production, generate a seed token from your backend via getAiTwinAuthToken, then call POST /twin-lead/start to mint a visitor lead_id into a new JWT. Pass that returned authToken to <AiTwin> and connect(), do not rely on fetchDevAuthToken outside dev.
When using <AiTwin id="…" />, the SDK calls fetchAiTwin internally and stores voiceId, ttsEngineId, brainId, and brainUrl. You can omit them in connect() and the SDK will use those values. If you use avatarId or assets without getAiTwin, pass voiceId, ttsEngineId, and brainId or brainUrl yourself in connect() when needed. Older twins may still return knowledgeContextId instead of brainId.
WebSocket URL (built automatically when wsUrl is omitted): wss://ai.aitwin.me/ws/voice?authToken=…&tenant=aiTwin&voiceId=…&brainId=…&brainUrl=…&speaking_rate=0.85&tts_stream=true. Query params: authToken (required, visitor JWT from POST /twin-lead/start, which already contains lead_id and twin_slug), tenant (always aiTwin), voiceId (optional), brainId (optional, from getAiTwin; legacy aliases personaId / knowledgeContextId), brainUrl (optional client SSE brain; when set, skips the hosted LLM — same GET + text/event-stream contract as /avatarReply and generateReply), speaking_rate (0.5–1.5, default 0.85), tts_stream (default true). You do not need a custom wsUrl to persist visitor history.
import { useEffect, useRef, useState } from "react";
import {
AiTwin,
fetchAiTwin,
type AiTwinHandle,
} from "@streamoji/aitwin";
async function startTwinLead(apiBase: string, seedToken: string, twinSlug: string) {
const key = `aitwin:lead:${twinSlug}`;
const existing = sessionStorage.getItem(key);
const response = await fetch(`${apiBase}/twin-lead/start`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${seedToken}`,
},
body: JSON.stringify({
twin_slug: twinSlug,
...(existing ? { lead_id: existing } : {}),
}),
});
const data = await response.json();
sessionStorage.setItem(key, data.lead_id);
return data.authToken as string;
}
function VoiceTwin() {
const twinRef = useRef<AiTwinHandle>(null);
const [sessionToken, setSessionToken] = useState<string>();
useEffect(() => {
const seedToken = "client_…"; // from getAiTwinAuthToken / getAiTwin
void startTwinLead("https://ai.aitwin.me", seedToken, "modi").then(setSessionToken);
}, []);
const handleConnect = async () => {
if (!sessionToken) return;
const twin = await fetchAiTwin("modi");
await twinRef.current?.connect({
authToken: sessionToken, // JWT already contains lead_id
voiceId: twin.voiceId, // optional
ttsEngineId: twin.ttsEngineId, // optional: visemetts or eng_c9b1e6d4
brainId: twin.brainId ?? twin.knowledgeContextId, // optional hosted brain
brainUrl: twin.brainUrl, // optional client SSE brain; skips hosted LLM
speakingRate: 0.85, // optional, default 0.85
});
};
const handleDisconnect = async () => {
await twinRef.current?.disconnect();
};
return (
<>
<AiTwin
ref={twinRef}
id="modi"
authToken={sessionToken}
onUserTranscript={(text, final) => {
if (final) console.log("user:", text);
}}
onBotOutput={(text) => console.log("bot:", text)}
onStatusChange={(status) => console.log("status:", status)}
onError={(msg) => console.error(msg)}
/>
<button type="button" onClick={() => void handleConnect()}>
Connect mic
</button>
<button type="button" onClick={() => void handleDisconnect()}>
Disconnect
</button>
</>
);
}Production auth (Proxy)
For production TTS and encrypted twin assets, authenticate each session with an authToken, a short-lived Bearer credential you pass via the authToken prop. It authorizes the twin to stream TTS without ever exposing your long-lived secret in the browser.
Generate tokens from your backend by calling getAiTwinAuthToken with your Client-Id and Client-Secret in request headers (not the body). Create and view these credentials on the API Keys page in your aitwin dashboard. Pass userId and userName so usage is attributed to the right end customer in your account.
Your backend calls getAiTwinAuthToken, then your frontend receives only the returned authToken, never the Client Secret.
curl -X POST "https://us-central1-streamoji-265f4.cloudfunctions.net/getAiTwinAuthToken" \
-H "Content-Type: application/json" \
-H "Client-Id: YOUR_CLIENT_ID" \
-H "Client-Secret: YOUR_64_CHAR_API_KEY" \
-d '{
"userId": "end-user-123",
"userName": "Jane Doe"
}'// Backend only, never run this in the browser
const CLIENT_ID = process.env.AITWIN_CLIENT_ID;
const CLIENT_SECRET = process.env.AITWIN_CLIENT_SECRET;
async function getAiTwinAuthToken({
userId,
userName,
maxAvatarCreations,
maxCreditsUtilization,
expiresIn,
}) {
const body = { userId, userName };
if (typeof maxAvatarCreations === "number") {
body.maxAvatarCreations = maxAvatarCreations;
}
if (typeof maxCreditsUtilization === "number") {
body.maxCreditsUtilization = maxCreditsUtilization;
}
if (typeof expiresIn === "number") {
body.expiresIn = expiresIn; // seconds; -1 = no expiry; omit = 30 min default
}
const response = await fetch(
"https://us-central1-streamoji-265f4.cloudfunctions.net/getAiTwinAuthToken",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Client-Id": CLIENT_ID,
"Client-Secret": CLIENT_SECRET,
},
body: JSON.stringify(body),
},
);
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error ?? "Auth token generation failed");
}
return data.authToken; // e.g. "client_eyJ..."
}// Frontend, pass the token from your backend API
<AiTwin
ref={twinRef}
id="olivia"
authToken={authToken}
/>Integration notes
Framework and deployment guidance for the React package.
Next.js
Skip this if you are not using Next.js. AiTwin is browser-only (canvas and related APIs) and must not be server-rendered.
Add the package to transpilePackages in next.config.ts, then import AiTwin with next/dynamic and ssr: false in a client component. The viseme worker is loaded from the Streamoji CDN at runtime, you do not need to copy worker files into your app.
// next.config.ts
const nextConfig = {
transpilePackages: ["@streamoji/aitwin"],
};
export default nextConfig;"use client";
import dynamic from "next/dynamic";
import { useRef } from "react";
import type { AiTwinHandle } from "@streamoji/aitwin";
const AiTwin = dynamic(
() => import("@streamoji/aitwin").then((m) => m.AiTwin),
{ ssr: false },
);Content Security Policy (CSP)
If your site sends a Content-Security-Policy header (or meta tag), allow the AiTwin API, CDN, and WebSocket endpoints below. The SDK fetches encrypted face assets and the lipsync worker from the CDN, streams TTS from the API, and uses blob: URLs for the worker script and the voice-session AudioWorklet.
Voice sessions (connect()) also require wss:// on the API host, plus blob: in script-src (AudioWorklet modules are checked as scripts, not workers). Backend-only calls such as getAiTwinAuthToken run on your server and are not part of the browser CSP.
When embedding the Create AI Twin iframe, add https://aitwin.me to frame-src. Set allow="microphone; camera" on the iframe element so users can record a voice and capture a photo.
API (connect-src, media-src)
- https://ai.aitwin.me
- wss://ai.aitwin.me
CDN, face assets, thumbnails, worker script (connect-src, img-src, worker-src)
- https://aitwin.bubu.social
Dashboard & Create AI Twin iframe (frame-src when embedding)
- https://aitwin.me
# Example CSP additions (merge with your existing policy)
connect-src 'self' https://ai.aitwin.me wss://ai.aitwin.me https://aitwin.bubu.social;
img-src 'self' https://aitwin.bubu.social data: blob:;
media-src 'self' blob: https://ai.aitwin.me;
worker-src blob: https://aitwin.bubu.social;
script-src 'self' blob: https://aitwin.me https://aitwin.bubu.social;
frame-src 'self' https://aitwin.me;Props reference
| Name | Description |
|---|---|
| id | Twin slug for cloud lookup (e.g. olivia). |
| authToken | Short-lived Bearer auth token for TTS and encrypted assets. Generate via getAiTwinAuthToken on your backend. |
| ttsEngineId | TTS engine. Use "visemetts" for standard xAI voices, or "eng_c9b1e6d4" for premium Cartesia voices and custom clones. Platform default when omitted on a cloud twin (id). |
| voiceId | TTS voice id. Create Twin copies ttsEngineId:voiceId (for example visemetts:eve); pass those as separate ttsEngineId and voiceId props. |
| language | Default TTS language for speakText() and voice connect() (Pipecat). Uniform code such as "en", "es-MX", or "auto". Set it on <AiTwin> or override per call in speakText({ language }). visemetts (Standard) and eng_c9b1e6d4 (Premium) support different codes, see Language support. |
| brainUrl | Optional client SSE brain URL. The widget and connect() send it on /ws/voice; speakWithReply / generateReply also accept it. When set, AiTwin skips the hosted brainId LLM. On <AiTwin id>, getAiTwin supplies it automatically. |
| speakingRate | Default speaking rate (default 0.85). |
| onReady | Called when face assets are loaded and canvas is ready. |
| onStatusChange | TTS status: idle, loading, speaking, done, error. |
| onError | Load or runtime errors. |
| onUserTranscript(text, final) | STT from mic during a voice session; final=true on finalized utterance. |
| onBotOutput(text) | Bot/LLM text from the voice session. |
| captions | Optional plain subtitles during bot speech. true enables defaults; false or omit disables. Object form: { fontFamily?, fontSize?, color? }. Type: boolean | AiTwinCaptionsOptions (exported from @streamoji/aitwin). |
| idleHandoffHoldMs | Milliseconds to hold a still closed-mouth frame after speech ends before the idle video loop resumes. Applies to speakText() and voice connect() playback. Added in 0.6.0. |
Ref methods
| Name | Description |
|---|---|
| speakText(text, options?) | Run TTS + lipsync. Optional per-call overrides: voiceId, ttsEngineId (visemetts or eng_c9b1e6d4), language (e.g. "fr", "es-MX", "auto"), speakingRate (0.5–1.5). Example: twin.speakText("Bonjour", { voiceId: "eve", ttsEngineId: "visemetts", language: "fr", speakingRate: 0.85 }). |
| connect(options?) | Open mic + WebSocket voice session. Options: authToken, voiceId, ttsEngineId, language, brainId (personaId / knowledgeContextId are legacy aliases), brainUrl (client SSE brain; skips hosted LLM when set — see Using your own brain), speakingRate, ttsStream, apiBase, wsUrl. Pass the visitor JWT from POST /twin-lead/start as authToken, lead_id and twin_slug are already in the token, so a custom wsUrl is not required to persist history. Type: VoiceConnectOptions. |
| disconnect() | End the voice WebSocket session and release the mic. |
| isConnected() | Whether a voice WebSocket session is active. |
| stop() | Stop playback and return toward idle. |
| isReady() | Whether face assets are loaded. |
| getStatus() | Lipsync status: idle, loading, speaking, done, error. |
| getBrainUrl() | Client SSE brain URL from getAiTwin, if present. Same value connect() and speakWithReply send as brainUrl. |
Captions (subtitles)
The AiTwin player supports optional plain subtitles during bot speech. When enabled, a compact caption bubble appears at the bottom of the twin canvas showing the text for the current speech segment.
This is not karaoke-style highlighting. There is no per-word sync, no active-word color, and no themeColor / activeColor options. The full segment text is shown as one block.
Enabling captions
Pass captions to <AiTwin /> or the embed widget:
<AiTwin id="olivia" captions={true} /><AiTwin
id="olivia"
captions={{
fontFamily: "Georgia, serif",
fontSize: "1.1rem",
color: "rgba(255,255,255,0.85)",
}}
/>AiTwinWidget.init("#aitwin", {
id: "olivia",
captions: true,
});
// Or with custom styling:
AiTwinWidget.init("#aitwin", {
id: "olivia",
captions: {
fontFamily: "Georgia, serif",
fontSize: "1.1rem",
color: "rgba(255,255,255,0.85)",
},
});Works for:
- speakText(), SSE TTS via /avatar_ttsWithPoses
- Realtime voice, bot responses over the voice WebSocket
- AiTwinWidget.init(), same captions option via InitOptions
Configuration options
| Option | Type | Default | Description |
|---|---|---|---|
| captions | boolean | AiTwinCaptionsOptions | off | true enables with defaults; omit or false disables. |
| fontFamily | string | system-ui, -apple-system, sans-serif | CSS font-family. |
| fontSize | string | clamp(0.875rem, 2.5vw, 1.125rem) | CSS font-size (responsive). |
| color | string | rgba(255,255,255,0.85) | Text color. |
Exported type: AiTwinCaptionsOptions from @streamoji/aitwin.
Where the text comes from
Subtitle text is built from the word list in each TTS/voice audio chunk returned by the API (SSE audio events or realtime avatar_audio_chunk). Words are joined with spaces into one string, no timing-based highlighting.
The player shows one segment at a time: when a new audio chunk/segment starts, the subtitle updates to that chunk's words. Long responses split across multiple chunks show as separate subtitle updates, not one cumulative line for the whole utterance.
When captions appear
- captions is enabled
- The twin is speaking (TTS or voice playback active)
- Lipsync is not in warmup/prefetch hold
- The current chunk has at least one word in its queue
When captions disappear
- Speech ends (status returns toward idle)
- stop() is called
- A new speakText() starts (cleared immediately before the new request)
- Lipsync warmup is active (avoids showing prefetched multi-chunk text)
- captions is toggled off
UI behavior
- Position: bottom center of the twin container, overlaid on the canvas
- Layout: rounded semi-transparent dark bubble (rgba(0,0,0,0.55))
- Line clamp: up to 2 lines; longer text is truncated with ellipsis
- Interaction: pointer-events: none, captions don't block clicks on the twin
- Accessibility: aria-live="polite" and aria-atomic="true" for screen readers
What captions are not
- Not word-synced / karaoke, no per-word highlight or color change while speaking
- Not a full transcript panel, only the current segment, not the full bot turn history
- Not user speech, STT/user transcript is separate (onUserTranscript); captions reflect bot speech words from the TTS pipeline
- Not configurable for bubble background, bubble styling is fixed in the component (only font/color/size are props)
Language support
Set a default TTS language on <AiTwin language="…" /> (or AiTwinWidget.init). The same code is used for speakText() and voice connect() / Pipecat.
Override per utterance with speakText(text, { language }). If omitted, the component prop (then the engine default, typically en) is used.
auto is visemetts (Standard) only. If you switch to Premium (eng_c9b1e6d4), fall back to en.
<AiTwin
id="olivia"
ttsEngineId="visemetts"
language="es-MX"
/>
<button
type="button"
onClick={() =>
void twinRef.current?.speakText("Bonjour", { language: "fr" })
}
>
Speak French
</button>AiTwinWidget.init("#aitwin", {
id: "olivia",
language: "es-MX",
});
twin.speakText("Bonjour", { language: "fr" });| Code | Language | visemetts (Standard) | eng_c9b1e6d4 (Premium) |
|---|---|---|---|
| en | English | Yes | Yes |
| es | Spanish | Yes | Yes |
| es-MX | Spanish (Mexico) | Yes | Yes |
| es-ES | Spanish (Spain) | Yes | Yes |
| pt | Portuguese | Yes | Yes |
| pt-BR | Portuguese (Brazil) | Yes | Yes |
| pt-PT | Portuguese (Portugal) | Yes | Yes |
| fr | French | Yes | Yes |
| de | German | Yes | Yes |
| it | Italian | Yes | Yes |
| zh | Chinese | Yes | Yes |
| ja | Japanese | Yes | Yes |
| hi | Hindi | Yes | Yes |
| ko | Korean | Yes | Yes |
| ru | Russian | Yes | Yes |
| tr | Turkish | Yes | Yes |
| id | Indonesian | Yes | Yes |
| ar | Arabic | Yes | Yes |
| ar-EG | Arabic (Egypt) | Yes | Yes |
| ar-AE | Arabic (UAE) | Yes | Yes |
| vi | Vietnamese | Yes | Yes |
| bn | Bengali | Yes | Yes |
| auto | Auto-detect | Yes | No |
| nl | Dutch | No | Yes |
| pl | Polish | No | Yes |
| sv | Swedish | No | Yes |
| tl | Tagalog | No | Yes |
| bg | Bulgarian | No | Yes |
| ro | Romanian | No | Yes |
| cs | Czech | No | Yes |
| el | Greek | No | Yes |
| fi | Finnish | No | Yes |
| hr | Croatian | No | Yes |
| ms | Malay | No | Yes |
| sk | Slovak | No | Yes |
| da | Danish | No | Yes |
| ta | Tamil | No | Yes |
| uk | Ukrainian | No | Yes |
| hu | Hungarian | No | Yes |
| no | Norwegian | No | Yes |
| th | Thai | No | Yes |
| he | Hebrew | No | Yes |
| ka | Georgian | No | Yes |
| te | Telugu | No | Yes |
| gu | Gujarati | No | Yes |
| kn | Kannada | No | Yes |
| ml | Malayalam | No | Yes |
| mr | Marathi | No | Yes |
| pa | Punjabi | No | Yes |
Idle handoff
After speech ends, the twin returns to a still closed-mouth frame before the idle video loop resumes. Adjust the pause with idleHandoffHoldMs.
Applies to both speakText() and voice connect() playback.
<AiTwin
id="olivia"
idleHandoffHoldMs={3000}
/>HTML / Web integration
Load the AiTwinWidget UMD from CDN on plain HTML/JS sites. No npm install or React app, call init, then speakText or connect from your own buttons.
Overview
Load the drop-in AiTwinWidget UMD from CDN. One script tag bundles the player (React is included internally). The face stage is a fixed 240×320 (3:4) box.
CDN URL:
https://aitwin.bubu.social/aitwin-widget.umd.jsInteractive demo
Generate an auth token, load AiTwinWidget from the CDN, then test speakText on the live widget below.
For testing only. In production, call getAiTwinAuthToken from your backend and pass the token to your iframe URL.
Live widget preview
Loads AiTwinWidget from CDN with showControls: false.
Not loaded
Load the script
Mount a container, load the CDN script, then call AiTwinWidget.init. Use showControls: true for a built-in demo panel, or false (default) for a face-only embed you control from your own UI.
<!-- Full demo UI (controls panel) -->
<div id="aitwin"></div>
<script src="https://aitwin.bubu.social/aitwin-widget.umd.js"></script>
<script>
AiTwinWidget.init("#aitwin", {
showControls: true,
id: "zoe",
// authToken: "client_…", // from your backend
});
</script><!-- Product embed: face only + your own buttons -->
<div id="aitwin"></div>
<button type="button" id="speak">Speak</button>
<button type="button" id="talk">Connect</button>
<button type="button" id="hangup">Disconnect</button>
<script src="/embed/conversation-session.js"></script>
<script src="https://aitwin.bubu.social/aitwin-widget.umd.js"></script>
<script>
const twinSlug = "zoe";
const seedToken = "client_…"; // from getAiTwinAuthToken
const apiBase = "https://ai.aitwin.me";
let sessionToken = seedToken;
async function boot() {
const session = await AiTwinConversation.startTwinLead({
apiBase,
seedAuthToken: seedToken,
twinSlug,
});
sessionToken = session.authToken; // JWT contains lead_id
const twin = AiTwinWidget.init("#aitwin", {
id: twinSlug,
// Or assets: faceId, voiceId, ttsEngineId ("visemetts" or "eng_c9b1e6d4"), language
// brainUrl: "https://example.com/brain", // own SSE brain; skips hosted LLM
authToken: sessionToken,
showControls: false, // hide demo panel
onReady: () => console.log("ready"),
onUserTranscript: (text, final) => {
if (final) console.log("you:", text);
},
onBotOutput: (text) => console.log("bot:", text),
});
document.getElementById("speak").onclick = () => {
void twin.speakText("Hello from AiTwin", { language: "en" });
};
document.getElementById("talk").onclick = () => {
void twin.connect({ authToken: sessionToken });
};
document.getElementById("hangup").onclick = () => {
void twin.disconnect();
};
}
void boot();
</script>Auth token
Production Speak and voice need a short-lived client_* JWT from your backend. Create credentials on API Keys. Never put the Client Secret in HTML.
The account behind the token also needs aiTwin credits for TTS and /ws/voice.
curl -X POST "https://us-central1-streamoji-265f4.cloudfunctions.net/getAiTwinAuthToken" \
-H "Content-Type: application/json" \
-H "Client-Id: YOUR_CLIENT_ID" \
-H "Client-Secret: YOUR_64_CHAR_API_KEY" \
-d '{"userId":"end-user-123","userName":"Jane Doe"}'Speak and voice
Speak - your app (or LLM reply) supplies text; call twin.speakText(text).
Reply, build chat in your site; after your backend returns spoken text, call speakText. The widget does not include a chat UI.
Voice, from a button click call twin.connect({ authToken }), then disconnect(). Use onUserTranscript / onBotOutput to mirror STT and bot text in your UI.
Conversation history
Reuse one lead_id per browser tab (sessionStorage key aitwin:lead:{slug}). Call POST /twin-lead/start with your seed client_* token and twin_slug. The response is { lead_id, authToken, twin_slug }, pass that authToken to the widget. Voice and text then persist without a custom wsUrl. History is stored only for twins with a brain (brainId or brainUrl). Owners review threads in Conversations History.
Copy /embed/conversation-session.js from this site, or call POST /twin-lead/start yourself and pass the returned authToken to the widget.
Captions (subtitles)
Pass captions in AiTwinWidget.init(target, options) , same shape as the React <AiTwin /> prop: true for defaults, or an object with fontFamily, fontSize, and color. Captions show bot speech during speakText() and realtime voice, not user STT.
See the full Captions (subtitles) section for configuration defaults, show/hide behavior, and limitations.
AiTwinWidget.init("#aitwin", {
id: "olivia",
captions: true,
});
// Or with custom styling:
AiTwinWidget.init("#aitwin", {
id: "olivia",
captions: {
fontFamily: "Georgia, serif",
fontSize: "1.1rem",
color: "rgba(255,255,255,0.85)",
},
});| Option | Type | Default | Description |
|---|---|---|---|
| captions | boolean | AiTwinCaptionsOptions | off | true enables with defaults; omit or false disables. |
| fontFamily | string | system-ui, -apple-system, sans-serif | CSS font-family. |
| fontSize | string | clamp(0.875rem, 2.5vw, 1.125rem) | CSS font-size (responsive). |
| color | string | rgba(255,255,255,0.85) | Text color. |
Language support
Pass language in AiTwinWidget.init for the default TTS / Pipecat language, or override it in speakText(text, { language }). visemetts (Standard) and eng_c9b1e6d4 (Premium) support different codes, see Language support.
AiTwinWidget.init("#aitwin", {
id: "olivia",
language: "es-MX",
});
twin.speakText("Bonjour", { language: "fr" });Widget API reference
Handle returned by AiTwinWidget.init(target, options).
| Method | Description |
|---|---|
| speakText(text, options?) | TTS + lipsync. Optional voiceId, ttsEngineId (visemetts or eng_c9b1e6d4), language, and speakingRate per call. |
| connect(options?) | Mic + WebSocket voice (/ws/voice). Call from a user gesture. Pass the visitor JWT from POST /twin-lead/start as authToken, lead_id is already in the token. Optional connect/init brainUrl sends the client SSE brain (skips hosted LLM; same contract as reply APIs). |
| disconnect() | End the voice session and release the mic. |
| stop() | Stop current TTS playback toward idle. |
| isReady() / isConnected() | Face loaded / voice session active. |
| update(options) / destroy() | Change twin id / token, or unmount the widget. |
Widget CSP
If you use a Content-Security-Policy, allow the widget script host in script-src, plus the usual AiTwin API / CDN / wss: entries from the React integration CSP note.
script-src 'self' https://aitwin.bubu.social;Using your own brain
Point the twin at your SSE endpoint instead of a hosted AiTwin knowledge base. Voice and reply APIs stream your tokens and skip the hosted brain.
Overview
Hosted AiTwin brains store knowledge and a persona on our side. If you already have an agent, LLM, or RAG pipeline, point the twin at brainUrl instead. AiTwin then skips the hosted brain and uses your SSE replies for live voice and text reply paths.
On Create Twin, open the Brain tab and switch to Own brain (AiTwin brain is the default). Paste an https SSE URL. The same field is stored on the twin as brainUrl.
Where it is used
/ws/voice— live voice. Tokens are spoken as they arrive (lowest latency). Session query params (except auth andbrainUrl) may be forwarded onto your URL.POST /avatarReply— text chat. Streams accumulate into one reply; plain text becomes bothspokenTextanddisplayText. Optional: if the full concatenated body is JSON withspokenText/displayText(ortext), those fields are used. Forwardslead_id,twin_slug, andbrainIdwhen present.POST /avatar_ttsWithPoseswithgenerateReply: true— reply + TTS in one round-trip. Same brain GET contract; the server waits for the full brain stream, then utterance-splits for TTS. Client SSE emits one or morespokenTextevents (no separatedisplayText). Same forwarded params as/avatarReply.
Prefer streaming plain speakable text tokens. That works on every path. Do not send Auth or brainUrl back to yourself — AiTwin never forwards those.
SSE protocol
AiTwin issues a GET to your URL with Accept: text/event-stream. Requirements:
- URL must be
httporhttps, with a host, and no#fragment. - The latest user utterance is always query param
text. It overwrites any existingtexton your URL. - Extra query params may be merged onto your URL (see Where it is used).
authToken,Authorization, andbrainUrlare never forwarded. - Respond with
Content-Type: text/event-stream. - Only
data:lines are used. Empty data lines anddata: [DONE]are skipped.event:,id:, and comment lines (:) are ignored. Payloads are concatenated in order.
Minimal stream
data: Hello
data: there
data: , how can I help?
data: [DONE]
Examples
Probe with curl
curl -N "https://example.com/brain?text=Hello%20there" \
-H "Accept: text/event-stream"Node.js HTTP server
import http from "node:http";
http
.createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://localhost:8787");
if (url.pathname !== "/brain") {
res.writeHead(404);
res.end();
return;
}
const text = url.searchParams.get("text") ?? "";
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
const reply = text.trim()
? `You said: ${text}`
: "I didn't catch that.";
for (const token of reply.split(" ")) {
res.write(`data: ${token} \n\n`);
}
res.write("data: [DONE]\n\n");
res.end();
})
.listen(8787);
console.log("Brain listening on http://localhost:8787/brain");
Next.js App Router
import { NextRequest } from "next/server";
export const runtime = "nodejs";
export async function GET(request: NextRequest) {
const text = request.nextUrl.searchParams.get("text") ?? "";
const encoder = new TextEncoder();
const reply = text.trim() ? `You said: ${text}` : "I didn't catch that.";
const stream = new ReadableStream({
start(controller) {
for (const token of reply.split(" ")) {
controller.enqueue(encoder.encode(`data: ${token} \n\n`));
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
});
}
Attach it to a twin
Create or edit a twin, open Brain, choose Own brain, paste the URL, and save. Hosted AiTwin brains are unused while brainUrl is set. Switch back to AiTwin brain to use a knowledge base instead.
The player and SDK load brainUrl from the twin and pass it on /ws/voice and on reply APIs that accept it. Embed create-twin success payloads include brainUrl when configured, see the Create Twin embed SDK.