PageSourceSearch

https://realtimeavatar.ai/assets/docs-C1Yjujd2.js

js realtimeavatar.ai collected 2026-10-05 12:34:06 UTC 224,311 bytes, 999 lines download raw bytes

1import{t as e}from"./jsx-runtime-BtH0gOTJ.js";import{c as t,d as n,l as r,m as i,p as a,u as o,v as s,x as c}from"./catalog-BeuOQp3e.js";import{n as l,r as u}from"./highlight-BaQJfTn-.js";var d=[{id:`start`,label:`Get started`},{id:`integrate`,label:`Integrate`},{id:`build`,label:`Build`},{id:`reference`,label:`Reference`}];function f(e){return e===`overview`?`/docs`:`/docs/${e}`}`${i(o)}${i(r)}`;var p=`${a(o*60)}–${a(r*60)}`;a(t),c(`sandbox`).includedRealtimeMinutes,n.filter(e=>!s(e)&&e.monthlyUsd!==null&&e.monthlyUsd>0);var m=e();function h({id:e,tabs:t}){return(0,m.jsxs)(`div`,{className:`code-tabs`,children:[t.map((t,n)=>(0,m.jsx)(`input`,{type:`radio`,name:e,id:`${e}-${n}`,defaultChecked:n===0},`${e}-input-${t.label}`)),(0,m.jsx)(`div`,{className:`code-tabs-list`,children:t.map((t,n)=>(0,m.jsx)(`label`,{htmlFor:`${e}-${n}`,children:t.label},`${e}-label-${t.label}`))}),(0,m.jsx)(`div`,{className:`code-tabs-panes`,children:t.map(t=>(0,m.jsxs)(`div`,{className:`code-tabs-pane`,children:[(0,m.jsx)(`pre`,{children:(0,m.jsx)(`code`,{children:l(t.code,t.lang??u(t.label))})}),t.note?(0,m.jsx)(`p`,{className:`code-tabs-note`,children:t.note}):null]},`${e}-pane-${t.label}`))})]})}
1function g({code:e,lang:t=`ts`}){return(0,m.jsx)(`pre`,{children:(0,m.jsx)(`code`,{children:l(e,t)})})}var _={slug:`overview`,title:`Realtime Avatar API`,navLabel:`Overview`,description:`Put a live character in your product, on a voice or video call, with your own tools wired into the conversation.`,group:`start`,order:0,updated:`2026-09-14`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(`h2`,{children:`Voice or video, on one meter`}),(0,m.jsx)(h,{id:`ov-mode`,tabs:[{label:`TypeScript`,lang:`tsx`,code:`<AvatarCall client={client} avatarId={avatarId} />                 // she is on screen
2<AvatarCall client={client} avatarId={avatarId} mode="voice" />   // audio only`},{label:`Python`,code:`body = {"avatar_id": avatar_id, "mode": "avatar"}   # she is on screen
3body = {"avatar_id": avatar_id, "mode": "voice"}    # audio only`}]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`It is priced to leave on.`}),` Live-call self-serve overage meters at`,` `,(0,m.jsx)(`strong`,{children:`about $5 an hour`}),` — `,p,` depending on plan — with a free tier to build against. That is the difference between a demo you show and a feature you ship: a companion app can afford to let someone talk.`]}),(0,m.jsxs)(`p`,{children:[`How she is rendered is in `,(0,m.jsx)(`a`,{href:`/docs/video`,children:`Creating an avatar`}),`.`]}),(0,m.jsx)(`h2`,{children:`She listens while she talks`}),(0,m.jsx)(`p`,{children:`Most voice AI takes turns like a walkie-talkie: you talk, you stop, it answers. This one is full-duplex — she is hearing you the whole time she is speaking — and in practice that shows up as four things you do not have to build:`}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`Interrupt her and she stops`}),`, mid-sentence, and can acknowledge it in character rather than snapping to silence.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`A cough or an "mm-hm" does not derail her.`}),` A backchannel is not an interruption, and being cut off by one is what makes a system feel brittle.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`A pause is not the end of your turn.`}),` Whether you are finished is judged by what you said, not by how long you have been quiet — so she neither talks over you nor leaves a gap.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`Silence and language switches are handled.`}),` Going quiet is a signal she can act on, and she follows a language change inside a single sentence.`]})]}),(0,m.jsx)(`p`,{children:`Nothing to configure — it is how every call behaves. The one limit worth knowing: she will not talk over you with a new sentence while you are speaking. That is a deliberate trade for the voice and model choices this platform is built on.`}),(0,m.jsx)(`h2`,{children:`Your tools, wired into the conversation`}),(0,m.jsx)(`p`,{children:`A character that can only talk is a demo. The useful version books the appointment, checks the order, writes the code and keeps talking while it runs.`}),(0,m.jsxs)(`p`,{children:[`You declare a tool the same way you would brief a colleague: what it is called, and`,` `,(0,m.jsx)(`em`,{children:`when to reach for it`}),`. The description is the whole teaching signal — she reads it and decides.`]}),(0,m.jsx)(h,{id:`ov-declare`,tabs:[{label:`TypeScript`,code:`import type { AvatarTool } from "realtime-avatar/tools";
4
5export const checkOrder: AvatarTool<{ order_id: string }> = {
6  description:
7    "Look up the status of a customer's order. Call this whenever they ask " +
8    "where something is, or when it will arrive.",
9  parameters: {
10    type: "object",
11    properties: { order_id: { type: "string" } },
12    required: ["order_id"],
13  },
14  execute: async ({ order_id }, { signal }) => {
15    const order = await api.order(order_id, { signal });
16    return \`\${order.status}, arriving \${order.eta}.\`;
17  },
18};`},{label:`Python`,code:`# Tools run in the page that renders the call — there is no hosted execution,
19# so there is nothing to declare in Python. Your backend's part is the GRANT:
20body["capabilities"] = ["client_tools"]
21
22# The mint schema is strict: a tools[] field on the request itself is a 422.
23# The page registers the manifest over RPC once the room is connected.`}]}),(0,m.jsxs)(`p`,{children:[`Then your server grants the tool plane at mint, and the page registers the tools once the room is connected. Omitting `,(0,m.jsx)(`code`,{children:`execute`}),` is a compile error rather than a timeout you find in production — and a tool has `,(0,m.jsx)(`strong`,{children:`2.5 seconds`}),` to answer, so anything slow acknowledges fast and delivers the real result out of band:`]}),(0,m.jsx)(h,{id:`ov-tools`,tabs:[{label:`TypeScript`,code:`// server — the session policy grants the client tool plane for this call
24session: async ({ avatarId }) => ({ instructions, clientTools: true })
25
26// client — register over RPC after connect; the record key is the tool's name
27import { attachAvatarTools } from "realtime-avatar/tools";
28
29const { accepted, rejected } = await attachAvatarTools(room, {
30  check_order: checkOrder,
31});`},{label:`Python`,code:`# server — grant the capability on the mint. That is the whole backend part:
32body["capabilities"] = ["client_tools"]
33
34# The worker only exposes tool registration to a session minted with the
35# capability. If the page reports the registration method is missing, the fix
36# is HERE, not in the page.`}]}),(0,m.jsxs)(`p`,{children:[`Full pattern: `,(0,m.jsx)(`a`,{href:`/docs/tool-calling`,children:`Tool calling`}),`.`]}),(0,m.jsxs)(`blockquote`,{children:[`These examples target `,(0,m.jsx)(`code`,{children:`[email protected]`}),`. On an older install the exports differ and the compiler will say so — update the package rather than working around it.`]}),(0,m.jsx)(`h2`,{children:`Where to go next`}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/quickstart`,children:`Quickstart`}),` — key to live call in three steps.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/sessions`,children:`Calls`}),` — the policy your server decides, the five states, ending gracefully.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/video`,children:`Creating an avatar`}),` — one image in; the platform generates the loop and a map of states it switches between, or synthesizes the video live.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/tool-calling`,children:`Tool calling`}),` — running your own agent loop against a live character.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/api-reference`,children:`API reference`}),` — every endpoint, scope, and error.`]})]})]})},v={slug:`quickstart`,title:`Quickstart`,description:`From an API key to a live avatar call in three steps: get a key, build the app on an example avatar, then swap in a character of your own.`,group:`start`,order:1,updated:`2026-09-14`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(`p`,{children:`Three steps. Your first call runs on a public example avatar, so nothing is blocked on creating one — and the app is one step, not two: a server half that holds the key and a client half that renders. The SDK ships both.`}),(0,m.jsx)(`h2`,{children:`1. Get a key and install the SDK`}),(0,m.jsxs)(`p`,{children:[`Open `,(0,m.jsx)(`a`,{href:`/platform/settings#api-keys`,children:`workspace Settings → API keys`}),`, give your key a name, and select `,(0,m.jsx)(`strong`,{children:`Create API key`}),`. Copy the full key and save it as `,(0,m.jsx)(`code`,{children:`REALTIME_AVATAR_API_KEY`}),` in your server’s environment. The default permissions work with this quickstart; advanced settings are optional.`]}),(0,m.jsxs)(`p`,{children:[`The list stays masked. Workspace owners and admins can return to Settings and select `,(0,m.jsx)(`strong`,{children:`Copy`}),` to copy newly created keys again while they are active and unexpired. Legacy keys stored as hashes only cannot be recovered if lost and need a replacement. Revoked or expired keys cannot be copied. See `,(0,m.jsx)(`a`,{href:`/docs/authentication`,children:`Authentication`}),` for details.`]}),(0,m.jsx)(`p`,{children:`The SDK is on npm — no registry configuration, no auth token:`}),(0,m.jsx)(h,{id:`qs-install`,tabs:[{label:`TypeScript`,lang:`bash`,code:`npm install --save-exact [email protected]`,note:(0,m.jsx)(m.Fragment,{children:`Self-contained: the contracts package is bundled into the build, so there is no private dependency to resolve. Pin an exact version for reproducible CI.`})},{label:`Python`,lang:`bash`,code:`pip install httpx`,note:(0,m.jsx)(m.Fragment,{children:`There is no Python SDK — the live call is rendered by a browser or native client, so Python's half is plain HTTP and one dependency.`})}]}),(0,m.jsx)(`h2`,{children:`2. Build the app on an example avatar`}),(0,m.jsxs)(`p`,{children:[`An app is two halves and the SDK ships both: a connect endpoint on your server, and one component in the browser. Hand this whole step to a coding agent if you would rather not type it — the prompt is on your key in`,` `,(0,m.jsx)(`a`,{href:`/platform/api-keys`,children:`settings`}),`.`]}),(0,m.jsx)(`h3`,{children:`The server half — the connect endpoint`}),(0,m.jsxs)(`p`,{children:[`Your API key must never reach a browser, so the browser talks to your app and your app talks to us. One function is that endpoint — it decides `,(0,m.jsx)(`strong`,{children:`who`}),` may start a call and `,(0,m.jsx)(`strong`,{children:`what`}),` the character knows when they do:`]}),(0,m.jsx)(h,{id:`qs-endpoint`,tabs:[{label:`Next.js`,code:`// app/api/realtime-avatar/[...path]/route.ts
37import { createRealtimeAvatarRoute } from "realtime-avatar/nextjs";
38
39export const { GET, POST } = createRealtimeAvatarRoute({
40  apiKey: process.env.REALTIME_AVATAR_API_KEY!,
41
42  // Who may do this. "connect" is the only operation that costs money to start,
43  // so that is where the wallet check belongs; the reads stay cheap.
44  authorize: async ({ request, operation }) => {
45    const user = await currentUser(request);
46    if (!user) return new Response("Unauthorized", { status: 401 });
47    if (operation === "connect" && !(await hasCredits(user))) {
48      return Response.json({ code: "insufficient_credits" }, { status: 402 });
49    }
50  },
51
52  // What the character knows for THIS call. Whatever the browser sent for these
53  // concerns is discarded — this callback is the only source.
54  session: async ({ request, avatarId, mode }) => {
55    const user = await currentUser(request);
56    const character = await db.character(avatarId);
57    return {
58      instructions: character.prompt,
59      context: await db.recentTurns(user.id, avatarId),
60      maxSeconds: secondsTheBalanceAffords(user, mode),
61    };
62  },
63});`,note:(0,m.jsxs)(m.Fragment,{children:[`The catch-all segment matters — the route answers four paths under one prefix (`,(0,m.jsx)(`code`,{children:`POST /connect`}),`, `,(0,m.jsx)(`code`,{children:`POST /end`}),`, `,(0,m.jsx)(`code`,{children:`GET /avatars`}),`,`,` `,(0,m.jsx)(`code`,{children:`GET /credits`}),`), so a single `,(0,m.jsx)(`code`,{children:`[...path]`}),` segment serves all of them.`]})},{label:`TanStack Start`,code:`// src/routes/api/realtime-avatar/$.ts
64import { createFileRoute } from "@tanstack/react-router";
65import { realtimeAvatarServerRoute } from "realtime-avatar/tanstack-start";
66
67const handlers = realtimeAvatarServerRoute({
68  apiKey: process.env.REALTIME_AVATAR_API_KEY!,
69  authorize: async ({ request, operation }) => {
70    const user = await requireUser(request);
71    if (!user) return new Response("Unauthorized", { status: 401 });
72    if (operation === "connect" && !(await hasCredits(user))) {
73      return Response.json({ code: "insufficient_credits" }, { status: 402 });
74    }
75  },
76  session: async ({ request, avatarId, mode }) => {
77    const user = await requireUser(request);
78    const character = await loadCharacter(avatarId);
79    return {
80      instructions: buildPrompt(character),
81      context: await loadRecentTurns(user.id, avatarId),
82      maxSeconds: secondsTheBalanceAffords(user, mode),
83    };
84  },
85});
86
87export const Route = createFileRoute("/api/realtime-avatar/$")({
88  server: { handlers },
89});`,note:(0,m.jsxs)(m.Fragment,{children:[`The trailing `,(0,m.jsx)(`code`,{children:`$`}),` is Start's splat segment — without it the handler only ever sees the mount path and answers 404. Build the handlers once at module scope. On Cloudflare Workers pass a factory — `,(0,m.jsx)(`code`,{children:`apiKey: () => getEnv().KEY`}),` — since there is no `,(0,m.jsx)(`code`,{children:`process.env`}),`.`]})},{label:`Python`,code:`# Your backend decides; the browser renders. Same split, no SDK needed.
90import os, httpx
91from fastapi import Depends, FastAPI
92from fastapi.responses import JSONResponse
93from pydantic import BaseModel
94
95BASE = "https://realtimeavatar.ai/api/v1"
96client = httpx.Client(base_url=BASE, timeout=30.0, headers={
97    "Authorization": f"Bearer {os.environ['REALTIME_AVATAR_API_KEY']}"})
98
99app = FastAPI()
100
101
102class StartCallRequest(BaseModel):
103    avatar_id: str
104    mode: str = "avatar"                          # "avatar" | "voice"
105    queue_ticket_id: str | None = None            # a client retrying after a 429 hands its ticket back
106
107
108@app.post("/api/calls")
109def start_call(req: StartCallRequest, user=Depends(current_user)):
110    if not has_credits(user):
111        return JSONResponse({"code": "insufficient_credits"}, 402)
112
113    character = load_character(req.avatar_id)
114    seconds = int(seconds_the_balance_affords(user, req.mode))   # wire: int, 1..1800
115    body = {
116        "avatar_id": character.avatar_id,
117        "mode": req.mode,
118        "stt_mode": "server",
119        # Decided HERE. The client sends none of this.
120        "instructions": build_prompt(character),
121        # ≤32 of exactly {"role": "system" | "user" | "assistant", "content": 1..8000 chars} —
122        # strict: any other key is a 422
123        "initial_context": [{"role": t.role, "content": t.text}
124                            for t in recent_turns(user.id, character.avatar_id)][-32:],
125        "max_session_seconds": max(1, min(seconds, 1800)),
126        # Optional: a signed POST of the transcript when the call ends (receiver: Tool calling).
127        "transcript_webhook": {"url": "https://your.app/api/rta/transcript",   # https only
128                               "secret": os.environ["TRANSCRIPT_SECRET"]},     # 16..200 chars
129        "client_metadata": {"user_id": str(user.id),                           # ≤16 string pairs,
130                            "character_id": character.avatar_id},              # echoed back to you
131    }
132    if req.queue_ticket_id:
133        body["queue_ticket_id"] = req.queue_ticket_id
134
135    r = client.post("/realtime/livekit/session", json=body)
136    data = r.json()
137    if r.status_code == 429 and "queue_ticket_id" in data:
138        # Every slot busy — the queue, not an error. Relay it VERBATIM: the busy body
139        # carries queue_ticket_id (the only handle that RELEASES the slot when the
140        # caller gives up) and recommended_retry_ms (when to come back). The client
141        # retries WITH the ticket; reshaping this strands it for its whole TTL.
142        return JSONResponse(data, 429)
143    if not r.is_success:
144        # 402 / 409 / 422 — and the 429s that are NOT the queue (plan concurrency
145        # ceiling, rate limit). Forward {error, code?}, but not at 429: an SDK
146        # client reads every 429 as the queue.
147        return JSONResponse(data, 503 if r.status_code == 429 else r.status_code)
148    return data                                   # return this VERBATIM`,note:(0,m.jsxs)(m.Fragment,{children:[`Two rules when hand-rolling. First, casing is `,(0,m.jsx)(`strong`,{children:`per endpoint`}),`, and every endpoint is strict — an unknown or mis-cased key is a rejected call. The two realtime routes shown here (`,(0,m.jsx)(`code`,{children:`/realtime/livekit/session`}),` and its `,(0,m.jsx)(`code`,{children:`/release`}),`) are`,` `,(0,m.jsx)(`strong`,{children:`snake_case`}),`; every REST resource endpoint — avatars, clips, keys, assets — is `,(0,m.jsx)(`strong`,{children:`camelCase`}),`. Measured against the published spec: 2 of the 10 request bodies are snake_case at the top level and the other 8 are camelCase — except `,(0,m.jsx)(`code`,{children:`POST /avatars`}),`, which is camelCase outside and snake_case inside `,(0,m.jsx)(`code`,{children:`voice`}),` (`,(0,m.jsx)(`code`,{children:`auto_description`}),`,`,` `,(0,m.jsx)(`code`,{children:`voice_id`}),`), as the sample above shows. And the SDK's policy names are not the wire names: `,(0,m.jsx)(`code`,{children:`context`}),` is `,(0,m.jsx)(`code`,{children:`initial_context`}),`,`,` `,(0,m.jsx)(`code`,{children:`maxSeconds`}),` is `,(0,m.jsx)(`code`,{children:`max_session_seconds`}),`,`,` `,(0,m.jsx)(`code`,{children:`transcript`}),` is `,(0,m.jsx)(`code`,{children:`transcript_webhook`}),`,`,` `,(0,m.jsx)(`code`,{children:`metadata`}),` is `,(0,m.jsx)(`code`,{children:`client_metadata`}),`. Second, if your client uses this SDK the response must reach it `,(0,m.jsx)(`strong`,{children:`byte-for-byte`}),` — the SDK hands the body to the room unvalidated, so a wrapped grant never connects: the token and URL read as undefined and the call sits in`,` `,(0,m.jsx)(`code`,{children:`connecting`}),` with no error. A client driving the room directly reads only the fields it needs and tolerates extras, but relaying verbatim keeps both paths working. The same goes for a busy `,(0,m.jsx)(`code`,{children:`429`}),`: it is the queue only when the body carries `,(0,m.jsx)(`code`,{children:`queue_ticket_id`}),`, and a retry that does not present that ticket mints a fresh one every poll — its position never advances.`]})}]}),(0,m.jsxs)(`p`,{children:[`Everything in `,(0,m.jsx)(`code`,{children:`session`}),` is authoritative: whatever the browser sends for the persona, the memory, the voice, or the time limit is discarded before the request leaves your server. A field your policy does not set is absent rather than 
148inherited, so an omission fails closed rather than open.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`code`,{children:`maxSeconds`}),` is the one to get right on day one — it is what stops a call your balance cannot cover. See `,(0,m.jsx)(`a`,{href:`/docs/authentication`,children:`Authentication`}),` for the full list of what belongs on the server and why.`]}),(0,m.jsx)(`h3`,{children:`The client half — render the call`}),(0,m.jsxs)(`p`,{children:[`Build the client once, then render one component. It handles the queue, reconnects, the idle timer, and the video surface for you. Pass a public example avatar id to`,(0,m.jsx)(`code`,{children:`avatarId`}),` — `,(0,m.jsx)(`code`,{children:`seed-rin-ashfall`}),` is one — and this call is live before you have created anything; step 3 swaps in your own:`]}),(0,m.jsxs)(`p`,{children:[`An example avatar serves its own still and idle loop, so the surface has something to show before the first frame arrives. A catalog row with no `,(0,m.jsx)(`code`,{children:`idleVideoUrl`}),` is a stream-only host published for one of the live channels; a call to one is refused with 409, so call an id whose row carries an idle loop. The two below are`,` `,(0,m.jsx)(`code`,{children:`seed-rin-ashfall`}),`’s. `,(0,m.jsx)(`strong`,{children:`Both props are optional and the filenames are not a scheme`}),` — they differ from character to character, so copy the pair that belongs to the avatar you are using rather than deriving them from its name. Point `,(0,m.jsx)(`code`,{children:`poster`}),` and `,(0,m.jsx)(`code`,{children:`idleVideoUrl`}),` at them and the page is never empty: she is on screen while the call is still connecting.`]}),(0,m.jsx)(h,{id:`qs-client`,tabs:[{label:`React`,code:`import { AvatarCall, createProxyClient } from "realtime-avatar/react";
149
150const client = createProxyClient({ proxyUrl: "/api/realtime-avatar" });
151
152export function Call({ avatarId }: { avatarId: string }) {
153  return (
154    <AvatarCall
155      client={client}
156      avatarId={avatarId}
157      idleVideoUrl="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/idle-10s.mp4"
158      poster="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/portrait.png"
159      onEnded={({ reason }) => showEndScreen(reason)}
160    >
161      {(call) =>
162        call.status === "waiting" ? <Banner>In line: {call.queuePosition}</Banner> : null
163      }
164    </AvatarCall>
165  );
166}`,note:(0,m.jsxs)(m.Fragment,{children:[`Live media needs the DOM. In Next.js mark the file `,(0,m.jsx)(`code`,{children:`"use client"`}),` and import it with `,(0,m.jsx)(`code`,{children:`{ ssr: false }`}),`; in TanStack Start gate it on a mounted flag or load it lazily.`]})},{label:`React Native`,code:`import {
167  AvatarVideoSurface,
168  RealtimeAvatarLiveKitRoom,
169  SessionLifecycleRoomBridge,
170  createProxyClient,
171  registerGlobals,
172  useSessionLifecycle,
173} from "realtime-avatar/react-native";
174
175registerGlobals();                                  // once, at app startup
176
177// Native has no page origin — the proxy URL must be ABSOLUTE.
178const client = createProxyClient({
179  proxyUrl: "https://your-app.example.com/api/realtime-avatar",
180});
181
182export function Call({ avatarId }: { avatarId: string }) {
183  const lifecycle = useSessionLifecycle({ client, session: { avatarId } });
184  if (!lifecycle.grant) return null;                // map lifecycle.phase to your UI
185
186  return (
187    <RealtimeAvatarLiveKitRoom
188      grant={lifecycle.grant}
189      onConnected={lifecycle.onConnected}
190      onDisconnected={lifecycle.onDisconnected}
191      onError={lifecycle.onConnectionError}
192    >
193      <SessionLifecycleRoomBridge lifecycle={lifecycle} />
194      <AvatarVideoSurface
195        idleVideoUrl={idleClipUrl}
196        poster={posterUrl}
197        // Native has no <video> — hand the idle clip to YOUR player, looped and muted.
198        renderIdleVideo={({ url, style }) => <IdleClip url={url} style={style} />}
199      />
200    </RealtimeAvatarLiveKitRoom>
201  );
202}`,note:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(`strong`,{children:`N
202ative is the lower-level surface today.`}),` `,(0,m.jsx)(`code`,{children:`<AvatarCall>`}),` is web-only — the session brain is the same module, but you compose the native room and video surface yourself, and you pick the video player (`,(0,m.jsx)(`code`,{children:`expo-video`}),`, `,(0,m.jsx)(`code`,{children:`react-native-video`}),`). Live media does not run in Expo Go; you need a dev client or an EAS build.`]})}]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`code`,{children:`status`}),` is five values — `,(0,m.jsx)(`code`,{children:`connecting`}),` · `,(0,m.jsx)(`code`,{children:`waiting`}),` ·`,` `,(0,m.jsx)(`code`,{children:`live`}),` · `,(0,m.jsx)(`code`,{children:`recovering`}),` · `,(0,m.jsx)(`code`,{children:`ended`}),` — and you write the copy for the ones you care about. The SDK ships no strings.`]}),(0,m.jsxs)(`p`,{children:[`The `,(0,m.jsx)(`code`,{children:`children`}),` render prop hands you a `,(0,m.jsx)(`code`,{children:`call`}),` handle:`,` `,(0,m.jsx)(`code`,{children:`say()`}),`, `,(0,m.jsx)(`code`,{children:`sayAndEnd()`}),`, `,(0,m.jsx)(`code`,{children:`keepAlive()`}),`,`,` `,(0,m.jsx)(`code`,{children:`end()`}),`, and `,(0,m.jsx)(`code`,{children:`secondsRemaining`}),`. Want your own layout around the video rather than on top of it? `,(0,m.jsx)(`code`,{children:`useAvatarCall()`}),` returns`,` `,(0,m.jsx)(`code`,{children:`{ call, view }`}),` so you can place the view anywhere.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`React Native`}),` runs the same session brain with native media —`,` `,(0,m.jsx)(`code`,{children:`useSessionLifecycle`}),`, the grant and queue handling, and the quality governor are the identical modules; only the room bridge and the video surface are native twins. Import from `,(0,m.jsx)(`code`,{children:`/react-native`}),`, call`,` `,(0,m.jsx)(`code`,{children:`registerGlobals()`}),` at startup, and pass an absolute`,` `,(0,m.jsx)(`code`,{children:`proxyUrl`}),` (native has no page origin). The one-component`,` `,(0,m.jsx)(`code`,{children:`<AvatarCall>`}),` wrapper is web-only for now.`]}),(0,m.jsx)(`h2`,{children:`3. Make the character yours`}),(0,m.jsxs)(`p`,{children:[`The example avatars get you a working call; your own character is one create call away, and nothing above changes but the id. An avatar is one portrait image plus a voice. Upload the image and the platform does the rest `,(0,m.jsx)(`strong`,{children:`in the background`}),`: it generates the looping idle video and a small motion library — an idle variant, a listening state, a gesture — from that single frame. From a trusted server:`]}),(0,m.jsx)(h,{id:`qs-avatar`,tabs:[{label:`TypeScript`,code:`import { RealtimeAvatar } from "realtime-avatar";
203
204const rta = new RealtimeAvatar({ apiKey: process.env.REALTIME_AVATAR_API_KEY! });
205
206// Upload the portrait, then create the avatar from it.
207const asset = await rta.uploadAsset(portraitBlob, { kind: "image", filename: "rin.png" });
208
209const avatar = await rta.createAvatar({
210  displayName: "Rin",
211  sourceKind: "image",                    // one frontal portrait: jpeg/png/webp, up to 8MB
212  sourceAssetId: asset.id,
213  voice: { auto_description: "Warm, clear, mid-pitch — natural and conversational." },
214});
215
216console.log(avatar.id);      // ava_…
217console.log(avatar.status);  // "preprocessing" — generation has started`},{label:`Python`,code:`import os, time, httpx
218
219client = httpx.Client(
220    base_url="https://realtimeavatar.ai/api/v1", timeout=60.0,
221    headers={"Authorization": f"Bearer {os.environ['REALTIME_AVATAR_API_KEY']}"})
222
223# Register the portrait, then create the avatar from it.
224asset = client.post("/assets/remote", json={
225    "kind": "image",
226    "remoteUrl": "https://cdn.example.com/rin/portrait.png",
227}).json()
228
229avatar = client.post("/avatars", json={
230    "displayName": "Rin",
231    "sourceAssetId": asset["id"],
232    "voice": {"auto_description": "Warm, clear, mid-pitch — natural and conversational."},
233}).json()
234
235print(avatar["id"])      # ava_…
236print(avatar["status"])  # "preprocessing" — generation has started
237                         # ("failed" + avatar["error"] if it could not be queued)
238
239# Poll until the loop and clips have rendered — a minute or two.
240while avatar["status"] == "preprocessing":
241    time.sleep(15)
242    avatar = client.get(f"/avatars/{avatar['id']}").json()
243print(avatar["status"])  # "ready" — mint calls; "failed" — avatar["error"] says why`}]}),(0,m.jsxs)(`blockquote`,{children:[(0,m.jsx)(`strong`,{children:`Creation is asynchronous.`}),` The create call returns in milliseconds with `,(0,m.jsx)(`code`,{children:`status: "preprocessing"`}),`; the idle loop and the clip library render in the background over the next minute or two. Poll `,(0,m.jsx)(`code`,{children:`getAvatar(id)`}),` `,`(`,(0,m.jsx)(`code`,{children:`GET /avatars/{id}`}),` on the wire) until `,(0,m.jsx)(`code`,{children:`status`}),` is`,` `,(0,m.jsx)(`code`,{children:`ready`}),` before minting a call. Before the loop attaches a mint is refused with a clear error — never a broken call.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsxs)(`strong`,{children:[`Why a `,(0,m.jsx)(`code`,{children:`failed`}),` status happened is not on the SDK object.`]}),` `,(0,m.jsx)(`code`,{children:`getAvatar()`}),` returns the five fields an integration branches on —`,` `,(0,m.jsx)(`code`,{children:`id`}),`, `,(0,m.jsx)(`code`,{children:`displayName`}),`, `,(0,m.jsx)(`code`,{children:`sourceKind`}),`,`,` `,(0,m.jsx)(`code`,{children:`status`}),`, `,(0,m.jsx)(`code`,{children:`defaultVoiceId`}),` — and nothing else. The wire's`,` `,(0,m.jsx)(`code`,{children:`GET /avatars/{id}`}),` carries the diagnostics on top of those:`,` `,(0,m.jsx)(`code`,{children:`error`}),` (the reason a `,(0,m.jsx)(`code`,{children:`failed`}),` avatar failed) and`,` `,(0,m.jsx)(`code`,{children:`idleVideoStatus`}),` (how far the loop got). Read those with a plain authenticated fetch when you are showing a human what went wrong. And do not branch on `,(0,m.jsx)(`code`,{children:`sourceKind`}),` to tell a finished avatar from a fresh one: once the generated loop attaches it reads `,(0,m.jsx)(`code`,{children:`video`}),`, and `,(0,m.jsx)(`code`,{children:`sourceAssetId`}),` `,`names the loop now serving rather than the portrait you uploaded (that is`,` `,(0,m.jsx)(`code`,{children:`anchor.url`}),` on `,(0,m.jsx)(`code`,{children:`GET /avatars/{id}/clips`}),`). Gate on`,` `,(0,m.jsx)(`code`,{children:`status`}),` — or `,(0,m.jsx)(`code`,{children:`idleVideoStatus`}),` — never on`,` `,(0,m.jsx)(`code`,{children:`sourceKind == "image"`}),`.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`code`,{children:`motionPrompt`}),` optionally art-directs the generated loop ("soft cafe light, gentle idle energy") — set it as `,(0,m.jsx)(`strong`,{children:`Resting motion direction`}),` on the`,` `,(0,m.jsx)(`a`,{href:`/platform/avatars`,children:`Avatars page`}),` or on the wire's`,` `,(0,m.jsx)(`code`,{children:`POST /avatars`}),`; omit it for the house default. Once`,` `,(0,m.jsx)(`code`,{children:`ready`}),`, `,(0,m.jsx)(`code`,{children:`GET /avatars/{id}/clips`}),` lists the generated motion library — render calls inherit it automatically. The Avatars page's`,` `,(0,m.jsx)(`strong`,{children:`Clips`}),` action provides the same full-library declaration flow for generated prompt clips when the workspace rollout is enabled.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`code`,{children:`voice: { auto_description }`}),` lets the platform pick the best-matching Fish Audio voice from your description; pass`,` `,(0,m.jsx)(`code`,{children:`voice: { voice: { provider, voice_id } }`}),` to choose one explicitly — an explicit voice is nested under `,(0,m.jsx)(`code`,{children:`voice.voice`}),` (in Python,`,` `,(0,m.jsx)(`code`,{children:`"voice": {"voice": {"provider": "fish", "voice_id": "…"}}`}),`); a top-level `,(0,m.jsx)(`code`,{children:`voice: { provider, voice_id }`}),` is a `,(0,m.jsx)(`code`,{children:`422`}),`. Omit `,(0,m.jsx)(`code`,{children:`voice`}),` and one is cast the same way from the avatar's name and persona, so every avatar is created with a voice.`]}),(0,m.jsxs)(`p`,{children:[`You can also do all of this on the `,(0,m.jsx)(`a`,{href:`/platform/avatars`,children:`Avatars page`}),` with no code — see`,` `,(0,m.jsx)(`a`,{href:`/docs/video`,children:`Creating an avatar`}),`. Either way you end up with an`,` `,(0,m.jsx)(`code`,{children:`ava_…`}),` id, which is the only thing your client needs to know.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`Build from an image.`}),` One portrait in, every piece of video generated by the platform — that is the path these docs describe. An image source queues the motion generation above: the avatar sits in `,(0,m.jsx)(`code`,{children:`preprocessing`}),` until the idle loop and the clip library are rendered, and only then does it mint calls.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`There is no other lane.`}),` `,(0,m.jsx)(`code`,{children:`sourceKind: "video"`}
243),` — registering a looping clip you host — is closed to new callers and answers`,` `,(0,m.jsx)(`code`,{children:`422`}),`. (That is the field you send on create; a ready image-built avatar reads `,(0,m.jsx)(`code`,{children:`sourceKind: "video"`}),` too, because the loop the platform generated is now its source.) An avatar is built from one portrait and the platform renders the idle loop and every motion clip from it; that shared rest pose is what makes a state switch a splice instead of a jump, and a supplied video cannot honour it. Tenants already creating from video keep working, and their existing avatars are untouched — the door is shut for new integrations, not behind anyone.`]}),(0,m.jsxs)(`blockquote`,{children:[(0,m.jsxs)(`strong`,{children:[`Your editor may still suggest `,(0,m.jsx)(`code`,{children:`createAvatarFromVideo()`}),`.`]}),` `,`It is on its way out and its doc comment is older still — it warns that an image-sourced avatar publishes a black track, which stopped being true when image-only creation shipped. Use `,(0,m.jsx)(`code`,{children:`createAvatarFromImage()`}),`.`]}),(0,m.jsx)(`h2`,{children:`That is the whole surface`}),(0,m.jsxs)(`p`,{children:[`Two calls: `,(0,m.jsx)(`code`,{children:`createRealtimeAvatarRoute`}),` on the server,`,` `,(0,m.jsx)(`code`,{children:`AvatarCall`}),` on the client. You never touch the transport, the reconnect logic, or a wire format — and because you never touch them, we can change them underneath you without breaking your app.`]}),(0,m.jsx)(`p`,{children:`If you do want that control — your own media policy, your own transport handling — every lower-level primitive is still exported and still supported. Most teams never need to.`}),(0,m.jsx)(`h2`,{children:`What to read next`}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/sessions`,children:`Calls`}),` — the policy your server decides, the five states, and how to end a call without cutting her off mid-sentence.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/tool-calling`,children:`Tool calling`}),` — if your character needs to actually do things.`]})]})]})},y={slug:`authentication`,title:`Authentication`,description:`Get a key, keep it on your server, and decide who may start a call.`,group:`start`,order:2,updated:`2026-09-10`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(`h2`,{children:`Keys`}),(0,m.jsxs)(`p`,{children:[`Create a key in `,(0,m.jsx)(`a`,{href:`/platform/settings#api-keys`,children:`workspace Settings → API keys`}),` and save the full key securely on your server. The list keeps keys masked; the masked text cannot be used for API calls. Workspace owners and admins can use the `,(0,m.jsx)(`strong`,{children:`Copy`}),` action to copy newly created keys again while they are active and unexpired, including after leaving Settings and returning later.`]}),(0,m.jsx)(`p`,{children:`Legacy keys created before repeat copying was available were stored as hashes only and cannot be recovered. If you lose one, create a replacement and update your integration. Revoked or expired keys cannot be copied; replace them to resume API access.`}),(0,m.jsx)(`p`,{children:`Use the full key in a bearer header:`}),(0,m.jsx)(g,{lang:`text`,code:`Authorization: Bearer YOUR_API_KEY`}),(0,m.jsxs)(`p`,{children:[`New keys use one format: `,(0,m.jsx)(`code`,{children:`tic_<keyId>_<secret>`}),`. Existing `,(0,m.jsx)(`code`,{children:`tic_live_`}),` and `,(0,m.jsx)(`code`,{children:`tic_test_`}),` keys remain valid without any changes. Use names to distinguish integrations. Each key can carry its own spend limit, which is useful when handing a key to a subsystem you would rather cap. The cap is enforced against the wallet, so it does nothing on a workspace billed as`,` `,(0,m.jsx)(`strong`,{children:`unlimited`}),`: that account never consults a balance, and the per-key check sits behind the one it skips. The limit is still accepted and echoed back, so if you are on an unlimited plan, treat a per-key cap as a note to yourself rather than a control.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`There is no separate test or sandbox mode.`}),` All keys call real services and use the same balance in their workspace. The legacy prefixes do not change permissions or billing. To keep development usage separate, use a separate workspace, or a per-key`,` `,(0,m.jsx)(`code`,{children:`spendLimitCreditMicros`}),` low enough to fail closed.`]}),(0,m.jsx)(`h3`,{children:`Scopes`}),(0,m.jsxs)(`p`,{children:[`You pick these at creation. A key made in the dashboard starts with every scope enabled except `,(0,m.jsx)(`code`,{children:`*`}),`, so it works straight away — untick whatever this key has no business doing, particularly `,(0,m.jsx)(`code`,{children:`api_keys:write`}),`. Creating one over the API instead and omitting `,(0,m.jsx)(`code`,{children:`scopes`}),` gives you the narrower`,` `,(0,m.jsx)(`code`,{children:`realtime:write`}),`, `,(0,m.jsx)(`code`,{children:`avatars:read`}),`, `,(0,m.jsx)(`code`,{children:`credits:read`}),`.`]}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Scope`}),(0,m.jsx)(`th`,{children:`Grants`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`realtime:write`})}),(0,m.jsx)(`td`,{children:`Start and end calls`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`avatars:read`})}),(0,m.jsx)(`td`,{children:`List and fetch avatars and assets`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`avatars:write`})}),(0,m.jsx)(`td`,{children:`Create and update avatars, upload assets, sync clips`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`credits:read`})}),(0,m.jsx)(`td`,{children:`Read the balance`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`code`,{children:`usage:read`}),` · `,(0,m.jsx)(`code`,{children:`usage:write`})]}),(0,m.jsx)(`td`,{children:`Usage reporting`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`api_keys:write`})}),(0,m.jsx)(`td`,{children:`Mint further keys`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`*`})}),(0,m.jsx)(`td`,{children:`Everything — avoid outside trusted back-office jobs`})]})]})]}),(0,m.jsx)(`h2`,{children:`The one rule: the key stays on your server`}),(0,m.jsxs)(`p`,{children:[`A browser holding the key could start unlimited calls on your account, so it never gets one. Your client talks to your app;
243 your app talks to us. That is what the route adapters are — `,(0,m.jsx)(`code`,{children:`createRealtimeAvatarRoute`}),` and its siblings — mount one and `,(0,m.jsx)(`code`,{children:`authorize`}),` is your gate:`]}),(0,m.jsx)(h,{id:`auth-gate`,tabs:[{label:`Next.js`,code:`import { createRealtimeAvatarRoute } from "realtime-avatar/nextjs";
244
245export const { GET, POST } = createRealtimeAvatarRoute({
246  apiKey: process.env.REALTIME_AVATAR_API_KEY!,   // never NEXT_PUBLIC_ prefixed
247
248  authorize: async ({ request, operation }) => {
249    const user = await currentUser(request);
250    if (!user) return new Response("Unauthorized", { status: 401 });
251
252    // "connect" is the only operation that costs money to START.
253    if (operation === "connect" && !(await hasCredits(user))) {
254      return Response.json({ code: "insufficient_credits" }, { status: 402 });
255    }
256  },
257});`},{label:`TanStack Start`,code:`import { realtimeAvatarServerRoute } from "realtime-avatar/tanstack-start";
258
259const handlers = realtimeAvatarServerRoute({
260  apiKey: () => getEnv().REALTIME_AVATAR_API_KEY,  // a factory works on Workers
261
262  authorize: async ({ request, operation }) => {
263    const user = await requireUser(request);
264    if (!user) return new Response("Unauthorized", { status: 401 });
265    if (operation === "connect" && !(await hasCredits(user))) {
266      return Response.json({ code: "insufficient_credits" }, { status: 402 });
267    }
268  },
269});`},{label:`Python`,code:`# No handler to mount — your own endpoint is the gate.
270from fastapi import Depends, HTTPException
271from fastapi.responses import JSONResponse
272
273@app.post("/api/calls")
274def start_call(req: StartCallRequest, user=Depends(current_user)):
275    if not user:
276        raise HTTPException(401)
277    if not has_credits(user):                 # starting a call is the costly one
278        # HTTPException would nest this under "detail"; keep code top-level.
279        return JSONResponse({"code": "insufficient_credits"}, 402)
280    ...`}]}),(0,m.jsxs)(`p`,{children:[`Return a `,(0,m.jsx)(`code`,{children:`Response`}),` to refuse, or nothing to allow. There are four operations:`,` `,(0,m.jsx)(`code`,{children:`connect`}),`, `,(0,m.jsx)(`code`,{children:`end`}),`, `,(0,m.jsx)(`code`,{children:`avatars`}),`, and `,(0,m.jsx)(`code`,{children:`credits`}),`. Put your wallet check on `,(0,m.jsx)(`code`,{children:`connect`}),` — it is the only one that costs money to start — and leave the reads cheap. The route exposes no avatar mutation at all: creating or deleting a character is a server-side job for the`,` `,(0,m.jsx)(`code`,{children:`RealtimeAvatar`}),` class, never something a browser reaches through this handler — so there is nothing to forget to gate. Every operation still passes`,` `,(0,m.jsx)(`code`,{children:`authorize`}),`, so this callback is the gate, not a second line behind one.`]}),(0,m.jsxs)(`p`,{children:[`A key with a `,(0,m.jsx)(`code`,{children:`NEXT_PUBLIC_`}),` or `,(0,m.jsx)(`code`,{children:`VITE_`}),` prefix is inlined into the client bundle at build time. That is the one mistake that actually leaks a key, and the prefix is the only warning you get.`]}),(0,m.jsx)(`h2`,{children:`What the client is not allowed to decide`}),(0,m.jsxs)(`p`,{children:[`The persona, the memory, the voice, and the time limit are all yours. The handler strips them from whatever the browser sent and uses your `,(0,m.jsx)(`code`,{children:`session`}),` policy instead — and a field your policy does not set is `,(0,m.jsx)(`strong`,{children:`absent`}),` rather than inherited, so forgetting one fails closed.`]}),(0,m.jsxs)(`p`,{children:[`The one worth getting right on day one is `,(0,m.jsx)(`code`,{children:`maxSeconds`}),`: it is what stops a call your balance cannot cover. See `,(0,m.jsx)(`a`,{href:`/docs/sessions`,children:`Calls`}),` for the full policy.`]})]})},b=`https://github.com/theinfluencecompany/realtime-avatar-starter-vite`,x=`https://bolt.new/~/github.com/theinfluencecompany/realtime-avatar-starter-vite`,S=`https://replit.com/github.com/theinfluencecompany/realtime-avatar-starter-vite`,C=`Add a live AI avatar video call to this app with the Realtime Avatar SDK.
281
282Packages: realtime-avatar (pin exactly 0.26.0), livekit-client, @livekit/components-react, zod.
283
284Rules, do not break them:
2851. The API key is a server secret named REALTIME_AVATAR_API_KEY. Never put it in browser code and never in a VITE_ or NEXT_PUBLIC_ variable. Ask me for it as a secret.
2862. The browser never calls Realtime Avatar directly. It calls ONE server route of ours that holds the key. The SDK ships that route; it answers POST <base>/connect and POST <base>/end and relays the platform's answer unchanged. Do not wrap or reshape what it returns.
2873. The server decides the call: instructions, maxSeconds and which avatar ids are allowed. Ignore anything the browser sends for those.
2884. Use the public example avatar "seed-rin-ashfall" and allow only that id for now.
289Reference: https://realtimeavatar.ai/llms.txt and https://realtimeavatar.ai/docs/quickstart.md`,w=`      authorize: ({ operation }) =>
290        operation === "connect" || operation === "end" ? undefined : new Response("Not found", { status: 404 }),
291      session: ({ avatarId }) =>
292        avatarId === "seed-rin-ashfall"
293          ? { instructions: "You are Rin, a warm, curious guide. Short spoken sentences, one question at a time.", maxSeconds: 120 }
294          : new Response("Avatar not allowed", { status: 403 }),`,T=(e,t)=>`Client: a page with a "Call Rin" button. Clicking it mounts the call component below (${t}). When the call ends, show the button again.
295
296    import { AvatarCall, createProxyClient } from "realtime-avatar/react";
297
298${e}
299
300    <AvatarCall
301      client={client}
302      avatarId="seed-rin-ashfall"
303      poster="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/portrait.png"
304      idleVideoUrl="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/idle-10s.mp4"
305      style={{ width: "100%", maxWidth: 420, aspectRatio: "3 / 4" }}
306      onEnded={() => setInCall(false)}
307    >
308      {(call) => (
309        <div style={{ position: "absolute", bottom: 16, left: 0, right: 0, textAlign: "center" }}>
310          {call.status === "waiting" ? <p>In line: {call.queuePosition}</p> : null}
311          <button onClick={call.end}>End call</button>
312        </div>
313      )}
314    </AvatarCall>
315
316The microphone only works on https or http://localhost.`,E=`Server: a Supabase Edge Function named "realtime-avatar".
317
318supabase/functions/realtime-avatar/index.ts:
319
320    import { realtimeAvatarHono } from "npm:[email protected]/hono";
321
322    const cors = {
323      "Access-Control-Allow-Origin": "*",
324      "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
325      "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
326    };
327
328    const route = realtimeAvatarHono({
329      apiKey: () => Deno.env.get("REALTIME_AVATAR_API_KEY") ?? "",
330${w}
331    });
332
333    Deno.serve(async (req) => {
334      if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
335      const res = await route({ req: { raw: req } });
336      const headers = new Headers(res.headers);
337      for (const [k, v] of Object.entries(cors)) headers.set(k, v);
338      return new Response(res.body, { status: res.status, headers });
339    });
340
341In supabase/config.toml add:
342
343    [functions.realtime-avatar]
344    verify_jwt = false
345
346(The gateway's JWT check rejects the browser's CORS preflight; the function does its own checks.)
347Store REALTIME_AVATAR_API_KEY as an Edge Function secret.`,D=`    const base = import.meta.env.VITE_SUPABASE_URL + "/functions/v1/realtime-avatar";
348    const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY ?? import.meta.env.VITE_SUPABASE_ANON_KEY;
349    const client = createProxyClient({
350      proxyUrl: base,
351      // Supabase's gateway wants the project's public key on every request.
352      fetch: (input, init) => {
353        const headers = new Headers(init?.headers);
354        headers.set("apikey", key);
355        headers.set("authorization", "Bearer " + key);
356        return fetch(input, { ...init, headers });
357      },
358    });`,O=`${C}
359
360How to build the server route depends on this project's stack. Check which one it is.
361
362IF this project uses TanStack Start (new Lovable projects do):
363Server: create the file src/routes/api/realtime-avatar/$.ts
364
365    import { createFileRoute } from "@tanstack/react-router";
366    import { realtimeAvatarServerRoute } from "realtime-avatar/tanstack-start";
367
368    const handlers = realtimeAvatarServerRoute({
369      // A factory, read per request. Read the secret the way this project reads other server secrets.
370      apiKey: () => process.env.REALTIME_AVATAR_API_KEY ?? "",
371${w}
372    });
373
374    export const Route = createFileRoute("/api/realtime-avatar/$")({ server: { handlers } });
375
376The trailing $ in the filename matters: it is the splat that lets one file answer /connect and /end.
377
378${T(`    const client = createProxyClient({ proxyUrl: "/api/realtime-avatar" });`,`render it only in the browser, after mount, never during server rendering`)}
379
380OTHERWISE (a React + Vite project with Lovable Cloud):
381${E}
382
383${T(D,`it only runs in the browser`)}`,k=`${C}
384
385This project uses Supabase (connect it from Bolt's Supabase integration first).
386
387${E}
388
389${T(D,`it only runs in the browser`)}`,A=`${C}
390
391This is a Next.js App Router project.
392
393Server: create app/api/realtime-avatar/[...path]/route.ts
394
395    import { createRealtimeAvatarRoute } from "realtime-avatar/nextjs";
396
397    export const { GET, POST } = createRealtimeAvatarRoute({
398      apiKey: () => process.env.REALTIME_AVATAR_API_KEY ?? "",
399${w}
400    });
401
402The [...path] catch-all matters: one file answers /connect and /end.
403Read REALTIME_AVATAR_API_KEY from the environment (set in the Vars panel). Never NEXT_PUBLIC_.
404
405${T(`    const client = createProxyClient({ proxyUrl: "/api/realtime-avatar" });`,`put it in a "use client" component and load that with next/dynamic and ssr: false`)}`,j=`${C}
406
407Build it as a Vite + React + TypeScript app with a small Node server (Hono) on the same port.
408
409Server: mount the SDK's route on Hono:
410
411    import { Hono } from "hono";
412    import { realtimeAvatarHono } from "realtime-avatar/hono";
413
414    const app = new Hono();
415    app.all(
416      "/api/realtime-avatar/*",
417      realtimeAvatarHono({
418        apiKey: () => process.env.REALTIME_AVATAR_API_KEY ?? "",
419  ${w.replaceAll(`
420`,`
421  `)}
422      }),
423    );
424
425Serve the built page from the same server, listen on 0.0.0.0, and read REALTIME_AVATAR_API_KEY from Replit Secrets.
426
427${T(`    const client = createProxyClient({ proxyUrl: "/api/realtime-avatar" });`,`it only runs in the browser`)}`,M=`https://lovable.dev/#prompt=${encodeURIComponent(O)}`,N=[_,v,y,{slug:`ai-app-builders`,title:`Build with Lovable, Bolt, v0 and Replit`,navLabel:`Lovable, Bolt, v0, Replit`,description:`Add a live AI avatar call to an app built in Lovable, Bolt.new, v0 or Replit: a copy-paste prompt per builder, where the API key goes, and a starter repo that opens in one click.`,group:`start`,order:3,updated:`2026-10-05`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsxs)(`p`,{children:[`AI app builders write the code from a prompt, so the fastest path is a prompt that already knows the one rule they tend to get wrong: `,(0,m.jsx)(`strong`,{children:`the API key stays on a server`}),`. Every builder below can run server code (a Supabase Edge Function, a TanStack Start or Next.js route, or a Node server), and the SDK ships the route that goes there. The browser only renders the call.`]}),(0,m.jsxs)(`p`,{children:[`Each prompt uses the public example avatar `,(0,m.jsx)(`code`,{children:`seed-rin-ashfall`}),`, so the first call works on a fresh key before you have created a character. Create a key in`,` `,(0,m.jsx)(`a`,{href:`https://realtimeavatar.ai/platform/settings#api-keys`,children:`Settings, API keys`}),` `,`first. The free Sandbox plan includes 17 minutes of realtime, and the prompts cap every call at 120 seconds.`]}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Builder`}),(0,m.jsx)(`th`,{children:`Fastest start`}),(0,m.jsx)(`th`,{children:`Server code runs in`}),(0,m.jsx)(`th`,{children:`Where the key goes`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`Lovable`}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`a`,{href:`#lovable`,children:`The Lovable prompt below`})}),(0,m.jsx)(`td`,{children:`TanStack Start route (new projects) or Supabase Edge Function (Vite projects)`}),(0,m.jsx)(`td`,{children:`Secrets: the Add secret form, or More, Cloud, Secrets`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`Bolt.new`}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`a`,{href:x,children:`Open the starter in Bolt`})}),(0,m.jsx)(`td`,{children:`The starter's Node route, or a Supabase Edge Function`}),(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`code`,{children:`.env`}),` file, or Bolt Secrets`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`v0`}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`a`,{href:`#v0`,children:`The Next.js prompt below`})}),(0,m.jsx)(`td`,{children:`Next.js route handler`}),(0,m.jsx)(`td`,{children:`Vars panel (Vercel environment variables)`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`Replit`}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`a`,{href:S,children:`Import the starter to Replit`})}),(0,m.jsx)(`td`,{children:`The starter's Node server`}),(0,m.jsx)(`td`,{children:`Secrets tool`})]})]})]}),(0,m.jsx)(`h2`,{children:`The starter repo`}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`a`,{href:b,children:`realtime-avatar-starter-vite`}),` is the shape Lovable and Bolt generate: Vite, React, TypeScript and Tailwind, with one page that renders a live call through `,(0,m.jsx)(`code`,{children:`AvatarCall`}),`. It carries the server half twice, sharing one policy file, so it runs wherever you put it:`]}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`code`,{children:`supabase/functions/realtime-avatar/index.ts`}),`, a Supabase Edge Function, for Lovable Cloud and Bolt with Supabase.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`code`,{children:`server/app.ts`}),`, a Hono app that runs inside the Vite dev server (one process, one port) and behind `,(0,m.jsx)(`code`,{children:`npm start`}),` in production, for Bolt, Replit and any Node host.`]})]}),(0,m.jsxs)(`p`,{children:[`The page picks the route on its own: the Supabase function when`,` `,(0,m.jsx)(`code`,{children:`VITE_SUPABASE_URL`}),` is set, the same-origin `,(0,m.jsx)(`code`,{children:`/api/realtime-avatar`}),` `,`otherwise. It is MIT licensed; copy any part of it.`]}),(0,m.jsx)(`h2`,{children:`Lovable`}),(0,m.jsx)(`p`,{children:`Lovable builds from prompts and cannot import a GitHub repo, so start from the prompt. Projects created since May 2026 use TanStack Start, and older ones are React and Vite with a Supabase backend (Lovable Cloud); the prompt covers both and lets Lovable check which one it is in.`}),(0,m.jsxs)(`ol`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:M,children:`Open the prompt in Lovable`}),` (it fills the prompt box; you press send), or paste it into an existing project.`]}),(0,m.jsxs)(`li`,{children:[`When Lovable asks for `,(0,m.jsx)(`code`,{children:`REALTIME_AVATAR_API_KEY`}),`, paste your key into the secure secret field. To add it yourself: More, Cloud, Secrets, Add secret.`]}),(0,m.jsx)(`li`,{children:`Open the preview, press Call Rin, and allow the microphone.`})]}),(0,m.jsx)(h,{id:`builders-lovable`,tabs:[{label:`Lovable prompt`,lang:`text`,code:O}]}),(0,m.jsx)(`p`,{children:`Lovable does not redeploy an Edge Function from a commit synced from GitHub; ask it in chat to deploy the function after you change it.`}),(0,m.jsx)(`h2`,{children:`Bolt`}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`a`,{href:x,children:`Open the starter in Bolt`}),`. Bolt opens the repo in its in-browser environment, where `,(0,m.jsx)(`code`,{children:`npm run dev`}),` serves the page and the API route on one port.`]}),(0,m.jsxs)(`ol`,{children:[(0,m.jsxs)(`li`,{children:[`Create a file named `,(0,m.jsx)(`code`,{children:`.env`}),` with`,` `,(0,m.jsx)(`code`,{children:`REALTIME_AVATAR_API_KEY=your_key`}),` and restart the dev server.`]}),(0,m.jsx)(`li`,{children:`Press Call Rin in the preview and allow the microphone.`})]}),(0,m.jsx)(`p`,{children:`Bolt runs Node inside your browser tab, where an outbound request from that server can be blocked by the browser. If Call fails with a network error in the preview, connect Supabase from Bolt's integrations and use the Edge Function instead; the starter already contains it. In an existing Bolt project, paste this prompt:`}),(0,m.jsx)(h,{id:`builders-bolt`,tabs:[{label:`Bolt prompt`,lang:`text`,code:k}]}),(0,m.jsx)(`h2`,{children:`v0`}),(0,m.jsxs)(`p`,{children:[`v0 generates Next.js App Router apps, where the SDK's`,` `,(0,m.jsx)(`a`,{href:`/docs/nextjs`,children:`Next.js route`}),` is one file.`]}),(0,m.jsxs)(`ol`,{children:[(0,m.jsx)(`li`,{children:`Paste the prompt into a 
427new or existing v0 chat.`}),(0,m.jsxs)(`li`,{children:[`Add `,(0,m.jsx)(`code`,{children:`REALTIME_AVATAR_API_KEY`}),` in the Vars panel (or Project settings, Environment Variables). The preview reads the Development value.`]}),(0,m.jsx)(`li`,{children:`Press Call Rin in the preview and allow the microphone.`})]}),(0,m.jsx)(h,{id:`builders-v0`,tabs:[{label:`v0 prompt`,lang:`text`,code:A}]}),(0,m.jsx)(`h2`,{children:`Replit`}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`a`,{href:S,children:`Import the starter to Replit`}),`. Its `,(0,m.jsx)(`code`,{children:`.replit`}),` runs`,` `,(0,m.jsx)(`code`,{children:`npm run dev`}),` on one port and deploys with `,(0,m.jsx)(`code`,{children:`npm run build`}),` and`,` `,(0,m.jsx)(`code`,{children:`npm start`}),`.`]}),(0,m.jsxs)(`ol`,{children:[(0,m.jsxs)(`li`,{children:[`Open the Secrets tool and add `,(0,m.jsx)(`code`,{children:`REALTIME_AVATAR_API_KEY`}),`.`]}),(0,m.jsx)(`li`,{children:`Press Run, then Call Rin in the webview, and allow the microphone.`}),(0,m.jsx)(`li`,{children:`To publish, deploy it, and check the deployment has the same secret.`})]}),(0,m.jsx)(`p`,{children:`To have Replit Agent build it into an existing app instead, give it this prompt:`}),(0,m.jsx)(h,{id:`builders-replit`,tabs:[{label:`Replit Agent prompt`,lang:`text`,code:j}]}),(0,m.jsx)(`h2`,{children:`What every prompt makes the builder do`}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`Key on the server.`}),` A browser holding the key can start unlimited calls on your account. The route holds it; the page gets a session grant for one call.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`Grant relayed unchanged.`}),` The browser SDK validates the grant strictly, so a route that wraps it produces a call that never connects.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`Policy on the server.`}),` The route sets the instructions, the 120 second cap and the avatar allowlist; whatever the page sends for them is discarded.`]})]}),(0,m.jsx)(`h2`,{children:`Before you share the link`}),(0,m.jsxs)(`p`,{children:[`As generated, anyone who can open the app can start a call, and every call spends your balance. Before going public, ask the builder to require a signed-in user in the route's `,(0,m.jsx)(`code`,{children:`authorize`}),` hook (return a 401 when there is none), and set`,` `,(0,m.jsx)(`code`,{children:`maxSeconds`}),` to what you will pay for per call. See`,` `,(0,m.jsx)(`a`,{href:`/docs/authentication`,children:`Authentication`}),` for what `,(0,m.jsx)(`code`,{children:`authorize`}),` gates, and `,(0,m.jsx)(`a`,{href:`/pricing`,children:`pricing`}),` for what a minute costs.`]}),(0,m.jsx)(`h2`,{children:`Your own character`}),(0,m.jsxs)(`p`,{children:[`Create an avatar from one portrait on the`,` `,(0,m.jsx)(`a`,{href:`https://realtimeavatar.ai/platform/avatars`,children:`Avatars page`}),`, then ask the builder to replace `,(0,m.jsx)(`code`,{children:`seed-rin-ashfall`}),` with its `,(0,m.jsx)(`code`,{children:`ava_…`}),` id in both the allowlist and the page, and to drop or replace the `,(0,m.jsx)(`code`,{children:`poster`}),` and`,` `,(0,m.jsx)(`code`,{children:`idleVideoUrl`}),`, which belong to the example avatar.`]})]})},{slug:`nextjs`,title:`Build a realtime AI avatar in Next.js`,navLabel:`Next.js`,description:`Build a working Next.js avatar app with a server-side session proxy, React video UI, a complete local starter, and troubleshooting.`,group:`integrate`,order:0,updated:`2026-09-14`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(`p`,{children:`Your API key must never reach a browser, so the browser talks to your app and your app talks to us. The Next.js App Router adapter handles that boundary.`}),(0,m.jsx)(`h2`,{children:`Run the complete starter`}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`a`,{href:`/downloads/nextjs-avatar-starter.zip`,children:`Download the Next.js starter`}),` for a complete local app with a ready avatar, start and end controls, optional microphone, text messages, and downloadable startup observations. It pins Next.js 16.3.4, React 19.3.0 and `,(0,m.jsx)(`code`,{children:`realtime-avatar`}),` 0.16.0. Use Node.js 22 or newer.`]}),(0,m.jsx)(h,{id:`nextjs-starter`,tabs:[{label:`Terminal`,lang:`bash`,code:`curl -fLO https://realtimeavatar.ai/downloads/nextjs-avatar-starter.z
427ip
428unzip nextjs-avatar-starter.zip
429cd nextjs-avatar-starter
430npm ci
431cp .env.example .env.local
432# Set REALTIME_AVATAR_API_KEY in .env.local, then:
433npm run dev`}]}),(0,m.jsxs)(`p`,{children:[`Get a session-capable key from `,(0,m.jsx)(`a`,{href:`/platform/api-keys`,children:`API keys`}),`, then open `,(0,m.jsx)(`code`,{children:`http://127.0.0.1:3000`}),`. Click `,(0,m.jsx)(`strong`,{children:`Start call`}),`, wait for `,(0,m.jsx)(`code`,{children:`live`}),`, and send a message. The example uses `,(0,m.jsx)(`code`,{children:`seed-rin-ashfall`}),`, so no avatar creation is needed. Each call uses credits and is capped at 60 seconds. Loading the page alone does not start a call.`]}),(0,m.jsxs)(`p`,{children:[`The starter permits local development only. Before hosting it, replace its authorization callback with your own sign-in, balance and rate checks, and back `,(0,m.jsx)(`code`,{children:`ownsSession`}),` with session-to-user records. The SDK's default in-process ownership tracking fits a single local process; it does not span serverless instances. The snippets below show where your application policy belongs.`]}),(0,m.jsx)(`h2`,{children:`The server half`}),(0,m.jsxs)(`p`,{children:[`App Router, mounted at a catch-all so every operation reaches the handler. The`,` `,(0,m.jsx)(`code`,{children:`[...path]`}),` segment is not decoration — without it the route only ever matches the mount path itself and answers 404 for `,(0,m.jsx)(`code`,{children:`connect`}),`,`,` `,(0,m.jsx)(`code`,{children:`end`}),`, `,(0,m.jsx)(`code`,{children:`avatars`}),` and `,(0,m.jsx)(`code`,{children:`credits`}),` alike.`]}),(0,m.jsx)(h,{id:`nextjs-route`,tabs:[{label:`Next.js`,code:`// app/api/realtime-avatar/[...path]/route.ts
434import { createRealtimeAvatarRoute } from "realtime-avatar/nextjs";
435
436export const { GET, POST } = createRealtimeAvatarRoute({
437  apiKey: process.env.REALTIME_AVATAR_API_KEY!,
438
439  // Who may do this. Return a Response to refuse, or nothing to allow.
440  // "connect" is the only operation that costs money to start, so that is
441  // where the wallet check belongs; the reads stay cheap.
442  authorize: async ({ request, operation }) => {
443    const user = await currentUser(request);
444    if (!user) return new Response("Sign in", { status: 401 });
445    if (operation === "connect" && !user.credits) {
446      return Response.json({ code: "insufficient_credits" }, { status: 402 });
447    }
448  },
449
450  // What the character knows. Decided HERE — the client sends none of it.
451  session: async ({ avatarId }) => ({
452    instructions: promptFor(avatarId),
453  }),
454});`}]}),(0,m.jsxs)(`p`,{children:[`Name the variable `,(0,m.jsx)(`strong`,{children:`without`}),` `,(0,m.jsx)(`code`,{children:`NEXT_PUBLIC_`}),`. That prefix is what inlines a value into the client bundle, so a key wearing it ships to every visitor — the one mistake this whole split exists to prevent.`]}),(0,m.jsx)(`h2`,{children:`The client half`}),(0,m.jsxs)(`p`,{children:[`One component, pointed at the route you just mounted. It is the same component on every framework — see `,(0,m.jsx)(`a`,{href:`/docs/react`,children:`React`}),` for the hook underneath it and for controlling the call yourself.`]}),(0,m.jsx)(h,{id:`nextjs-client`,tabs:[{label:`Next.js`,code:`"use client";
455import { AvatarCall, createProxyClient } from "realtime-avatar/react";
456
457const client = createProxyClient({ proxyUrl: "/api/realtime-avatar" });
458
459export function Call({ avatarId }: { avatarId: string }) {
460  return <AvatarCall client={client} avatarId={avatarId} />;
461}`}]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`code`,{children:`"use client"`}),` is required: the call holds a WebRTC room and browser media, none of which exist in a server component.`]}),(0,m.jsx)(`h2`,{children:`Record a call on the server`}),(0,m.jsxs)(`p`,{children:[`Recording is a server policy. Add `,(0,m.jsx)(`code`,{children:`recording`}),` to the session returned by the proxy; the browser cannot enable or disable it. RTA runs LiveKit Egress and returns a typed artifact after the call finishes. Use `,(0,m.jsx)(`code`,{children:`audio_video`}),` when you need one timeline for transcript and lip-sync analysis.`]}),(0,m.jsx)(h,{id:`nextjs-recording`,tabs:[{label:`Next.js`,code:`// app/api/realtime-avatar/[...path]/route.ts
462export const { GET, POST } = createRealtimeAvatarRoute({
463  apiKey: process.env.REALTIME_AVATAR_API_KEY!,
464  session: async ({ avatarId }) => ({
465    instructions: promptFor(avatarId),
466    recording: "audio_video", // "off" | "audio" | "video" | "audio_video"
467  }),
468});
469
470// server-only follow-up, after the call:
471const artifact = await rta.getRecording(recordingId);
472if (artifact.status === "ready") {
473  const playback = await rta.getRecordingAccess(artifact.recordingId);
474  // Keep recordingId; playback.url expires and can be renewed.
475}`}]}),(0,m.jsxs)(`p`,{children:[`Recording reads require the `,(0,m.jsx)(`code`,{children:`recordings:read`}),` scope and stay tenant-scoped. Keep only `,(0,m.jsx)(`code`,{children:`recordingId`}),` in your database; never persist the temporary playback URL. See `,(0,m.jsx)(`a`,{href:`/docs/sessions`,children:`Calls`}),` for retention, access and transcript correlation details.`]}),(0,m.jsx)(`h2`,{children:`Troubleshooting the first call`}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Symptom`}),(0,m.jsx)(`th`,{children:`Check`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`404 on connect`}),(0,m.jsxs)(`td`,{children:[`Keep the catch-all `,(0,m.jsx)(`code`,{children:`[...path]`}),` directory and match the client's proxy URL.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`Missing key or 503 in the starter`}),(0,m.jsxs)(`td`,{children:[`Set the server-only variable in `,(0,m.jsx)(`code`,{children:`.env.local`}),` and restart the development server.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`401 or 403`}),(0,m.jsx)(`td`,{children:`Check API key scopes and your authorization callback. The starter refuses production calls until you implement application authorization.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`402`}),(0,m.jsx)(`td`,{children:`Check the workspace's available credits, entitlement and active reservations.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`Waiting in queue`}),(0,m.jsx)(`td`,{children:`Keep the queue state visible. Include queue time when measuring the user's startup experience.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`No microphone audio`}),(0,m.jsx)(`td`,{children:`Use localhost or HTTPS and grant permission. Try a text message to test the rest of the path.`})]})]})]}),(0,m.jsxs)(`p`,{children:[`The starter's JSON distinguishes SDK readiness from the first displayed remote video frame. Read the `,(0,m.jsx)(`a`,{href:`/blog/how-to-benchmark-realtime-avatar-startup-and-turn-latency`,children:`benchmark methodology`}),` before comparing those values with spoken-turn latency.`]}),(0,m.jsx)(`h2`,{children:`Next`}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/authentication`,children:`Authentication`}),` — keys, scopes, and what`,` `,(0,m.jsx)(`code`,{children:`authorize`}),` is really gating.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/sessions`,children:`Calls`}),` — everything the `,(0,m.jsx)(`code`,{children:`session`}),` policy can decide.`]})]})]})},{slug:`tanstack-start`,title:`TanStack Start`,navLabel:`TanStack Start`,description:`Mount the realtime avatar proxy in a TanStack Start app: a splat server route on the server, one component in the browser.`,group:`integrate`,order:1,updated:`2026-09-02`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(`p`,{children:`Your API key must never reach a browser, so the browser talks to your app and your app talks to us. In Start that is one server route.`}),(0,m.jsx)(`h2`,{children:`The server half`}),(0,m.jsxs)(`p`,{children:[`Mount at `,(0,m.jsx)(`code`,{children:`routes/api/realtime-avatar/$.ts`}),`. The trailing `,(0,m.jsx)(`code`,{children:`$`}),` is Start's splat segment and it is `,(0,m.jsx)(`strong`,{children:`load-bearing`}),`: without it the handler only ever sees the mount path itself and answers 404 for every operation — the same trap Next.js's `,(0,m.jsx)(`code`,{children:`[...path]`}),` exists to avoid.`]}),(0,m.jsx)(h,{id:`start-route`,tabs:[{label:`TanStack Start`,code:`// routes/api/realtime-avatar/$.ts
476import { createFileRoute } from "@tanstack/react-router";
477import { realtimeAvatarServerRoute } from "realtime-avatar/tanstack-start";
478
479export const Route = createFileRoute("/api/realtime-avatar/$")({
480  server: {
481    handlers: realtimeAvatarServerRoute({
482      apiKey: process.env.REALTIME_AVATAR_API_KEY!,
483
484      authorize: async ({ request, operation }) => {
485        const user = await currentUser(request);
486        if (!user) return new Response("Sign in", { status: 401 });
487        if (operation === "connect" && !user.credits) {
488          return Response.json({ code: "insufficient_credits" }, { status: 402 });
489        }
490      },
491
492      session: async ({ avatarId }) => ({
493        instructions: promptFor(avatarId),
494      }),
495    }),
496  },
497});`}]}),(0,m.jsxs)(`p`,{children:[`On Cloudflare Workers there is no `,(0,m.jsx)(`code`,{children:`process.env`}),`, so pass`,` `,(0,m.jsx)(`code`,{children:`apiKey`}),` as a `,(0,m.jsx)(`strong`,{children:`factory`}),` —`,` `,(0,m.jsx)(`code`,{children:`apiKey: () => getEnv().REALTIME_AVATAR_API_KEY`}),` — and it is read per request instead of at module scope.`]}),(0,m.jsx)(`h2`,{children:`The client half`}),(0,m.jsx)(h,{id:`start-client`,tabs:[{label:`TanStack Start`,code:`import { AvatarCall, createProxyClient } from "realtime-avatar/react";
498
499const client = createProxyClient({ proxyUrl: "/api/realtime-avatar" });
500
501export function Call({ avatarId }: { avatarId: string }) {
502  return <AvatarCall client={client} avatarId={avatarId} />;
503}`}]}),(0,m.jsx)(`h2`,{children:`Next`}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/authentication`,children:`Authentication`}),` — keys, scopes, and what`,` `,(0,m.jsx)(`code`,{children:`authorize`}),` is really gating.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/sessions`,children:`Calls`}),` — everything the `,(0,m.jsx)(`code`,{children:`session`}),` policy can decide.`]})]})]})},{slug:`express`,title:`Express`,navLabel:`Express`,description:`Mount the realtime avatar proxy as Express middleware — Express 4 or 5 — with the same authorize and session policy every adapter takes.`,group:`integrate`,order:2,updated:`2026-09-02`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(`p`,{children:`Your API key must never reach a browser, so the browser talks to your app and your app talks to us. In Express that is one middleware.`}),(0,m.jsx)(`h2`,{children:`The server half`}),(0,m.jsx)(`p`,{children:`Express 4 and 5. Mount it on a path prefix, with a JSON body parser in front — the adapter reads a parsed body rather than a stream.`}),(0,m.jsx)(h,{id:`express-route`,tabs:[{label:`Express`,code:`import express from "express";
504import { realtimeAvatarExpress } from "realtime-avatar/express";
505
506const app = express();
507
508app.use(
509  "/api/realtime-avatar",
510  express.json(),
511  realtimeAvatarExpress({
512    apiKey: process.env.REALTIME_AVATAR_API_KEY!,
513
514    authorize: async ({ request, operation }) => {
515      const user = await currentUser(request);
516      if (!user) return new Response("Sign in", { status: 401 });
517      if (operation === "connect" && !user.credits) {
518        return Response.json({ code: "insufficient_credits" }, { status: 402 });
519      }
520    },
521
522    session: async ({ avatarId }) => ({
523      instructions: promptFor(avatarId),
524    }),
525  }),
526);`}]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`code`,{children:`express.json()`}),` is not optional here. Express hands the adapter an already-parsed body, which it re-serializes to forward — fine for this route, whose payloads are small, but it is the reason the Fetch-based adapters (`,(0,m.jsx)(`a`,{href:`/docs/hono`,children:`Hono`}),`, `,(0,m.jsx)(`a`,{href:`/docs/nextjs`,children:`Next.js`}),`,`,` `,(0,m.jsx)(`a`,{href:`/docs/tanstack-start`,children:`TanStack Start`}),`) are the better path when you have the choice.`]}),(0,m.jsx)(`h2`,{children:`The client half`}),(0,m.jsx)(`p`,{children:`Express serves your API; the browser half is the same component as everywhere else, pointed at the prefix you mounted.`}),(0,m.jsx)(h,{id:`express-client`,tabs:[{label:`React`,code:`import { AvatarCall, createProxyClient } from "realtime-avatar/react";
527
528const client = createProxyClient({ proxyUrl: "/api/realtime-avatar" });
529
530<AvatarCall client={client} avatarId={avatarId} />`}]}),(0,m.jsxs)(`p`,{children:[`If your React app is served from a different origin than the Express API, give`,` `,(0,m.jsx)(`code`,{children:`proxyUrl`}),` the absolute URL and allow credentials in your CORS policy — the proxy is your endpoint, so its auth is your session, not ours.`]}),(0,m.jsx)(`h2`,{children:`Next`}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/authentication`,children:`Authentication`}),` — keys, scopes, and what`,` `,(0,m.jsx)(`code`,{children:`authorize`}),` is really gating.`]}
530),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/sessions`,children:`Calls`}),` — everything the `,(0,m.jsx)(`code`,{children:`session`}),` policy can decide.`]})]})]})},{slug:`hono`,title:`Hono, Workers, Bun and Deno`,navLabel:`Hono & Workers`,description:`Mount the realtime avatar proxy on any Fetch-handler runtime — Hono, Cloudflare Workers, Bun or Deno — including the apiKey factory Workers require.`,group:`integrate`,order:3,updated:`2026-09-02`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsxs)(`p`,{children:[`This adapter is for Hono and for anything else built on Fetch handlers — Cloudflare Workers, Bun, Deno. It takes a `,(0,m.jsx)(`code`,{children:`Request`}),` and returns a`,` `,(0,m.jsx)(`code`,{children:`Response`}),`, which is why one adapter covers all four.`]}),(0,m.jsx)(`h2`,{children:`The server half`}),(0,m.jsx)(`p`,{children:`Mount on a wildcard so every operation reaches the handler, not just the mount path.`}),(0,m.jsx)(h,{id:`hono-route`,tabs:[{label:`Hono`,code:`import { Hono } from "hono";
531import { realtimeAvatarHono } from "realtime-avatar/hono";
532
533const app = new Hono<{ Bindings: Env }>();
534
535app.all(
536  "/api/realtime-avatar/*",
537  realtimeAvatarHono({
538    // A FACTORY, not a value. On Workers there is no process.env, and a
539    // module-scope read runs before any binding exists.
540    apiKey: () => env.REALTIME_AVATAR_API_KEY,
541
542    authorize: async ({ request, operation }) => {
543      const user = await currentUser(request);
544      if (!user) return new Response("Sign in", { status: 401 });
545      if (operation === "connect" && !user.credits) {
546        return Response.json({ code: "insufficient_credits" }, { status: 402 });
547      }
548    },
549
550    session: async ({ avatarId }) => ({
551      instructions: promptFor(avatarId),
552    }),
553  }),
554);
555
556export default app;`}]}),(0,m.jsxs)(`p`,{children:[`On Node or Bun, where `,(0,m.jsx)(`code`,{children:`process.env`}),` exists, a plain string is fine:`,` `,(0,m.jsx)(`code`,{children:`apiKey: process.env.REALTIME_AVATAR_API_KEY!`}),`. The factory form is what makes the Workers case work, and it is harmless everywhere else — the value is simply read per request instead of once at module scope.`]}),(0,m.jsx)(`h2`,{children:`The client half`}),(0,m.jsx)(h,{id:`hono-client`,tabs:[{label:`React`,code:`import { AvatarCall, createProxyClient } from "realtime-avatar/react";
557
558const client = createProxyClient({ proxyUrl: "/api/realtime-avatar" });
559
560<AvatarCall client={client} avatarId={avatarId} />`}]}),(0,m.jsx)(`h2`,{children:`Next`}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/authentication`,children:`Authentication`}),` — keys, scopes, and what`,` `,(0,m.jsx)(`code`,{children:`authorize`}),` is really gating.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/sessions`,children:`Calls`}),` — everything the `,(0,m.jsx)(`code`,{children:`session`}),` policy can decide.`]})]})]})},{slug:`react`,title:`Realtime AI avatars with React and React Native`,navLabel:`React & Native`,description:`Embed live AI avatar calls in React or React Native with AvatarCall, useAvatarCall, connection states, and a server-side session proxy.`,group:`integrate`,order:4,updated:`2026-09-18`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(`p`,{children:`Every server adapter mounts the same proxy, so the client half is the same everywhere: one component pointed at your route.`}),(0,m.jsx)(`h2`,{children:`The component`}),(0,m.jsxs)(`p`,{children:[`For an existing browser React app, install the SDK and its browser media peers. The `,(0,m.jsx)(`a`,{href:`/docs/nextjs`,children:`downloadable Next.js starter`}),` already declares all three.`]}),(0,m.jsx)(h,{id:`react-install`,tabs:[{label:`Terminal`,lang:`bash`,code:`npm install --save-exact [email protected] [email protected] @livekit/[email protected] [email protected]`}]}),(0,m.jsx)(h,{id:`react-component`,tabs:[{label:`React`,code:`import { AvatarCall, createProxyClient } from "realtime-avatar/react";
561
562const client = createProxyClient({ proxyUrl: "/api/realtime-avatar" });
563
564<AvatarCall client={client} avatarId={avatarId} />
565
566// Audio only — no video track is requested, so nothing renders and
567// nothing decodes.
568<AvatarCall client={client} avatarId={avatarId} mode="voice" />`}]}),(0,m.jsx)(`h2`,{children:`Build product UI from call state`}),(0,m.jsx)(`p`,{children:`Use the SDK's call status, actions and end reasons for your interface. The standard call flow does not require Room, Participant, Track or WebRTC objects.`}),(0,m.jsx)(h,{id:`react-call-state`,tabs:[{label:`React`,code:`<AvatarCall
569  client={client}
570  avatarId={avatarId}
571  onStatusChange={(status) => console.log("Call status:", status)}
572  onEnded={({ reason }) => console.log("Call ended:", reason)}
573>
574  {(call) => <button onClick={call.end}>End call</button>}
575</AvatarCall>`}]}),(0,m.jsx)(`p`,{children:`Call state, transport quality and actual media presentation answer different questions. A connected room or an available track does not prove that a frame appeared on the user's screen. Keep detailed diagnostics out of the default product flow.`}),(0,m.jsxs)(`p`,{children:[`LiveKit owns transport recovery. RTA owns session preparation, capacity and fresh grants. Your product decides which notice or retry control to show. RTA's optional qual
575ity policy callback describes its policy result; it is distinct from the provider's native connection quality. See `,(0,m.jsx)(`a`,{href:`/docs/sessions`,children:`Calls`}),`for correlation and historical diagnostics.`]}),(0,m.jsx)(`h2`,{children:`Optional connection details`}),(0,m.jsxs)(`p`,{children:[`SDK `,(0,m.jsx)(`code`,{children:`0.16.0`}),` adds `,(0,m.jsx)(`code`,{children:`onConnectionDetailsChange`}),` when your product needs current connection facts. It receives a snapshot and subsequent changes;`,(0,m.jsx)(`code`,{children:`null`}),` clears a retired source. Keep its state scoped to one call. Omitting the callback adds no reporting listeners, timer or stats polling.`]}),(0,m.jsx)(h,{id:`react-connection-details`,tabs:[{label:`React`,code:`import { useState } from "react";
576import { AvatarCall, type AvatarCallProps, type AvatarConnectionDetails } from "realtime-avatar/react";
577
578function CallWithDetails(props: Pick<AvatarCallProps, "client" | "avatarId">) {
579  const [details, setDetails] = useState<AvatarConnectionDetails | null>(null);
580  return <>
581    <AvatarCall {...props} onConnectionDetailsChange={setDetails} />
582    {details && <output>Local connection: {details.localQuality}</output>}
583  </>;
584}`}]}),(0,m.jsxs)(`p`,{children:[`The snapshot contains `,(0,m.jsx)(`code`,{children:`connectionState`}),`, `,(0,m.jsx)(`code`,{children:`localQuality`}),`, and separate `,(0,m.jsx)(`code`,{children:`audio`}),`/`,(0,m.jsx)(`code`,{children:`video`}),` publisher quality and stream state. The publisher fields come from the selected tracks, which may have different publishers. An absent source is null; an existing source can report unknown quality. These facts do not establish rendered video or audible audio, and the SDK does not upload them. Your product decides which notices or user actions they warrant.`]}),(0,m.jsxs)(`p`,{children:[`Native and custom integrations pass this same callback to their existing`,` `,(0,m.jsx)(`code`,{children:`SessionLifecycleRoomBridge`}),`. Both client entries export the same`,` `,(0,m.jsx)(`code`,{children:`AvatarConnectionDetails`}),` type. There is no extra wrapper or separate native observer. Continue using call status and end reasons for the call lifecycle.`]}),(0,m.jsx)(`h2`,{children:`The hook underneath it`}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`code`,{children:`AvatarCall`}),` is a default surface over `,(0,m.jsx)(`code`,{children:`useAvatarCall`}),`. Reach for the hook when you want the call's state but your own layout — a custom control bar, your own video element, a call that lives inside something else.`]}),(0,m.jsx)(h,{id:`react-hook`,tabs:[{label:`React`,code:`import { createProxyClient, useAvatarCall } from "realtime-avatar/react";
585
586const client = createProxyClient({ proxyUrl: "/api/realtime-avatar" });
587
588function MyCall({ avatarId }: { avatarId: string }) {
589  const { call, view } = useAvatarCall({ client, avatarId });
590  return <>
591    {view}
592    <p role="status">{call.status}</p>
593    <button onClick={() => call.end()}>End call</button>
594  </>;
595}`}]}),(0,m.jsxs)(`p`,{children:[`Mounting the call component starts connection. Mount it only after the user clicks a start button, and unmount it after `,(0,m.jsx)(`code`,{children:`onEnded`}),`. Keep connection, queue, recovery and end states visible. The `,(0,m.jsx)(`a`,{href:`/docs/nextjs`,children:`complete Next.js starter`}),` demonstrates that lifecycle with text messages and an optional microphone.`]}),(0,m.jsx)(`h2`,{children:`Let the character see your camera`}),(0,m.jsxs)(`p`,{children:[`Your server must authorize `,(0,m.jsx)(`code`,{children:`camera: true`}),` when starting the call. This grants permission; capture stays off until the user chooses to share. Mount one `,(0,m.jsx)(`code`,{children:`useAvatarCamera`}),` control inside the existing room, with`,(0,m.jsx)(`code`,{children:`allowed`}),` from the grant and `,(0,m.jsx)(`code`,{children:`active`}),` from your live-call and foreground state. Do not also drive the room's `,(0,m.jsx)(`code`,{children:`video`}),` prop.`]}),(0,m.jsx)(h,{id:`react-camera`,tabs:[{label:`React`,code:`import { useAvatarCamera } from "realtime-avatar/react";
596
597function CameraControl({ allowed, active }: { allowed: boolean; active: boolean }) {
598  const camera = useAvatarCamera({ allowed, active });
599  return <>
600    <p>Share camera images with AI to let the character see what you show.</p>
601    <button style={{ minHeight: 44 }} disabled={!camera.available}
602      aria-pressed={camera.enabled} onClick={() => void camera.toggle()}>
603      {camera.pending ? "Cancel camera request" : camera.enabled ? "Stop sharing camera" : "Share camera"}
604    </button>
605    <p role="status">{camera.error ? "Camera unavailable. Check camera permissions and try again." : ""}</p>
606  </>;
607}`}]}),(0,m.jsxs)(`p`,{children:[`Repeated on/off toggles keep the same session and microphone. Calling`,(0,m.jsx)(`code`,{children:`setEnabled(false)`}),` also cancels a pending permission request in the SDK; any track returned later is stopped without publishing. Stop sharing on`,(0,m.jsx)(`code`,{children:`visibilitychange`}),` when hidden and on `,(0,m.jsx)(`code`,{children:`pagehide`}),`. Returning to the page must not automatically enable the camera. For a local preview, pass the returned `,(0,m.jsx)(`code`,{children:`participant`}),` and`,(0,m.jsx)(`code`,{children:`publication`}),` to LiveKit's `,(0,m.jsx)(`code`,{children:`VideoTrack`}),`, with camera source and muted playback.`]}),(0,m.jsx)(`p`,{children:`The default capture is front-facing, 640×360 at 5 frames per second. Visual understanding samples at most once every two seconds using Gemini 2.5 Flash-Lite; this is occasional image understanding, not continuous motion recognition. Camera images are excluded from server call recordings, although spoken descriptions can remain in recorded audio or conversation history.`}),(0,m.jsx)(`h2`,{children:`React Native`}),(0,m.jsx)(`p`,{children:`A separate entry point, because the media stack is not the browser's: it wraps LiveKit's React Native room and needs an audio session the OS has granted.`}),(0,m.jsx)(h,{id:`react-native`,tabs:[{label:`React Native`,code:`import {
608  RealtimeAvatarLiveKitRoom,
609  AvatarVideoSurface,
610  registerGlobals,
611} from "realtime-avatar/react-native";
612
613registerGlobals();`}]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`code`,{children:`registerGlobals`}),` from the same entry point must run once at app start — it installs the WebRTC globals React Native does not ship.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`code`,{children:`RealtimeAvatarLiveKitRoom`}),` owns the OS audio session by default. Only call `,(0,m.jsx)(`code`,{children:`useRealtimeAvatarAudioSession(connected)`}),` yourself when the room has`,(0,m.jsx)(`code`,{children:`manageAudioSession={false}`}),`; keep one owner. Import `,(0,m.jsx)(`code`,{children:`useAvatarCamera`}),` from `,(0,m.jsx)(`code`,{children:`realtime-avatar/react-native`}),`for the same camera API. Use a native Pressable and Text for the control and LiveKit's n
613ative VideoTrack for the preview.`]}),(0,m.jsx)(h,{id:`native-camera`,tabs:[{label:`React Native`,code:`import { useEffect, useState } from "react";
614import { AppState, Pressable, Text, View } from "react-native";
615import { useAvatarCamera } from "realtime-avatar/react-native";
616
617function NativeCameraControl({ allowed, active }: { allowed: boolean; active: boolean }) {
618  const [foreground, setForeground] = useState(AppState.currentState === "active");
619  const camera = useAvatarCamera({ allowed, active: active && foreground });
620  const { setEnabled } = camera;
621  useEffect(() => {
622    const listener = AppState.addEventListener("change", (state) => {
623      if (state === "active") setForeground(true);
624      if (state === "background") {
625        setForeground(false);
626        void setEnabled(false);
627      }
628    });
629    return () => listener.remove();
630  }, [setEnabled]);
631  return <View>
632    <Text>Share camera images with AI to let the character see what you show.</Text>
633    <Pressable accessibilityRole="button" disabled={!camera.available}
634      style={{ minHeight: 44, justifyContent: "center" }} onPress={() => void camera.toggle()}>
635      <Text>{camera.pending ? "Cancel camera request" : camera.enabled ? "Stop sharing camera" : "Share camera"}</Text>
636    </Pressable>
637    <Text>{camera.error ? "Camera unavailable. Check camera permissions." : ""}</Text>
638  </View>;
639}`}]}),(0,m.jsxs)(`p`,{children:[`Treat iOS's transient `,(0,m.jsx)(`code`,{children:`inactive`}),` during a permission prompt differently from `,(0,m.jsx)(`code`,{children:`background`}),`. Stop capture in the background and require a new user action to share again. Pass false for `,(0,m.jsx)(`code`,{children:`active`}),`when the call ends or is held.`]}),(0,m.jsx)(`p`,{children:`Camera sharing reuses the existing LiveKit React Native and WebRTC native modules; it does not require expo-camera. The native binary must already contain those modules and camera permission declarations (iOS camera usage description and Android CAMERA permission). Otherwise ship a new native build. A JavaScript-only update may use OTA only when its runtime fingerprint matches the installed build and store review/release policy permits it. Update your privacy disclosure for camera images sent to an AI provider; an OTA cannot add native permissions. SDK 0.20.0 also requires a compatible Zod 4 peer, which is JavaScript-only.`}),(0,m.jsx)(`h2`,{children:`Next`}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[`Mount the server half: `,(0,m.jsx)(`a`,{href:`/docs/nextjs`,children:`Next.js`}),`,`,` `,(0,m.jsx)(`a`,{href:`/docs/tanstack-start`,children:`TanStack Start`}),`,`,` `,(0,m.jsx)(`a`,{href:`/docs/express`,children:`Express`}),`, or`,` `,(0,m.jsx)(`a`,{href:`/docs/hono`,children:`Hono and Workers`}),`.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`a`,{href:`/docs/sessions`,children:`Calls`}),` — what the server decides on every connect.`]})]})]})},{slug:`mcp`,title:`Build avatar apps with the Realtime Avatar MCP server`,navLabel:`MCP`,description:`Connect a coding agent to Realtime Avatar: configure the MCP server, inspect available avatars, and build a working app with the TypeScript SDK.`,group:`integrate`,order:5,updated:`2026-09-10`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`An agent cannot guess an avatar id.`}),` That is the whole reason this exists. Hand a coding agent the docs and it will write plausible code against a character that does not exist, a plan you are not on, and a balance it cannot see — and you will find out at the first 404. The MCP server closes that gap by letting the agent read your actual account before it writes a line.`]}),(0,m.jsx)(`h2`,{children:`Install`}),(0,m.jsxs)(`p`,{children:[`For a concrete first task, ask your coding agent to inspect available avatars and credits, then adapt the `,(0,m.jsx)(`a`,{href:`/downloads/nextjs-avatar-starter.zip`,children:`Next.js starter`}),` using a returned avatar ID. Keep the key on the server and start a paid call only after a user action. The `,(0,m.jsx)(`a`,{href:`/docs/nextjs`,children:`integration guide`}),` covers the complete browser-to-server path.`]}
639),(0,m.jsxs)(`p`,{children:[`Published on npm as `,(0,m.jsx)(`code`,{children:`realtime-avatar-mcp`}),`. There is nothing to build and nothing to host: it runs over stdio, straight from `,(0,m.jsx)(`code`,{children:`npx`}),`. Add this to your agent's MCP config — `,(0,m.jsx)(`code`,{children:`.mcp.json`}),` for Claude Code,`,` `,(0,m.jsx)(`code`,{children:`.cursor/mcp.json`}),` for Cursor, `,(0,m.jsx)(`code`,{children:`claude_desktop_config.json`}),` for Claude Desktop.`]}),(0,m.jsx)(h,{id:`mcp-config`,tabs:[{label:`Read-only (default)`,lang:`json`,code:`{
640  "mcpServers": {
641    "realtime-avatar": {
642      "command": "npx",
643      "args": ["-y", "realtime-avatar-mcp"],
644      "env": { "REALTIME_AVATAR_API_KEY": "YOUR_API_KEY" }
645    }
646  }
647}`},{label:`With writes`,lang:`json`,code:`{
648  "mcpServers": {
649    "realtime-avatar": {
650      "command": "npx",
651      "args": ["-y", "realtime-avatar-mcp"],
652      "env": {
653        "REALTIME_AVATAR_API_KEY": "YOUR_API_KEY",
654        "REALTIME_AVATAR_ALLOW_WRITES": "1"
655      }
656    }
657  }
658}`}]}),(0,m.jsxs)(`p`,{children:[`Create a key in `,(0,m.jsx)(`a`,{href:`/platform/settings#api-keys`,children:`workspace Settings → API keys`}),` and replace `,(0,m.jsx)(`code`,{children:`YOUR_API_KEY`}),` with the full key. The list stays masked; workspace owners and admins can use `,(0,m.jsx)(`strong`,{children:`Copy`}),` to copy newly created keys again while they are active and unexpired. Lost legacy keys stored as hashes only need a replacement, and revoked or expired keys cannot be copied. See `,(0,m.jsx)(`a`,{href:`/docs/authentication`,children:`Authentication`}),` for details. Creating a key is free; calls use workspace credits. There is no separate free sandbox mode.`]}),(0,m.jsx)(`h2`,{children:`What the agent gets`}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`Read tools are on by default. Write tools are not.`}),` An agent that can read your account cannot cost you anything; an agent that can start a call and create a character can, so that half is behind`,` `,(0,m.jsx)(`code`,{children:`REALTIME_AVATAR_ALLOW_WRITES=1`}),` and you opt in deliberately.`]}),(0,m.jsx)(`h3`,{children:`Always available`}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`code`,{children:`list_avatars`}),` — every avatar on the account, with the id you pass to a session mint. This is the one an agent should call before writing any code.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`code`,{children:`get_avatar`}),` — full detail for one avatar, including its clip set and voice.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`code`,{children:`credit_balance`}),` — the balance, and how much is reserved by calls in flight.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`code`,{children:`list_sessions`}),` — the itemised bill: when each session ran, how long it was billable for, and what it cost.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`code`,{children:`list_clips`}),` — the declared clip library with each clip's render status.`]})]}),(0,m.jsxs)(`h3`,{children:[`Behind `,(0,m.jsx)(`code`,{children:`REALTIME_AVATAR_ALLOW_WRITES=1`})]}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`code`,{children:`create_avatar_from_image`}),` and `,(0,m.jsx)(`code`,{children:`create_avatar_from_video`}),` — a character from one photo. Video-source creation is restricted; a tool being listed does not grant access to that lane.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`code`,{children:`upload_asset`}),` and `,(0,m.jsx)(`code`,{children:`create_remote_asset`}),` — get the source material in.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`code`,{children:`start_call`}),` — mint a live session and return the grant.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`code`,{children:`set_loop`}),` and `,(0,m.jsx)(`code`,{children:`set_clip_library`}),` — redirect the resting loop and re-declare the clip set.`]})]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`Creating a character spends credits and takes minutes.`}),` Both create tools are asynchronous: the avatar comes back `,(0,m.jsx)(`code`,{children:`preprocessing`}),` and is not callable until it reaches `,(0,m.jsx)(`code`,{children:`ready`}),`. An agent that mints a session against a preprocessing avatar gets a `,(0,m.jsx)(`code`,{children:`409`}),` with`,` `,(0,m.jsx)(`code`,{children:`code: "avatar_not_ready"`}),` — poll `,(0,m.jsx)(`code`,{children:`get_avatar`}),` instead of retrying the call.`]}),(0,m.jsx)(`h2`,{children:`What it is not`}),(0,m.jsxs)(`p`,{children:[`The MCP server is a `,(0,m.jsx)(`em`,{children:`build-time`}),` tool. It is how your agent learns the account while it writes your integration; it is not how your application talks to the platform at runtime. Your app should keep minting sessions from your own server, because that endpoint is where you decide who may call and what the character knows — see`,` `,(0,m.jsx)(`a`,{href:`/docs/authentication`,children:`Authentication`}),`. Never commit any API key in an agent config, regardless of its prefix.`]}),(0,m.jsx)(`h2`,{children:`Try it in one line`}),(0,m.jsx)(`p`,{children:`With the server configured, this prompt is enough to get a working page, because the agent can now look up a real avatar instead of inventing one:`}),(0,m.jsx)(g,{lang:`text`,code:`Use the realtime-avatar MCP server. List my avatars, pick the first ready one,
659then build a one-button page that opens a live video call with her: she speaks
660first, two-way voice, her portrait while she connects, and a hang-up button.
661Mint the session from a server route, never from the browser.
662Follow https://realtimeavatar.ai/llms.txt for the SDK shapes.`})]})},{slug:`sessions`,title:`Calls`,description:`What your server decides, what the client reports, and how to end a call without cutting the character off mid-sentence.`,group:`build`,order:1,updated:`2026-09-24`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(`p`,{children:`A call is one live conversation. Your server decides who may start one and what the character knows; the client renders it and reports five states. Those are the only two surfaces you touch.`}),(0,m.jsx)(`h2`,{children:`What your server decides`}),(0,m.jsxs)(`p`,{children:[`The `,(0,m.jsx)(`code`,{children:`session`}),` policy on your connect endpoint. Every field here is authoritative — whatever the browser sent for these is discarded, and a field you do not set is absent rather than 
662inherited from the caller. The Field column is the SDK name; the Wire column is the key a hand-rolled request sends — snake_case and strict, see Calling the API directly below.`]}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Field`}),(0,m.jsx)(`th`,{children:`Wire`}),(0,m.jsx)(`th`,{children:`What it does`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`instructions`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`instructions`})}),(0,m.jsx)(`td`,{children:`Her behavior contract — who she is and how she speaks. Up to 16,000 characters. The highest-leverage field on the whole surface.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`context`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`initial_context`})}),(0,m.jsxs)(`td`,{children:[`Up to 32 prior messages, replayed as memory. This is what makes a call continue a story instead of starting cold. On the wire it is `,(0,m.jsx)(`code`,{children:`initial_context`}),`, and each entry is `,(0,m.jsx)(`code`,{children:`{ role: "system" | "user" | "assistant", content }`}),` with content 1–8000 characters — the SDK types this for you, raw HTTP does not.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`maxSeconds`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`max_session_seconds`})}),(0,m.jsx)(`td`,{children:`Hard stop, up to 1800. Enforced on our side, so it holds even if your client stops reporting. Compute it from the balance you just admitted.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`listen`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`stt_mode: "server" | "off"`})}),(0,m.jsxs)(`td`,{children:[`Speech recognition. Default true — she hears the user the whole call. Set`,` `,(0,m.jsx)(`code`,{children:`false`}),` only if you drive every turn yourself — see`,` `,(0,m.jsx)(`a`,{href:`/docs/tool-calling`,children:`Tool calling`}),`.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`voice`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`voice`})}),(0,m.jsx)(`td`,{children:`Override her stored voice for this call. Must be a Fish voice; see below.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`video`})}),(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`code`,{children:`render_backend: "generative"`}),`, or omit it`]}),(0,m.jsxs)(`td`,{children:[`How she is rendered: omit it for the generated loop + state map, or`,` `,(0,m.jsx)(`code`,{children:`{ mode: "generative" }`}),` to synthesize the video live — see `,(0,m.jsx)(`a`,{href:`/docs/video`,children:`Creating an avatar`}),`.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`camera`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`camera`})}),(0,m.jsxs)(`td`,{children:[`Optional boolean. Your server authorizes camera sharing with true; capture remains off until the user enables it. Works in voice and avatar calls. Use `,(0,m.jsx)(`code`,{children:`useAvatarCamera`}),` to toggle within the same session without interrupting the microphone. See `,(0,m.jsx)(`a`,{href:`/docs/react`,children:`React and Native`}),`. User camera pixels are excluded from server video recordings.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`clientTools`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`capabilities: ["client_tools"]`})}),(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`code`,{children:`true`}),` opts the call into the client tool plane — functions declared in your page that she can call mid-conversation. See`,` `,(0,m.jsx)(`a`,{href:`/docs/tool-calling`,children:`Tool calling`}),`.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`transcript`})}),(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`code`,{children:`transcript_webhook: { url, secret }`}),` — https URL, secret 16–200 chars`]}),(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`code`,{children:`{ url, secret }`}),` — get the two-sided transcript back, signed, after the call ends. On the wire it is `,(0,m.jsx)(`code`,{children:`transcript_webhook`}),`, and `,(0,m.jsx)(`code`,{children:`secret`}),` is 16–200 characters — the SDK types this for you, raw HTTP does not. See `,(0,m.jsx)(`a`,{href:`/docs/tool-calling`,children:`Tool calling`}),`.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`metadata`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`client_metadata`})}),(0,m.jsx)(`td`,{children:`Up to 16 string pairs, echoed verbatim on that transcript so you can attribute it without a lookup.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`recording`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`recording`})}),(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`code`,{children:`off`}),` (default), `,(0,m.jsx)(`code`,{children:`audio`}),`, `,(0,m.jsx)(`code`,{children:`video`}),`, or `,(0,m.jsx)(`code`,{children:`audio_video`}),`. Your server decides whether to record after obtaining consent. Audio includes published user and avatar audio.`]})]})]})]}),(0,m.jsx)(`p`,{children:`The complete session creation request must fit within 64 KiB of JSON. This byte budget includes instructions, context, and all other fields; non-ASCII text can use multiple bytes per character.`}),(0,m.jsxs)(`p`,{children:[`There is no standalone `,(0,m.jsx)(`code`,{children:`voice_style`}),` field. Put persona, wording, and reply style in `,(0,m.jsx)(`code`,{children:`instructions`}),`. Sessions speak with Fish voices only. A`,` `,(0,m.jsx)(`code`,{children:`voice`}),` with any other provider (Cartesia, Breeze Blue) is not an error: the call speaks with the character's own Fish voice, or with a Fish house voice matched to the character, and keeps that override's `,(0,m.jsx)(`code`,{children:`language`}),` and`,` `,(0,m.jsx)(`code`,{children:`speed`}),`. A Fish `,(0,m.jsx)(`code`,{children:`voice`}),` takes `,(0,m.jsx)(`code`,{children:`speed`}),`,`,` `,(0,m.jsx)(`code`,{children:`emotion`}),`, and `,(0,m.jsx)(`code`,{children:`language`}),`. The platform carries the session instructions and voice configuration separately: the model writes the reply under the session instructions, then TTS renders that text under the voice configuration.`]}),(0,m.jsx)(`h2`,{children:`Two modes`}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Mode`}),(0,m.jsx)(`th`,{children:`What the user gets`}),(0,m.jsx)(`th`,{children:`Use it for`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`code`,{children:`avatar`}),` (default)`]}),(0,m.jsx)(`td`,{children:`She is on screen — audio and video`}),(0,m.jsx)(`td`,{children:`The full call`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`voice`})}),(0,m.jsx)(`td`,{children:`Audio only`}),(0,m.jsx)(`td`,{children:`A cheaper on-ramp, or a fallback when bandwidth will not carry video`})]})]})]}),(0,m.jsxs)(`p`,{children:[`Same character, same policy — only `,(0,m.jsx)(`code`,{children:`mode`}),` changes. A voice call draws on separate capacity, so it never competes with a video call.`]}),(0,m.jsx)(`h2`,{children:`Recordings`}),(0,m.jsxs)(`p`,{children:[`Your backend can request a recording on `,(0,m.jsx)(`code`,{children:`POST /realtime/livekit/session`}),`. Recording defaults to `,(0,m.jsx)(`code`,{children:`off`}),`, and browser requests cannot override your server policy. Use `,(0,m.jsx)(`code`,{children:`audio`}),` for a mixed recording of published user and avatar audio, `,(0,m.jsx)(`code`,{children:`video`}),` for silent video, or `,(0,m.jsx)(`code`,{children:`audio_video`}),` `,`for both in one MP4.`]}),(0,m.jsx)(g,{lang:`python`,code:`# Use your authenticated server-side HTTP client; see the API reference.
663response = client.post("/realtime/livekit/session", json={
664    "avatar_id": avatar_id,
665    "recording": "audio_video",
666})
667response.raise_for_status()
668grant = response.json()
669recording_id = grant["recording"]["recordingId"]
670
671# Later, after the call has ended:
672recording = client.get(f"/recordings/{recording_id}").json()
673if recording["status"] == "ready":
674    access = client.get(f"/recordings/{recording_id}/access").json()
675    # Return access["url"] only to someone allowed to view this call.`}),(0,m.jsxs)(`p`,{children:[`The recording ID stays the same through `,(0,m.jsx)(`code`,{children:`pending`}),`, `,(0,m.jsx)(`code`,{children:`recording`}),`,`,` `,(0,m.jsx)(`code`,{children:`processing`}),` and `,(0,m.jsx)(`code`,{children:`ready`}),`. A failure is `,(0,m.jsx)(`code`,{children:`failed`}),`; retention expiry is `,(0,m.jsx)(`code`,{children:`expired`}),`. Ending the call starts finalization, so the file may become ready afterward. Recording reads require a server API key with`,` `,(0,m.jsx)(`code`,{children:`recordings:read`}),` and are limited to your workspace. The`,` `,(0,m.jsx)(`a`,{href:`/recording.contract.ts`,children:`canonical recording schema`}),` defines the artifact and playback-access response.`]}),(0,m.jsxs)(`p`,{children:[`RTA stores the media privately for 30 days. Keep `,(0,m.jsx)(`code`,{children:`recordingId`}),` in your database. Playback URLs last at most one hour and support seeking; request a new URL when one expires. A URL's `,(0,m.jsx)(`code`,{children:`expiresAt`}),` is separate from the recording's `,(0,m.jsx)(`code`,{children:`retainedUntil`}),`. Renewing playback access does not extend retention.`]}),(0,m.jsxs)(`p`,{children:[`Recording captures authorized tracks already published in the call. It does not open a camera, request screen capture, or grant additional publishing rights. Transcript delivery remains a separate option; use `,(0,m.jsx)(`code`,{children:`sessionId`}),` and timestamps to join transcript turns to the recording.`]}),(0,m.jsxs)(`p`,{children:[`If recording is unavailable, the mint returns `,(0,m.jsx)(`code`,{children:`recording_unsupported`}),` `,`before starting the call. A capture startup failure returns `,(0,m.jsx)(`code`,{children:`recording_failed`}),`. Raw API callers must let RTA create a fresh room for each recorded session; reusing `,(0,m.jsx)(`code`,{children:`room_name`}),` or setting `,(0,m.jsx)(`code`,{children:`create_room: false`}),` is refused. Endpoint details are in the `,(0,m.jsx)(`a`,{href:`/docs/api-reference`,children:`API reference`}),`.`]}),(0,m.jsx)(`h2`,{children:`Connection history`}),(0,m.jsxs)(`p`,{children:[`Enable `,(0,m.jsx)(`code`,{children:`connectionHistory: true`}),` in your server session policy when you want the SDK to retain connection changes with the call. On the wire this is`,(0,m.jsx)(`code`,{children:`connection_history: true`}),`. Collection defaults to off and requires an SDK release with connection-history support. Your application keeps using the existing`,(0,m.jsx)(`code`,{children:`onConnectionDetailsChange`}),` callback for live behavior; it does not write an uploader.`]}),(0,m.jsxs)(`p`,{children:[`The SDK uses existing LiveKit connection events and batches their changes to RTA with a short-lived capability that can only write to that session. It adds no receiver polling. Read the history through `,(0,m.jsx)(`code`,{children:`GET /sessions/{sessionId}/connection-history`}),`using a server key with `,(0,m.jsx)(`code`,{children:`usage:read`}),`. RTA retains at most 240 received changes for 30 days. Missing data remains unknown. Client timestamps are approximate; connection quality is not proof of successful device playback or an exact recording timeline.`]}),(0,m.jsx)(`h2`,{children:`What the client reports`}),(0,m.jsx)(`p`,{children:`Five states. Write copy for the ones you care about; the SDK ships none.`}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:(0,m.jsx)(`code`,{children:`status`})}),(0,m.jsx)(`th`,{children:`Meaning`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`connecting`})}),(0,m.jsx)(`td`,{children:`Getting her ready`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`waiting`})}),(0,m.jsxs)(`td`,{children:[`Every slot is busy and you are holding a place in line —`,` `,(0,m.jsx)(`code`,{children:`call.queuePosition`}),` has the number. `,(0,m.jsx)(`strong`,{children:`Not an error`}),`; the SDK retries for you. Render the position, not a failure.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`live`})}),(0,m.jsx)(`td`,{children:`She is there`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`recovering`})}),(0,m.jsx)(`td`,{children:`A blip; reconnecting automatically with backoff`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`ended`})}),(0,m.jsxs)(`td`,{children:[`Over — `,(0,m.jsx)(`code`,{children:`onEnded`}),` already told you why`]})]})]})]}),(0,m.jsx)(`h2`,{children:`Network facts and call diagnostics`}),(0,m.jsxs)(`p`,{children:[`Use RTA's call status, actions and end reasons for ordinary product flows. Internally the SDK reads LiveKit's connection and track facts through its public APIs. A connected room or an available track does not prove that the user saw a frame or heard audio. Keep playback observations and business rules separate from transport diagnostics. The `,(0,m.jsx)(`a`,{href:`/docs/react`,children:`React integration`}),` shows the SDK's existing call-level callbacks.`]}),(0,m.jsxs)(`p`,{children:[`Keep the RTA `,(0,m.jsx)(`code`,{children:`session_id`}),` together with your own call or segment ID. Server-authored `,(0,m.jsx)(`code`,{children:`metadata`}),` on `,(0,m.jsx)(`code`,{children:`startCall`}),` becomes`,` `,(0,m.jsx)(`code`,{children:`client_metadata`}),` on the wire and carries application correlation independently of transcript storage. The granted room name and the platform's session timestamps provide the starting point for a LiveKit investigation. Room names can be reused; a room SID identifies a specific room lifetime.`]}),(0,m.jsxs)(`p`,{children:[`Platform operators can investigate detailed historical network information in LiveKit Cloud. Its`,` `,(0,m.jsx)(`a`,{href:`https://docs.livekit.
675io/deploy/admin/analytics-api/`,children:`Analytics API`}),` lists sessions and participant details on Scale plans or higher; use the returned LiveKit session ID for detail requests, not the RTA session ID. Project management credentials remain with the operator; an RTA API key does not grant access to the shared LiveKit project. A developer diagnostic endpoint would need to authorize the RTA session first and expose only permitted fields. The documented API does not establish whether a browser actually presented each frame.`]}),(0,m.jsx)(`h2`,{children:`What you can do mid-call`}),(0,m.jsx)(h,{id:`calls-handle`,tabs:[{label:`TypeScript`,code:`call.say(text, { steer })  // say something to her; \`steer\` shapes THIS reply only
676call.sayAndEnd(text)       // speak one exact line, verbatim, then close
677call.keepAlive()           // the user is still here — postpone the idle disconnect
678call.end()                 // end now
679call.secondsRemaining      // to the hard stop, or null before the clock lands
680call.queuePosition         // place in line while waiting, else null`},{label:`Python`,code:`# These are CLIENT-side actions — they act on a live call, which lives in the
681# browser or native app. A Python backend owns the policy, not the live turn.
682#
683# What Python does own — and there is no Python SDK, so this half is plain HTTP:
684#   POST /realtime/livekit/session           mint a call — body is snake_case and strict:
685#                                            avatar_id, mode, instructions, initial_context,
686#                                            max_session_seconds, stt_mode, capabilities,
687#                                            transcript_webhook, client_metadata (the Wire
688#                                            column above, not the SDK field names)
689#   POST /realtime/livekit/session/release   free the slot early
690#   GET  /credits/balance                    what the next call must fit inside`,note:(0,m.jsxs)(m.Fragment,{children:[`See `,(0,m.jsx)(`a`,{href:`/docs/tool-calling`,children:`Tool calling`}),` for the Python side of the conversation — the signed transcript you receive when a call ends.`]})}]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`code`,{children:`say(text, { steer })`}),` is the hook for tool results —`,` `,(0,m.jsx)(`code`,{children:`steer`}),` applies to one reply and then evaporates, so a lookup result cannot leak into every later turn. See `,(0,m.jsx)(`a`,{href:`/docs/tool-calling`,children:`Tool calling`}),`.`]}),(0,m.jsx)(`h2`,{children:`Ending well`}),(0,m.jsxs)(`p`,{children:[`A call that stops mid-sentence feels broken no matter how good the render was. When time is nearly up you get a callback and a handle; whatever you pass to`,` `,(0,m.jsx)(`code`,{children:`sayAndEnd`}),` is spoken `,(0,m.jsx)(`strong`,{children:`exactly as written`}),` — never rewritten by the model, never interrupted — and only then does the call close.`]}),(0,m.jsx)(g,{lang:`tsx`,code:`<AvatarCall
691  client={client}                                  // createProxyClient({ proxyUrl })
692  avatarId={avatarId}
693  balanceMs={creditRemainingMs}
694
695  onEnding={async ({ secondsLeft, call }) => {
696    if (secondsLeft > 15) return;                 // wait for the last window
697    call.sayAndEnd(await writeGoodbye(character));
698  }}
699  onQuiet={({ secondsLeft }) => toast(\`Still there? (\${secondsLeft}s)\`)}
700  onLowBalance={({ secondsLeft }) => openTopUp(secondsLeft)}
701  onEnded={({ reason }) => showEndScreen(reason)}
702/>`}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`code`,{children:`onEnded`}),` always carries one of `,(0,m.jsx)(`code`,{children:`user_ended`}),` ·`,` `,(0,m.jsx)(`code`,{children:`session_cap`}),` · `,(0,m.jsx)(`code`,{children:`idle`}),` · `,(0,m.jsx)(`code`,{children:`disconnected`}),` ·`,` `,(0,m.jsx)(`code`,{children:`out_of_credits`}),` · `,(0,m.jsx)(`code`,{children:`agent_ended`}),` · `,(0,m.jsx)(`code`,{children:`failed`}),`. Key your end screen off it — "talk again?" and "you're out of minutes" are different screens 
702with different outcomes.`]}),(0,m.jsx)(`p`,{children:`The idle timer is real and enforced: when it fires the client disconnects and capacity is freed. That is deliberate. An abandoned tab holding a live character is the most expensive thing that can happen in this product, and it would be on your bill.`}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`You can also release a call from your server.`}),` `,(0,m.jsx)(`code`,{children:`rta.endCall(sessionId, { reason })`}),` frees the slot immediately — best-effort and idempotent: `,(0,m.jsx)(`code`,{children:`true`}),` when acknowledged, `,(0,m.jsx)(`code`,{children:`false`}),` for anything else, never a throw, so calling it twice (or on a call that already ended) is safe. Reach for it when your backend learns the call is over before the client does: a webhook, an admin action, a superseded reconnect. (Wire:`,` `,(0,m.jsx)(`code`,{children:`POST /realtime/livekit/session/release`}),`, `,(0,m.jsx)(`code`,{children:`realtime:write`}),` scope.)`]}),(0,m.jsxs)(`p`,{children:[`From the client, `,(0,m.jsx)(`code`,{children:`call.end()`}),` is the same thing and is what you normally want. The SDK also sends a release on tab-close via`,` `,(0,m.jsx)(`code`,{children:`releaseLiveKitSessionBeacon()`}),`, which survives the page going away where a normal `,(0,m.jsx)(`code`,{children:`fetch`}),` would not. Release early and often: a held slot is capacity nobody else can use, and the queue is shared.`]}),(0,m.jsx)(`h2`,{children:`Calling the API directly`}),(0,m.jsxs)(`p`,{children:[`Not on TypeScript? The same call is one authenticated POST — the Python tab on`,` `,(0,m.jsx)(`a`,{href:`/docs/quickstart`,children:`Quickstart`}),` has a working endpoint. Two things to know before hand-rolling it:`]}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`Every endpoint is strict, and casing is per endpoint.`}),` An unknown or mis-cased key is a rejected call, not a dropped field — and which casing is right depends on where you are writing. The realtime routes on this page (`,(0,m.jsx)(`code`,{children:`/realtime/livekit/session`}),` and its `,(0,m.jsx)(`code`,{children:`/release`}),`) are`,` `,(0,m.jsx)(`strong`,{children:`snake_case`}),`; every REST resource endpoint — avatars, clips, keys, assets — is `,(0,m.jsx)(`strong`,{children:`camelCase`}),`. The preview `,(0,m.jsx)(`code`,{children:`/scenes`}),` routes answer in snake_case, like the mint they feed. Measured against the published spec: 2 of the 10 request bodies are snake_case at the top level and 8 are camelCase — with one exception worth knowing, because the strictness above turns it into a 422:`,` `,(0,m.jsx)(`code`,{children:`POST /avatars`}),` is camelCase outside and `,(0,m.jsx)(`strong`,{children:`snake_case inside`}),` `,(0,m.jsx)(`code`,{children:`voice`}),` (`,(0,m.jsx)(`code`,{children:`auto_description`}),`, `,(0,m.jsx)(`code`,{children:`voice_id`}),`). The SDK translates its camelCase policy for you; a hand-rolled request has no such help — send the Wire column from the policy table above (`,(0,m.jsx)(`code`,{children:`initial_context`}),`,`,` `,(0,m.jsx)(`code`,{children:`max_session_seconds`}),`, `,(0,m.jsx)(`code`,{children:`transcript_webhook`}),`,`,` `,(0,m.jsx)(`code`,{children:`client_metadata`}),`…), never the SDK names.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`Pass the connection payload to your client byte-for-byte.`}),` It is validated strictly — add one key and the client rejects the whole thing, so the call never opens and nothing points at the cause.`]})]}),(0,m.jsxs)(`p`,{children:[`Full endpoint list, scopes, and error codes:`,` `,(0,m.jsx)(`a`,{href:`/docs/api-reference`,children:`API reference`}),`.`]})]})},{slug:`video`,title:`Creating an avatar`,description:`One image in, a moving character out: generated motion, idle variations and requestable actions, full-library declarations, loop editing, and generation prices.`,group:`build`,order:0,updated:`2026-09-09`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(`p`,{children:`Between sentences your character is not speaking — and that is most of a conversation. A still portrait reads as a dead connection. You do not have to fix that yourself any more: creation takes one image and generates the motion for you.`}),(0,m.jsx)(`h2`,{children:`One image in, a moving character out`}),(0,m.jsx)(`p`,{children:`Upload a portrait, create the avatar, and the platform renders — in the background — everything a live call needs:`}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`The looping idle video`}),`: a ~10s closed arc whose first and last frame are both the portrait, so it loops seamlessly by construction. This becomes the avatar's source video — the state she rests in.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`A multi-clip motion library`}),`: a motion director looks at the portrait and the persona and designs short closed-arc clips — a signature idle variant, a listening state, a gesture — each ending exactly where it began, so the realtime worker can swap between them at invisible seams.`]})]}),(0,m.jsxs)(`p`,{children:[`The create call returns immediately with `,(0,m.jsx)(`code`,{children:`status: "preprocessing"`}),` (the wire also reports the loop's own `,(0,m.jsx)(`code`,{children:`idleVideoStatus`}),`); poll`,` `,(0,m.jsx)(`code`,{children:`getAvatar(id)`}),` (`,(0,m.jsx)(`code`,{children:`GET /avatars/{id}`}),` on the wire) until`,` `,(0,m.jsx)(`code`,{children:`ready`}),`, and read the library with`,` `,(0,m.jsx)(`code`,{children:`GET /avatars/{id}/clips`}),`. Render calls inherit the generated library automatically; a mint that sends an explicit empty`,` `,(0,m.jsx)(`code`,{children:`clip_library: []`}),` opts that call out of clips entirely. A motion prompt at creation art-directs the loop (wire `,(0,m.jsx)(`code`,{children:`motionPrompt`}),`, or the Avatars page's `,(0,m.jsx)(`strong`,{children:`Resting motion direction`}),`); a clip that fails to render fails alone — the avatar still ships with the loop and the clips that made it.`]}),(0,m.jsxs)(`p`,{children:[`Creation is `,(0,m.jsx)(`strong`,{children:`image-only`}),`: one portrait is the entire input, and the platform generates the initial videos. Afterwards, library clips can also use pose-compatible uploaded video assets. Below are the session-time behavior, the full-library declaration, and the generation tools you can use to art-direct motion.`]}),(0,m.jsx)(`h2`,{children:`Two modes`}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Mode`}),(0,m.jsx)(`th`,{children:`How it works`}),(0,m.jsx)(`th`,{children:`Choose it when`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`code`,{children:`looping`}),` (default)`]}),(0,m.jsx)(`td`,{children:`Speech is animated onto the looping video the platform generated from the portrait. Her face, her room, her wardrobe — pixel-identical every call.`}),(0,m.jsx)(`td`,{children:`Identity has to be exact and repeatable. Almost every companion, brand, or character product.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`generative`})}),(0,m.jsx)(`td`,{children:`The video is synthesized as she speaks. No clips to supply, none to maintain.`}),(0,m.jsx)(`td`,{children:`You have no footage, or you want motion no loop could cover.`})]})]})]}),(0,m.jsx)(h,{id:`video-generative`,tabs:[{label:`TypeScript`,code:`session: async ({ avatarId }) => ({
703  video: { mode: "generative" },   // that is the entire switch
704}
704)`},{label:`Python`,code:`body = {
705    "avatar_id": avatar_id,
706    "mode": "avatar",
707    "render_backend": "generative",   # that is the entire switch
708}`}]}),(0,m.jsxs)(`p`,{children:[`Generative ignores clips by construction — the type makes passing them impossible rather than silently dropping them. Everything below is `,(0,m.jsx)(`code`,{children:`looping`}),`.`]}),(0,m.jsxs)(`blockquote`,{children:[`A live call needs a `,(0,m.jsx)(`strong`,{children:`video`}),` source — which every avatar created from an image now grows on its own. Before the generated loop attaches, a mint is refused with a clear error, never a black or broken call; once it attaches, calls can connect while the realtime cache still warms (the first call starts slower). Poll `,(0,m.jsx)(`code`,{children:`status`}),` to `,(0,m.jsx)(`code`,{children:`ready`}),` for the fully-warm contract; a`,` `,(0,m.jsx)(`code`,{children:`failed`}),` status carries the reason in `,(0,m.jsx)(`code`,{children:`error`}),`.`]}),(0,m.jsx)(`h2`,{children:`Looping: the resting loop`}),(0,m.jsx)(`p`,{children:`You do not need a session override. The avatar's source video is the generated resting loop, and calls inherit its clip library and behavior. Resting in that loop needs no declaration at all:`}),(0,m.jsx)(h,{id:`video-loop`,tabs:[{label:`TypeScript`,code:`session: async ({ avatarId }) => ({
709  // no video config: the avatar rests in its generated idle loop
710})`},{label:`Python`,code:`body = {
711    "avatar_id": avatar_id,
712    "mode": "avatar",
713    # no video fields: the avatar rests in its generated idle loop
714}`}]}),(0,m.jsx)(`h2`,{children:`Looping: shared clips, event-driven behavior`}),(0,m.jsxs)(`p`,{children:[`A clip is media; behavior decides when to play it. The same clip can serve the`,` `,(0,m.jsx)(`code`,{children:`idle`}),` variations, the `,(0,m.jsx)(`code`,{children:`on.userSpeechStarted.clips`}),` list, and any action under `,(0,m.jsx)(`code`,{children:`actions`}),` at once. There are no per-clip roles. The platform library and its behavior ride calls automatically.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`code`,{children:`userSpeechStarted`}),` plays a listening reaction once when the user starts speaking; it is not a state held for their whole utterance. `,(0,m.jsx)(`code`,{children:`actions`}),` is the requestable catalogue: the language model reads each action's`,` `,(0,m.jsx)(`code`,{children:`description`}),` and asks for one when it fits its reply — declaring an action never plays it by itself. Write that description for the language model, not as a generation prompt: the renderer reads `,(0,m.jsx)(`code`,{children:`source.motionPrompt`}),` instead. Speaker lip-sync stays on the current playback cursor during full-duplex speech; speaking does not reset the body to a separate talking loop.`]}),(0,m.jsxs)(`p`,{children:[`GET separates media and job rows in `,(0,m.jsx)(`code`,{children:`data`}),` from playback rules in`,` `,(0,m.jsx)(`code`,{children:`behavior`}),`. Rows have no row-level `,(0,m.jsx)(`code`,{children:`role`}),`, `,(0,m.jsx)(`code`,{children:`whenHint`}),`, or`,` `,(0,m.jsx)(`code`,{children:`weight`}),`; the one weight lives on the idle group. Inspect both together:`]}),(0,m.jsx)(h,{id:`video-states`,tabs:[{label:`TypeScript`,code:`const library = await rta.listClips(avatarId);
715const { data, revision, anchor, defaultSourceAssetId, behavior } = library;
716// data: media/job rows; url is the serving take, status is the current job.
717// behavior.idle: { clips: ["idle_soft", "nod"], weight: 2 }
718// behavior.on?.userSpeechStarted: { clips: ["nod"] }
719// behavior.actions: {
720//   agree: { description: "When agreeing with the user", clips: ["nod"] }
721// }`},{label:`Python`,code:`response = client.get(f"/avatars/{avatar_id}/clips")
722response.raise_for_status()
723library = response.json()
724revision = library["revision"]   # 0 = creation-owned; bumps on every accepted PUT
725clips = library["data"]         # media/job rows, not behavior declarations
726anchor = library["anchor"]
727default_source_asset_id = library["defaultSourceAssetId"]
728behavior = library["behavior"]
729# behavior["idle"] = {"clips": ["idle_soft", "nod"], "weight": 2}
730# behavior["on"]["userSpeechStarted"] = {"clips": ["nod"]}
731# behavior["actions"]["agree"] = {
732#     "description": "When agreeing with the user", "clips": ["nod"]}
733# idle, on and actions are optional; inspect their presence before indexing.`}]}),(0,m.jsxs)(`p`,{children:[`A mint that sends an explicit empty `,(0,m.jsx)(`code`,{children:`clip_library: []`}),` opts that call out of clips entirely — she stays in the resting loop. For persistent behavior changes, declare the avatar library rather than building a second client-side state machine.`]}),(0,m.jsx)(`h3`,{children:`One weighted idle pool`}),(0,m.jsxs)(`p`,{children:[`The stored source is addressed as `,(0,m.jsx)(`code`,{children:`primary`}),` and participates in the same weighted idle pool as the authored clips. Use `,(0,m.jsx)(`code`,{children:`idle.weights`}),` to weight any member, including the source; zero disables it. Every member avoids immediately repeating when another is available, while a sole available member loops completely. The id `,(0,m.jsx)(`code`,{children:`primary`}),` stays reserved for source addressing.`]}),(0,m.jsx)(h,{id:`video-idle-variations`,tabs:[{label:`JSON`,code:`{
734  "idle": { "clips": ["sway", "stretch"], "weights": { "primary": 1, "sway": 2, "stretch": 1 } }
735}`}]}),(0,m.jsxs)(`p`,{children:[`Each member weight is finite, from 0–100, and defaults to 1. At least one member must have positive weight. Unavailable members are removed before one weighted draw; the remaining weights are renormalized. These are selection probabilities, not screen-time proportions. Use `,(0,m.jsx)(`code`,{children:`weights`}),` or the legacy aggregate`,(0,m.jsx)(`code`,{children:`weight`}),`, never both. Legacy `,(0,m.jsx)(`code`,{children:`idle.weight`}),` defaults to 1 and is divided equally among listed clips, with source weight 1. No media is re-rendered by this migration. If a disabled source is the only registered material, session admission returns a retryable `,(0,m.jsx)(`code`,{children:`clip_library_not_ready`}),`.`]}),(0,m.jsx)(`h3`,{children:`Every clip is a sentence, and you can change it`}),(0,m.jsxs)(`p`,{children:[`The starter library is a starting point, not a fixed set. Declare the library you want and the platform reconciles: a clip whose description is unchanged is`,` `,(0,m.jsx)(`strong`,{children:`kept`}),` and keeps serving, a new or revised one is `,(0,m.jsx)(`strong`,{children:`queued`}),` `,`to render, and one you leave out is `,(0,m.jsx)(`strong`,{children:`retired`}),`. The 202 is acceptance, not readiness. `,(0,m.jsx)(`code`,{children:`revision`}),` is the top-level field of the GET envelope beside`,` `,(0,m.jsx)(`code`,{children:`data`}),` — 0 while the creation pipeline owns the library; each accepted declare increments it, and the 202 body carries the new value.`]}
735),(0,m.jsxs)(`p`,{children:[`This is a full desired set: removing a record retires that clip, and omitted behavior is removed too. `,(0,m.jsx)(`code`,{children:`expectedRevision`}),` is required. Read the library first, keep the sources you still want, and send the revision you read; a stale writer receives`,` `,(0,m.jsx)(`code`,{children:`409 revision_conflict`}),`. For older generated rows whose editable motion direction is null, supply a new direction before declaring them as generated sources.`]}),(0,m.jsx)(h,{id:`video-declare`,tabs:[{label:`TypeScript`,code:`const { revision } = await rta.listClips(avatarId);
736const { plan } = await rta.setClipLibrary(avatarId, {
737  expectedRevision: revision,          // required compare-and-set
738  clips: {
739    idle_soft: { source: { motionPrompt: "breathing gently, a slow blink" } },
740    nod: { source: { motionPrompt: "a small attentive nod, returns to rest", durationSeconds: 5 } },
741    wave: { source: { motionPrompt: "raises a hand and waves warmly, then lowers it" } },
742  },
743  idle: { clips: ["idle_soft", "nod"], weight: 2 },
744  on: {
745    userSpeechStarted: { clips: ["nod"] },
746  },
747  actions: {
748    agree: { description: "When agreeing with the user", clips: ["nod"] },
749    greet: { description: "When greeting the user", clips: ["wave"] },
750  },
751});
752// plan → { kept: [...], queued: [...], retired: [...] }
753
754await rta.waitForClips(avatarId);      // settles when nothing is still RENDERING`},{label:`Python`,code:`response = client.get(f"/avatars/{avatar_id}/clips")
755response.raise_for_status()
756revision = response.json()["revision"]
757r = client.put(f"/avatars/{avatar_id}/clips", json={
758    "expectedRevision": revision,   # required compare-and-set
759    "clips": {
760        "idle_soft": {"source": {"motionPrompt": "breathing gently, a slow blink"}},
761        "nod": {"source": {"motionPrompt": "a small attentive nod, returns to rest", "durationSeconds": 5}},
762        "wave": {"source": {"motionPrompt": "raises a hand and waves warmly, then lowers it"}},
763    },
764    "idle": {"clips": ["idle_soft", "nod"], "weight": 2},
765    "on": {
766        "userSpeechStarted": {"clips": ["nod"]},
767    },
768    "actions": {
769        "agree": {"description": "When agreeing with the user", "clips": ["nod"]},
770        "greet": {"description": "When greeting the user", "clips": ["wave"]},
771    },
772})
773# httpx does not raise on 4xx: 403 clip_library_not_enabled, 409 revision_conflict
774# (body carries the current revision), 422 clip_declaration_rejected
775r.raise_for_status()
776plan = r.json()["plan"]`}]}),(0,m.jsxs)(`p`,{children:[`Declaring is a per-tenant rollout: until it is enabled for your tenant the PUT answers`,` `,(0,m.jsx)(`code`,{children:`403 clip_library_not_enabled`}),` and avatars keep their creation-generated library. The other refusals — `,(0,m.jsx)(`code`,{children:`409 revision_conflict`}),`,`,` `,(0,m.jsx)(`code`,{children:`422 clip_declaration_rejected`}),` — are in`,` `,(0,m.jsx)(`a`,{href:`/docs/editing#refusals-worth-telling-apart`,children:`Refusals worth telling apart`}),`.`]}),(0,m.jsxs)(`p`,{children:[`Each record contains only `,(0,m.jsx)(`code`,{children:`source`}),`: either `,(0,m.jsx)(`code`,{children:`{ assetId }`}),` `,`or `,(0,m.jsx)(`code`,{children:`{ motionPrompt, durationSeconds? }`}),`. Generated`,` `,(0,m.jsx)(`code`,{children:`source.durationSeconds`}),` is an integer from 4–8 seconds, default 5; it does not belong beside `,(0,m.jsx)(`code`,{children:`source`}),`. Changing source content queues media work; changing selection membership, the idle weight, or action descriptions is metadata-only and does not re-render unchanged media. An updated clip keeps its previous serving take while the new job runs, and after a failed replacement.`]}),(0,m.jsxs)(`p`,{children:[`Listening and action lists draw uniformly. Idle uses the weighted pool above: each `,(0,m.jsx)(`code`,{children:`idle.weights`}),` value is finite in 0–100 and defaults to 1. Legacy`,(0,m.jsx)(`code`,{children:`idle.weight`}),` remains supported without `,(0,m.jsx)(`code`,{children:`weights`}),`. Each list is nonempty with unique references to records in `,(0,m.jsx)(`code`,{children:`clips`}),`; the source is addressed separately as `,(0,m.jsx)(`code`,{children:`primary`}),` in the weight map. Selection uses only eligible serving material.`]}),(0,m.jsxs)(`p`,{children:[`Ten idle variations are valid; there is no role-count or total-clip authoring cap. Request bytes remain bounded by transport limits, and worker admission is independent: a valid declaration is not a promise of unlimited resident media or GPU capacity. Clip and action ids use 1–64 letters, digits, underscores, or hyphens; reserved keys`,` `,(0,m.jsx)(`code`,{children:`__proto__`}),`, `,(0,m.jsx)(`code`,{children:`constructor`}),`, and `,(0,m.jsx)(`code`,{children:`prototype`}),` are refused. Motion prompts are nonempty after trimming and at most 1,200 characters; action descriptions are nonempty after trimming and at most 512 characters.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`You can also upload a clip`}),` — `,(0,m.jsx)(`code`,{children:`source: { assetId }`}),` `,`instead of a prompt — but it is `,(0,m.jsx)(`em`,{children:`pose-validated`}),` before it can serve: its first and last frames have to land on the avatar's rest pose, or that clip settles`,` `,(0,m.jsx)(`code`,{children:`failed`}),` with the verdict in `,(0,m.jsx)(`code`,{children:`poseCheck`}),` and the rest of the library is untouched. That validation is the whole reason an upload is allowed at all; without it a supplied video makes every switch visible.`]}),(0,m.jsxs)(`p`,{children:[`An action has its own id: `,(0,m.jsx)(`code`,{children:`greet`}),` is what the model asks for, and`,` `,(0,m.jsx)(`code`,{children:`wave`}),` is only the media it plays — so several actions can share one clip, and one action can offer several for variety.`]}),(0,m.jsx)(`h3`,{children:`The resting loop is not a clip, and it is also a sentence`}),(0,m.jsxs)(`p`,{children:[`The avatar's `,(0,m.jsx)(`strong`,{children:`source`}),` is the resting loop, and declaring idle variations does not replace it — resting is the other half of every idle draw, always available, and it is the one thing that may play twice running. So there is nothing to preserve and nothing to re-declare: the id `,(0,m.jsx)(`code`,{children:`primary`}),` is reserved, and a declaration that uses it is refused.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`Do not declare the source asset as a variation.`}),` That adds a`,` `,(0,m.jsx)(`em`,{children:`second`}),` copy of it: the avatar rests on it AND draws it as a variation, so it plays roughly twice as often as you intended and can follow itself back-to-back through the variation path — exactly the stutter the no-immediate-repeat rule exists to prevent.`,` `,(0,m.jsx)(`code`,{children:`defaultSourceAssetId`}),` is on the `,(0,m.jsx)(`code`,{children:`listClips`}),` envelope to tell you which asset the loop is, not to be declared.`]}),(0,m.jsxs)(`p`,{children:[`Direct the source loop at creation with `,(0,m.jsx)(`code`,{children:`motionPrompt`}),`, and re-direct it afterwards — that is how the resting loop changes, not through the clip library:`]}),(0,m.jsx)(h,{id:`video-loop-sentence`,tabs:[{label:`TypeScript`,code:`const { servingUrl } = await rta.setLoop(avatarId, {
777  motionPrompt: "tilts her head, a small amused smile, settles back to centre",
778});
779// servingUrl = the loop she is playing RIGHT NOW, for the whole render
780
781await rta.waitForLoop(avatarId);   // throws if the render gave up`},{label:`Python`,code:`accepted = client.put(f"/avatars/{avatar_id}/loop", json={
782    "motionPrompt": "tilts her head, a small amused smile, settles back to centre",
783}).json()
784# accepted["servingUrl"] is the PREVIOUS loop (None if she had none yet) — she plays it for the whole render.
785# Poll GET /avatars/{id} until idleVideoStatus is "ready" (or "failed").`}]}),(0,m.jsxs)(`p`,{children:[`She stays `,(0,m.jsx)(`code`,{children:`ready`}),` and keeps serving the previous loop for the entire render, then the swap publishes in one step. `,(0,m.jsx)(`strong`,{children:`Your clip library is untouched`}),` — clips render against the portrait, not against the loop, so a re-direct re-queues nothing and does not move the library's`,` `,(0,m.jsx)(`code`,{children:`revision`}),`. Describe a `,(0,m.jsx)(`em`,{children:`closed arc`}),`: it has to end where it began, or the loop snaps every time it wraps.`]}),(0,m.jsxs)(`p`,{children:[`And keep it `,(0,m.jsx)(`strong`,{children:`small`}),`. Because a separate model animates her mouth during speech, every loop description is held to a nearly-still envelope — blinks, a soft closed-lip smile, a shift in weight, a few degrees of head drift. A laugh, a head turn, or an open mouth comes back inside that envelope anyway: a new render, a new charge, and a loop that looks like the one you already had. It is the only outcome here that carries no error code, which is exactly what makes it worth knowing before you write the sentence.`]}),(0,m.jsx)(`h3`,{children:`What makes a switch invisible`}),(0,m.jsxs)(`p`,{children:[`Every clip the director renders starts and ends on the `,(0,m.jsx)(`strong`,{children:`same rest pose`}),` `,`— the portrait itself. That shared frame is what turns a switch into a splice instead of a jump, and it is why each clip is generated independently from the one anchor image rather than chained off the previous clip's last frame, which would compound drift with every hop. Clips stay short (~5s closed arcs): the lever for natural idle is `,(0,m.jsx)(`strong`,{children:`how many states × how long she dwells`}),`, not clip length.`]}),(0,m.jsx)(`h2`,{children:`Editing the loop`}),(0,m.jsxs)(`blockquote`,{children:[(0,m.jsx)(`strong`,{children:`Preview — not yet enabled on the public fleet.`}),` The field is accepted and forwarded today, and currently resolves to the unedited loop. Build against it if you want to be ready; do not ship a feature to your users that depends on it yet.`]}),(0,m.jsxs)(`p`,{children:[`A state map switches between clips. `,(0,m.jsx)(`em`,{children:`Editing`}),` rewrites the clip itself: the generated loop is re-rendered under a sentence you write, and the result is lip-synced by the same path as an unedited one. One loop becomes every season, every set, every outfit — without ever generating a new one.`]}),(0,m.jsx)(h,{id:`video-edits`,tabs:[{label:`TypeScript`,code:`session: async ({ avatarId }) => ({
786  video: {
787    edits: { instruction: "a snowy cabin at night, warm lamplight" },
788  },
789})`},{label:`Python`,code:`body = {
790    "avatar_id": avatar_id,
791    "mode": "avatar",
792    "support_edits": {"instruction": "a snowy cabin at night, warm lamplight"},
793}`}]}),(0,m.jsxs)(`p`,{children:[`Editing runs `,(0,m.jsx)(`strong`,{children:`upstream of the lips`}),`, on the closed-mouth plate, so it costs nothing at the start of a call — she is on screen and talking from the first word, she simply has not changed clothes yet. The first lap through the loop is live and expensive; every lap after replays what that one banked, so a fixed instruction converges on the cost of plain looping.`]}),(0,m.jsxs)(`p`,{children:[`It needs the avatar's `,(0,m.jsx)(`strong`,{children:`video`}),` source — the generated loop — so it is refused at the mint while the loop is still rendering, rather than falling back. It also cannot be combined with `,(0,m.jsx)(`code`,{children:`generative`}),` — a synthesized body has no clip to open — and the type makes that pairing unrepresentable rather than a runtime error. A `,(0,m.jsx)(`code`,{children:`voice`}
793),` call is refused for the same reason: no video track, nothing to rewrite.`]}),(0,m.jsxs)(`p`,{children:[`Editing is `,(0,m.jsx)(`strong`,{children:`per call, and off unless you ask`}),`. Nothing stored on the character turns it on, so a call that omits `,(0,m.jsx)(`code`,{children:`edits`}),` is byte-identical to one made before this option existed — which matters, because the first edited lap holds a scarce GPU editor and can wait minutes on a cold one. If you want every call for a character edited, say so in your `,(0,m.jsx)(`code`,{children:`session`}),` policy; that is the same place you already decide the persona and the ceiling.`]}),(0,m.jsx)(`h3`,{children:`Letting the look follow the conversation`}),(0,m.jsxs)(`blockquote`,{children:[(0,m.jsx)(`strong`,{children:`Experimental — "runtime edit".`}),` The shape of `,(0,m.jsx)(`code`,{children:`live`}),` may still change, and the re-dress does not reach every capacity tier yet, so a call may run to the end on its opening set. See`,` `,(0,m.jsx)(`a`,{href:`/docs/experimental`,children:`Experimental features`}),` for what that word promises. The `,(0,m.jsx)(`em`,{children:`trust boundary`}),` below is not experimental — it is enforced on every mint.`]}),(0,m.jsxs)(`p`,{children:[`Add `,(0,m.jsx)(`code`,{children:`live`}),` and the set stops holding still: the look follows the conversation, within the bounds you set. Those rules are`,` `,(0,m.jsx)(`strong`,{children:`yours, not your user's`}),` — they are the app's policy about what a conversation is allowed to do to the picture, which is why they are server-owned like`,` `,(0,m.jsx)(`code`,{children:`instructions`}),`. A browser that could set them could redress your character into anything.`]}),(0,m.jsx)(h,{id:`video-live-edit`,tabs:[{label:`TypeScript`,code:`edits: {
794  instruction: "her apartment at golden hour",
795  live: {
796    rules: "only change the room and the light, never her face or clothes; " +
797           "only when they ask to go somewhere",
798    cooldownSeconds: 60,
799  },
800}`},{label:`Python`,code:`body["support_edits"] = {
801    "instruction": "her apartment at golden hour",
802    "live_edit": {
803        "rules": ("only change the room and the light, never her face or clothes; "
804                  "only when they ask to go somewhere"),
805        "cooldown_seconds": 60,
806    },
807}`}]}),(0,m.jsxs)(`p`,{children:[`Write the rules narrowly. Every change of direction buys a fresh lap on a scarce editor `,(0,m.jsx)(`em`,{children:`and`}),` a visible style pop — the editor has no mid-stream prompt update, so a look changes at a seam rather than easing. `,(0,m.jsx)(`code`,{children:`cooldownSeconds`}),` is the floor that stops an eager director spending a GPU per user turn; it is a bound, not a preference, and the default is deliberately conservative.`]}),(0,m.jsx)(`h2`,{children:`Generation pricing`}),(0,m.jsxs)(`p`,{children:[`The generated library itself is currently `,(0,m.jsx)(`strong`,{children:`included with creation`}),` — the loop's generation is metered per-model exactly like a resting-loop update on the Avatars page, and the extra library clips are not billed separately while pricing settles. The rates below apply when you drive the generators `,(0,m.jsx)(`em`,{children:`directly`}),`:`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`Generation is billed at the model vendor's own list price.`}),` We add no margin — what you pay is what MiniMax and ByteDance charge, so the table below moves when their rate cards move. Prices are per generated clip and charged on success; failed generations are not charged.`]}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Gadget`}),(0,m.jsx)(`th`,{children:`Model`}),(0,m.jsx)(`th`,{children:`Best for`}),(0,m.jsx)(`th`,{children:`Price`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`minimax-h3`})}),(0,m.jsx)(`td`,{children:`MiniMax H3 · first frame → last frame · 768P`}),(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`strong`,{children:`The default.`}),` Closed loops from any portrait — both ends pinned to the same frame, so the clip closes on itself by construction.`]}),(0,m.jsx)(`td`,{children:`$0.80`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`seedance-2.0-mini`})}),(0,m.jsx)(`td`,{children:`Seedance 2.0 Mini · 720p`}),(0,m.jsx)(`td`,{children:`Cheap iteration while you are still finding the look`}),(0,m.jsx)(`td`,{children:`$0.80`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`seedance-2.0-fast`})}),(0,m.jsx)(`td`,{children:`Seedance 2.0 Fast · 720p`}),(0,m.jsx)(`td`,{children:`The everyday choice — clearly better motion than mini`}),(0,m.jsx)(`td`,{children:`$1.20`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`seedance-2.0`})}),(0,m.jsx)(`td`,{children:`Seedance 2.0 · 720p`}),(0,m.jsx)(`td`,{children:`The hero clip: the one she rests in most of the time`}),(0,m.jsx)(`td`,{children:`$1.50`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`seedance-2.5`})}),(0,m.jsx)(`td`,{children:`Seedance 2.5 · 720p`}),(0,m.jsx)(`td`,{children:`The newest tier; priced as 2.0 until its own rate card publishes`}),(0,m.jsx)(`td`,{children:`$1.50`})]})]})]}),(0,m.jsxs)(`p`,{children:[`Prices are for a 10-second clip at 24fps — H3 renders 768P portrait, the Seedance tiers 720p 16:9 — the shape worth generating for a resting loop. The Seedance rates come from`,` `,(0,m.jsx)(`a`,{href:`https://www.byteplus.c
807om/en/topic/578667`,rel:`noreferrer`,children:`ByteDance's published Seedance rate card`}),` `,`($0.15/sec at 720p, $0.12 Fast, $0.08 Mini); H3 is $0.08/s at 768P. Credits bill at $1 ≈ 720 credit-seconds, so `,(0,m.jsx)(`code`,{children:`seedance-2.0`}),` is 1,080 credits and the default`,` `,(0,m.jsx)(`code`,{children:`minimax-h3`}),` is 576.`]}),(0,m.jsx)(`p`,{children:`1080p costs materially more on every model — Seedance 2.0 is $0.37/sec there, roughly 2.5× the 720p rate. For a clip that spends its life as a background loop behind a speaking character, 720p is the right default.`}),(0,m.jsx)(`h2`,{children:`Uploading the portrait`}),(0,m.jsxs)(`p`,{children:[`The one asset you bring is the image. Upload the bytes, or hand us a URL and we fetch it server-side — the second is what you want for anything large (the origin must answer with `,(0,m.jsx)(`code`,{children:`Content-Length`}),`; the same 8 MB image cap as an upload applies):`]}),(0,m.jsx)(h,{id:`video-upload`,tabs:[{label:`TypeScript`,code:`// bytes you already hold — createAvatar takes the asset id from here
808const asset = await rta.uploadAsset(file, { kind: "image" });
809
810// or a URL we fetch for you (no multipart through your runtime)
811const asset = await rta.createRemoteAsset({
812  kind: "image",
813  remoteUrl: "https://your-bucket.example.com/rin/portrait.png",
814});
815
816asset.url; // https://realtimeavatar.ai/api/assets/… — public, ready to use`},{label:`Python`,code:`# client: the httpx.Client from the quickstart (base_url https://realtimeavatar.ai/api/v1, bearer API key)
817
818# bytes you already hold
819with open("portrait.png", "rb") as fh:
820    asset = client.post("/assets", files={"file": fh}, data={"kind": "image"}).json()
821# raw bytes without a filename: files={"file": ("portrait.png", data, "image/png")} —
822# the server keys on the part's Content-Type (jpeg/png/webp)
823
824# or a URL we fetch for you
825asset = client.post("/assets/remote", json={
826    "kind": "image",
827    "remoteUrl": "https://your-bucket.example.com/rin/portrait.png",
828}).json()
829
830asset["publicUrl"]  # https://realtimeavatar.ai/api/assets/… — public, ready to use
831# (the SDK surfaces this same field as asset.url; on the wire it is publicUrl)`}]}),(0,m.jsxs)(`p`,{children:[`The returned URL is `,(0,m.jsx)(`strong`,{children:`publicly readable`}),` — no auth, no signing. Treat these URLs as `,(0,m.jsx)(`strong`,{children:`unguessable, not private`}),`: the key is random and nothing indexes it, but anyone holding the link can fetch it. Do not upload anything whose exposure would matter.`]}),(0,m.jsxs)(`p`,{children:[`Uploading costs nothing — you are charged for generation, not storage. Accepted kinds are `,(0,m.jsx)(`code`,{children:`image`}),` (portraits), `,(0,m.jsx)(`code`,{children:`audio`}),` (voice material), and`,` `,(0,m.jsx)(`code`,{children:`video`}),` for an uploaded motion clip declared with`,` `,(0,m.jsx)(`code`,{children:`source: { assetId }`}),`. Video upload does not make the asset an Avatar source: new Avatars are still portrait-only, and an uploaded action clip must pass the rest-pose check before it can serve.`]})]})},{slug:`editing`,title:`Editing a character`,navLabel:`Editing`,description:`Change an avatar's clips and resting loop after creation: how to declare them, how each change settles, and the refusals worth telling apart.`,group:`build`,order:2,updated:`2026-09-08`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsxs)(`p`,{children:[`Her clips and her resting loop are both editable after creation. The resting loop is re-directed in words; each library clip can either be rendered from a motion description or use the `,(0,m.jsx)(`code`,{children:`assetId`}),` of a pose-compatible video you uploaded. Her id, her voice and her live calls survive every change on this page.`]}),(0,m.jsxs)(`p`,{children:[`Media changes are accepted before their background work finishes, and`,` `,(0,m.jsx)(`strong`,{children:`she keeps serving the previous take`}),` until its replacement is ready. Behavior-only edits do not render media. Keep the API key and declaration policy on your server; never forward arbitrary browser-authored motion or action descriptions as trusted character policy.`]}),(0,m.jsx)(`h2`,{children:`Her clips`}),(0,m.jsxs)(`p`,{children:[`Send the library you want, in full: `,(0,m.jsx)(`code`,{children:`clips`}),` is a record keyed by clip id; each value contains only `,(0,m.jsx)(`code`,{children:`source`}),`. Playback rules live separately in`,` `,(0,m.jsx)(`code`,{children:`idle`}),`, `,(0,m.jsx)(`code`,{children:`on`}),` and `,(0,m.jsx)(`code`,{children:`actions`}),`. The platform reconciles source content: an unchanged clip is `,(0,m.jsx)(`strong`,{children:`kept`}),`, new or revised media is`,` `,(0,m.jsx)(`strong`,{children:`queued`}),`, and a record you leave out is `,(0,m.jsx)(`strong`,{children:`retired`}),`. The stored source joins the runtime idle pool as `,(0,m.jsx)(`code`,{children:`primary`}),`. That id is reserved for source addressing, so do not duplicate it in the media record.`]}),(0,m.jsx)(h,{id:`editing-clips`,tabs:[{label:`TypeScript`,code:`const { revision } = await rta.listClips(avatarId);
832
833const { plan } = await rta.setClipLibrary(avatarId, {
834  expectedRevision: revision,        // required compare-and-set
835  clips: {
836    sway: { source: { motionPrompt: "sways gently, settles back to rest" } },
837    nod: { source: { motionPrompt: "a small attentive nod, returns to rest", durationSeconds: 5 } },
838  },
839  idle: { clips: ["sway", "nod"], weights: { primary: 1, sway: 2, nod: 1 } },
840  on: {
841    userSpeechStarted: { clips: ["nod"] },
842  },
843  actions: {
844    agree: { description: "When agreeing with the user", clips: ["nod"] },
845  },
846});
847// plan → { kept: [...], queued: [...], retired: [...] }
848
849await rta.waitForClips(avatarId);`},{label:`Python`,code:`response = client.get(f"/avatars/{avatar_id}/clips")
850response.raise_for_status()
851library = response.json()
852revision = library["revision"]   # top-level; 0 while creation still owns the library
853
854r = client.put(f"/avatars/{avatar_id}/clips", json={
855    "expectedRevision": revision,
856    "clips": {
857        "sway": {"source": {"motionPrompt": "sways gently, settles back to rest"}},
858        "nod": {"source": {"motionPrompt": "a small attentive nod, returns to rest", "durationSeconds": 5}},
859    },
860    "idle": {"clips": ["sway", "nod"], "weights": {"primary": 1, "sway": 2, "nod": 1}},
861    "on": {
862        "userSpeechStarted": {"clips": ["nod"]},
863    },
864    "actions": {
865        "agree": {"description": "When agreeing with the user", "clips": ["nod"]},
866    },
867})
868# httpx does not raise on 4xx. Refusals come back as {error, status, code} with no "plan":
869# 403 clip_library_not_enabled, 409 revision_conflict (body "revision" = current), 422 clip_declaration_rejected
870r.raise_for_status()
871plan = r.json()["plan"]   # the 202 body also carries the new "revision"`}]}),(0,m.jsxs)(`p`,{children:[`Generated `,(0,m.jsx)(`code`,{children:`source.durationSeconds`}),` is an optional integer from 4–8 seconds, default 5. Uploaded sources contain only `,(0,m.jsx)(`code`,{children:`assetId`}),`; the GET envelope's `,(0,m.jsx)(`code`,{children:`defaultSourceAssetId`}),` names the source member of the idle pool. Selection membership, the idle weight, and action descriptions are metadata-only: changing them does not re-render unchanged sources. Motion prompts and action descriptions are screened before the declaration can change anything, including on metadata-only updates.`]}),(0,m.jsxs)(`p`,{children:[`Every selection is a plain list of clip ids — nonempty, no duplicates within one list, every reference declared in `,(0,m.jsx)(`code`,{children:`clips`}),` — and candidates in one list draw uniformly for listening and actions. Idle uses one weighted idle pool:`,(0,m.jsx)(`code`,{children:`idle.weights`}),` gives each member, including `,(0,m.jsx)(`code`,{children:`primary`}),`, a finite weight from 0–100. Each omitted member defaults to 1; zero disables it. At least one member must remain positive. Unavailable members are removed and weights are renormalized. The legacy aggregate `,(0,m.jsx)(`code`,{children:`idle.weight`}),` is still supported, but cannot be combined with `,(0,m.jsx)(`code`,{children:`weights`}),`. Ten idle variations are valid: there are no idle, listening, or total-clip authoring caps beyond 32 per list. Request bytes remain bounded by transport, and worker admission is independent of declaration validity, not unlimited GPU capacity.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`code`,{children:`on.userSpeechStarted`}),` chooses a listening clip to play once, not a loop held throughout the user's speech — a triggered reaction repeats freely and never goes silent because it just played. `,(0,m.jsx)(`code`,{children:`actions`}),` is the requestable menu for the language model: each `,(0,m.jsx)(`code`,{children:`description`}),` explains when that action fits, its `,(0,m.jsx)(`code`,{children:`clips`}),` list selects the take, and declaring one never plays it by itself. The same clip can serve idle, listening and any action. Every idle member, including the source, avoids immediately repeating when another member is available. A sole available member loops completely. Speaker lip-sync continues on the current playback cursor during full-duplex speech; it does not select a separate talking state.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`Omission is deletion.`}),` This is the whole desired library, not a patch — a clip missing from the record is retired, and omitted `,(0,m.jsx)(`code`,{children:`idle`}),`,`,` `,(0,m.jsx)(`code`,{children:`on`}),` or `,(0,m.jsx)(`code`,{children:`actions`}),` rules are removed. `,(0,m.jsx)(`code`,{children:`expectedRevision`}),` is required: pass the`,` `,(0,m.jsx)(`code`,{children:`revision`}),` you last read and a stale writer gets a `,(0,m.jsx)(`code`,{children:`409`}),` instead of silently erasing another change. Re-read and reconcile after a conflict; do not blindly overwrite the revision. You read it from the top level of`,` `,(0,m.jsx)(`code`,{children:`GET /avatars/{id}/clips`}),`, beside `,(0,m.jsx)(`code`,{children:`data`}),` — it is`,` `,(0,m.jsx)(`code`,{children:`0`}),` while the creation pipeline still owns the library, every accepted declaration bumps it, and the `,(0,m.jsx)(`code`,{children:`202`}),` body carries the new value.`]}
871),(0,m.jsxs)(`p`,{children:[`For an uploaded take, upload or remotely register a video asset first, then use`,` `,(0,m.jsx)(`code`,{children:`source: { assetId }`}),`. Uploaded clips are pose-validated against the avatar's rest-pose anchor before they can serve; the first and last frame must both return to that pose. The dashboard's `,(0,m.jsx)(`strong`,{children:`Clips`}),` editor performs the same asset registration and full-library declaration without exposing a developer API key to the browser.`]}),(0,m.jsx)(`h2`,{children:`Her resting loop`}),(0,m.jsxs)(`p`,{children:[`The source loop is what `,(0,m.jsx)(`code`,{children:`motionPrompt`}),` described at creation. Without an authored idle group it forms a one-member idle pool. With authored idle clips, the source participates as an ordinary member of the same weighted pool. It has no separate rest draw or repeat privilege. Changing the source loop does not rewrite a library entry that references the previous source asset.`]}),(0,m.jsx)(h,{id:`editing-loop`,tabs:[{label:`TypeScript`,code:`const { servingUrl } = await rta.setLoop(avatarId, {
872  motionPrompt: "tilts her head, a small amused smile, settles back to centre",
873});
874// servingUrl = the loop she is playing RIGHT NOW, for the whole render
875
876await rta.waitForLoop(avatarId);   // throws if the render gave up`},{label:`Python`,code:`resp = client.put(f"/avatars/{avatar_id}/loop", json={
877    "motionPrompt": "tilts her head, a small amused smile, settles back to centre",
878})
879resp.raise_for_status()   # 409 loop_pending, 422 loop_prompt_rejected / loop_not_generatable
880accepted = resp.json()
881# accepted["servingUrl"] = the loop she plays for the whole render (None if she had none yet)
882# Poll GET /avatars/{id} until idleVideoStatus is "ready" (or "failed").`}]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`Your clip library is untouched.`}),` Clips render against the portrait, not against the loop, so re-describing the loop re-queues nothing and does not move the library's `,(0,m.jsx)(`code`,{children:`revision`}),`. Describe a `,(0,m.jsx)(`em`,{children:`closed arc`}),` — it has to end where it began, or the loop snaps every time it wraps.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`Keep it small.`}),` The loop is her resting state and a separate model animates her mouth, so every description is held to a nearly-still envelope: blinks, a soft closed-lip smile, a shift in weight, a few degrees of head drift. Ask for a laugh, a head turn, or an open mouth and the render comes back inside the envelope anyway — accepted, charged, and looking very much like the loop you already had. That is the one failure here with no error code attached to it, so aim inside the envelope rather than at the edge of it.`]}),(0,m.jsx)(`h2`,{children:`Waiting, and how each one ends`}),(0,m.jsxs)(`p`,{children:[`Both return on `,(0,m.jsx)(`strong`,{children:`acceptance`}),`, not completion, so both need a wait — and the intuitive version of each is wrong:`]}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`code`,{children:`waitForClips`}),` settles when nothing is still `,(0,m.jsx)(`em`,{children:`rendering`}),`. Waiting for every clip to reach `,(0,m.jsx)(`code`,{children:`ready`}),` hangs forever: a clip rejected by pose validation settles `,(0,m.jsx)(`code`,{children:`failed`}),`, which is terminal.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`code`,{children:`waitForLoop`}),` settles on `,(0,m.jsx)(`code`,{children:`idleVideoStatus`}),` and throws if the render gave up. A failed re-direct leaves her `,(0,m.jsx)(`code`,{children:`ready`}),` — she is still serving the old loop — and `,(0,m.jsx)(`code`,{children:`idleVideoStatus`}),` is the only status field guaranteed to move; `,(0,m.jsx)(`code`,{children:`error`}),` may or may not carry the reason. Poll`,` `,(0,m.jsx)(`code`,{children:`idleVideoStatus`}),`, never `,(0,m.jsx)(`code`,{children:`status`}),` or `,(0,m.jsx)(`code`,{children:`error`}),`: a loop watching `,(0,m.jsx)(`code`,{children:`status`}),` waits for a change that never comes.`]})]}),(0,m.jsx)(`h2`,{children:`Refusals worth telling apart`}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Code`}),(0,m.jsx)(`th`,{children:`Means`}),(0,m.jsx)(`th`,{children:`Do`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`403 clip_library_not_enabled`})}),(0,m.jsx)(`td`,{children:`a per-tenant rollout gate`}),(0,m.jsx)(`td`,{children:`ask us; nothing about the body helps`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`409 revision_conflict`})}),(0,m.jsx)(`td`,{children:`someone declared first`}),(0,m.jsx)(`td`,{children:`re-read, re-decide, re-declare`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`409 loop_pending`})}),(0,m.jsx)(`td`,{children:`a re-direct is already in flight`}),(0,m.jsx)(`td`,{children:`wait, then retry`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`422 loop_not_generatable`})}),(0,m.jsx)(`td`,{children:`no portrait to re-animate`}),(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`strong`,{children:`terminal`}),` — do not retry`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`422 clip_declaration_rejected`})}),(0,m.jsx)(`td`,{children:`a motion prompt or action description was refused (clips route)`}),(0,m.jsx)(`td`,{children:`rewrite it`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`422 loop_prompt_rejected`})}),(0,m.jsx)(`td`,{children:`the loop description was refused (loop route)`}),(0,m.jsx)(`td`,{children:`rewrite it`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`409 avatar_not_ready`})}),(0,m.jsxs)(`td`,{children:[`she is not `,(0,m.jsx)(`code`,{children:`ready`}),` (either route)`]}),(0,m.jsxs)(`td`,{children:[`poll `,(0,m.jsx)(`code`,{children:`status`}),` until `,(0,m.jsx)(`code`,{children:`ready`}),`, then retry; the loop route also refuses a creation that settled `,(0,m.jsx)(`code`,{children:`failed`})]})]})]})]}),(0,m.jsxs)(`p`,{children:[`A `,(0,m.jsx)(`code`,{children:`503`}),` with `,(0,m.jsx)(`code`,{children:`retryable: true`}),` (`,(0,m.jsx)(`code`,{children:`clip_screen_unavailable`}),`, `,(0,m.jsx)(`code`,{children:`loop_workflow_unavailable`}),`,`,` `,(0,m.jsx)(`code`,{children:`loop_workflow_queue_failed`}),`) is transient — back off and send the same body again. Each refusal above is JSON `,(0,m.jsx)(`code`,{children:`{ error, status, code }`}),` — a malformed body (`,(0,m.jsx)(`code`,{children:`422`}),`) or an unknown avatar (`,(0,m.jsx)(`code`,{children:`404`}),`) carries no`,` `,(0,m.jsx)(`code`,{children:`code`}),` — so switch on `,(0,m.jsx)(`code`,{children:`code`}),`, not on the status alone.`]}),(0,m.jsxs)(`p`,{children:[`A rejected `,(0,m.jsx)(`em`,{children:`upload`}),` is not an error response at all: the declaration is accepted and that one clip settles `,(0,m.jsx)(`code`,{children:`failed`}),` with the verdict in`,` `,(0,m.jsx)(`code`,{children:`poseCheck`}),`. The rest of the library is untouched.`]}),(0,m.jsx)(`h2`,{children:`What you cannot change`}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`Her portrait.`}),` It is the rest pose every clip is rendered to start and end on — the shared frame that makes a state switch a splice instead of a jump — so replacing it would invali
882date the whole library at once. Create a new avatar instead. Everything else about her is a sentence away.`]})]})},{slug:`tool-calling`,title:`Tool calling`,description:`The platform never executes your tools. Here is the boundary, the client tool plane, and the server loop that turns a call into a working agent with a face.`,group:`build`,order:3,updated:`2026-09-02`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`Your tools never run on the platform.`}),` There is no hosted executor — a tool executes in your page or on your server, with your credentials, under your authorization rules. That is a deliberate boundary, not a gap we forgot to fill.`]}),(0,m.jsxs)(`p`,{children:[`The reason is trust. A hosted tool runner would need your credentials, your authorization rules, and a say in when a side effect fires. Your backend already has all three and already knows which user is on the call. So execution stays where the trust already is, and you get two lanes to wire it in: `,(0,m.jsx)(`strong`,{children:`client tools`}),`, where she calls a function you declared in your page mid-conversation, and`,` `,(0,m.jsx)(`strong`,{children:`server steering`}),`, where your backend runs the agent loop and hands her the result to say.`]}),(0,m.jsx)(`h2`,{children:`Client tools: functions she can call in your page`}),(0,m.jsxs)(`p`,{children:[`Opt in at mint with `,(0,m.jsx)(`code`,{children:`clientTools: true`}),` (wire`,` `,(0,m.jsx)(`code`,{children:`capabilities: ["client_tools"]`}),`) — part of the call policy, so it is your server's decision like everything else — then declare the tools in the page after the room connects. The manifest is registered over RPC; nothing about a tool ever appears in the mint request, and the tool name is the record key.`]}),(0,m.jsx)(h,{id:`tools-client-plane`,tabs:[{label:`TypeScript`,code:`// Server — the connect route opts the call in. Policy, like everything else.
883session: async () => ({ instructions, clientTools: true }),
884
885// Page — after room.connect(). The name she calls is the record key.
886import { attachAvatarTools, type AvatarTool } from "realtime-avatar/tools";
887
888const check_order: AvatarTool<{ order_id: string }> = {
889  description: "Look up the status of an order by id",
890  parameters: {
891    type: "object",
892    properties: { order_id: { type: "string" } },
893    required: ["order_id"],
894  },
895  execute: async ({ order_id }) =>
896    await fetch("/api/orders/" + order_id).then((r) => r.json()),
897};
898
899const { accepted, rejected } = await attachAvatarTools(room, { check_order });`}]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`A tool has 2.5 seconds.`}),` That is a conversational floor, not a tunable — past a couple of seconds of dead air the call stops feeling live. The platform abandons the call after the deadline and tells her it failed, and the abort is `,(0,m.jsx)(`em`,{children:`cooperative`}),`: every `,(0,m.jsx)(`code`,{children:`execute`}),` receives an`,` `,(0,m.jsx)(`code`,{children:`AbortSignal`}),`, but a handler that ignores it still runs to completion and can commit a side effect — only its `,(0,m.jsx)(`em`,{children:`result`}),` is discarded. Make anything slow idempotent, or check `,(0,m.jsx)(`code`,{children:`signal.aborted`}),` before you write. A tool that calls a model does not fit: return an acknowledgement inside the deadline and deliver the real answer on screen when it lands. And read the`,` `,(0,m.jsx)(`code`,{children:`{ accepted, rejected }`}),` return — a rejected schema is a tool she simply does not have.`]}),(0,m.jsx)(`h2`,{children:`Server steering: the two primitives`}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Call`}),(0,m.jsx)(`th`,{children:`What happens`}),(0,m.jsx)(`th`,{children:`Use for`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`sendTurn(text, { instructions })`})}),(0,m.jsxs)(`td`,{children:[`Interrupts stale speech and generates a reply to `,(0,m.jsx)(`code`,{children:`text`}),`, with`,` `,(0,m.jsx)(`code`,{children:`instructions`}),` applied to `,(0,m.jsx)(`em`,{children:`this turn only`}),` — passed as generation instructions, not concatenated into the user message.`]}
899),(0,m.jsx)(`td`,{children:`Injecting a tool result and letting the character deliver it in her own voice`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`sendClosingTurn(text)`})}),(0,m.jsxs)(`td`,{children:[`Speaks `,(0,m.jsx)(`code`,{children:`text`}),` `,(0,m.jsx)(`strong`,{children:`verbatim`}),` — never routed through the model — uninterruptibly, then ends the session.`]}),(0,m.jsx)(`td`,{children:`The one line that must be exact: a goodbye, a legal disclosure`})]})]})]}),(0,m.jsxs)(`p`,{children:[`Per-turn `,(0,m.jsx)(`code`,{children:`instructions`}),` is the tool-result channel. It steers wording, register, and content for one reply and then evaporates — it does not mutate the session prompt, so a tool result cannot leak into every later turn.`]}),(0,m.jsx)(`h2`,{children:`The loop`}),(0,m.jsx)(`p`,{children:`Run your agent where your data is: on your server. The call is the interface, not the brain.`}),(0,m.jsx)(h,{id:`tools-loop`,tabs:[{label:`TypeScript`,code:`// 1. You observe the user's turn. 2. Your agent decides a tool is needed
900// and runs it. 3. You hand the RESULT back and let her say it.
901const result = await tools.checkOrderStatus({ userId, orderId });
902
903await session.sendTurn(userText, {
904  instructions: [
905    "Answer using ONLY these facts:",
906    JSON.stringify(result),
907    "One or two spoken sentences. Do not read the JSON aloud.",
908  ].join("\\n"),
909});`},{label:`Python`,code:`import json
910from fastapi import Depends
911
912# The live turn is client-side, so Python's role is to DECIDE and to receive.
913# Your client calls your endpoint; you run the tool and return the steer text.
914@app.post("/api/turn")
915def turn(req: TurnRequest, user=Depends(current_user)):
916    result = check_order_status(user.id, req.order_id)
917    return {
918        "text": req.text,
919        "steer": (
920            "Answer using ONLY these facts: "
921            f"{json.dumps(result)}. "
922            "One or two spoken sentences. Do not read the JSON aloud."
923        ),
924    }`,note:(0,m.jsxs)(m.Fragment,{children:[`The client passes that straight into `,(0,m.jsx)(`code`,{children:`sendTurn(text, { instructions: steer })`}),`. Keeping the decision on your server is the point — the credentials and the authorization rules already live there.`]})}]}),(0,m.jsx)(`p`,{children:`For a deterministic string — a confirmation number, a price, a required disclosure — do not route it through the model at all. Use the verbatim path so what she says is exactly what you wrote.`}),(0,m.jsx)(`h3`,{children:`Latency`}),(0,m.jsx)(`p`,{children:`A tool call between the user finishing a sentence and the character starting to speak is dead air, and dead air on a video call is louder than on a chat. Two things help:`}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`Speak first, then resolve.`}),` Send a short acknowledging turn immediately ("let me look"), run the tool, then send the answer turn.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`Pre-fetch at mint.`}),` Anything you can know before the call starts belongs in the policy's `,(0,m.jsx)(`code`,{children:`context`}),` (wire`,` `,(0,m.jsx)(`code`,{children:`initial_context`}),`), not in a tool call three seconds in.`]})]}),(0,m.jsx)(`h2`,{children:`Getting the conversation back`}),(0,m.jsxs)(`p`,{children:[`Your agent needs to know what was said — and what she did. Register a transcript webhook at mint time (wire `,(0,m.jsx)(`code`,{children:`transcript_webhook`}),`: an https URL and a 16–200 character secret; tag the session with `,(0,m.jsx)(`code`,{children:`client_metadata`}),`) and you get a signed POST after the call ends with the full two-sided transcript (`,(0,m.jsx)(`code`,{children:`segments`}),`) and the tool calls the model acted on (`,(0,m.jsx)(`code`,{children:`tool_calls`}),`: name, arguments, result or error, and duration). It is sent after capacity is released, so it never delays the next caller. Delivery is at-least-once: the worker waits 5 seconds for your answer, retries once after ~2 seconds on a 5xx or a timeout with the identical body and signature, and takes any status under 500 as delivered. A session with no committed turns and no tool calls sends nothing:`]}),(0,m.jsx)(h,{id:`tools-transcript`,tabs:[{label:`TypeScript`,code:`// Registered on your connect endpoint — server-side only.
925session: async ({ avatarId }) => ({
926  instructions,
927  transcript: { url: "https://your.app/api/rta/transcript", secret: TRANSCRIPT_SECRET },
928  // Echoed verbatim, so the keys are yours — except user_id, which
929  // GET /usage/sessions?endUserId= filters on.
930  metadata: { user_id: user.id, characterId: avatarId, mode: "video" },
931})`},{label:`Python`,code:`import hashlib, hmac, json, time
932from fastapi import BackgroundTasks, HTTPException, Request
933
934# Mint half — in your connect route, next to avatar_id. Wire names, not the
935# SDK's transcript / metadata.
936body["transcript_webhook"] = {"url": "https://your.app/api/rta/transcript",  # https only
937                              "secret": TRANSCRIPT_SECRET}                   # 16..200 chars
938body["client_metadata"] = {"user_id": str(user.id), "characterId": character.avatar_id,
939                           "mode": req.mode}                                 # up to 16 string pairs
940
941# Receiver half.
942MAX_SKEW_SECONDS = 300
943
944
945def verify(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
946    if not timestamp.isdecimal() or abs(time.time() - int(timestamp)) > MAX_SKEW_SECONDS:
947        return False                                  # missing, malformed, or stale: replay-bound
948    signed = f"{timestamp}.{body.decode()}".encode()
949    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
950    return hmac.compare_digest(f"v1={expected}", signature)
951
952
953@app.post("/api/rta/transcript")
954async def transcript(request: Request, tasks: BackgroundTasks):
955    raw = await request.body()                        # RAW bytes, before any parsing
956    if not verify(raw, request.headers.get("x-rta-signature", ""),
957                  request.headers.get("x-rta-timestamp", ""), TRANSCRIPT_SECRET):
958        raise HTTPException(401)
959    payload = json.loads(raw)
960    if already_saved(payload["session_id"]):          # at-least-once: dedupe on session_id
961        return {"ok": True}
962    user_id = (payload.get("client_metadata") or {}).get("user_id")   # None when the mint set none
963    tasks.add_task(save_turns, user_id, payload["segments"])
964    tasks.add_task(save_tool_calls, user_id, payload.get("tool_calls", []))   # absent when none ran
965    return {"ok": True}                               # answer inside 5 s; save after`,note:(0,m.jsxs)(m.Fragment,{children:[`Sign over the `,(0,m.jsx)(`strong`,{children:`raw request bytes`}),`. Parsing to a dict and re-serializing changes the whitespace, and the signature will never match.`,` `,(0,m.jsx)(`code`,{children:`TRANSCRIPT_SECRET`}),` is the same secret you passed as`,` `,(0,m.jsx)(`code`,{children:`transcript_webhook.secret`}),` at mint. The URL must be https — the mint accepts http, but the worker silently drops it and nothing is ever delivered.`]})}]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`strong`,{children:`Registering it without the SDK.`}),` The TypeScript tab above turns the webhook on; the Python tab only verifies what arrives. If your server mints over raw HTTP, the field on the mint body is `,(0,m.jsx)(`code`,{children:`transcript_webhook`}),` — not`,` `,(0,m.jsx)(`code`,{children:`transcript`}),`, which is the SDK's name for it — and the mint is strict, so the wrong key is a 422 rather than a dropped field and no webhook is ever sent.`,` `,(0,m.jsx)(`code`,{children:`secret`}),` must be 16–200 characters: it is the HMAC key the handler below verifies against, so generate it rather than typing one.`]}),(0,m.jsxs)(`p`,{children:[`The POST carries `,(0,m.jsx)(`code`,{children:`x-rta-signature: v1=<hex hmac>`}),` and`,` `,(0,m.jsx)(`code`,{children:`x-rta-timestamp`}),`; the signed payload is`,` `,(0,m.jsx)(`code`,{children:`"<timestamp>.<body>"`}),` under HMAC-SHA256 with your shared secret. Verify it before trusting anything, and reject stale timestamps.`,` `,(0,m.jsx)(`code`,{children:`client_metadata`}),` comes back verbatim (`,(0,m.jsx)(`code`,{children:`{}`}),` when the mint sent none), so you can attribute the transcript without a session lookup, and`,` `,(0,m.jsx)(`code`,{children:`session_id`}),` is the key to dedupe a retry on.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`code`,{children:`tool_calls`}),` is present only when the session ran at least one tool. Each entry is`,` `,(0,m.jsxs)(`code`,{children:[`{`,`name, call_id, arguments, ts, ok, result | error, duration_ms`,`}`]}),`; an entry with no `,(0,m.jsx)(`code`,{children:`ok`}),` field means the call produced nothing the model saw. Store it next to the segments — a reply grounded in a lookup is only auditable if the lookup itself is in the history. Arguments and results are truncated to 2,000 characters each and at most 200 calls are recorded (`,(0,m.jsx)(`code`,{children:`tool_calls_truncated: true`}),` marks an overflow); this is a history, not a replay. One known gap: a tool round belonging to a turn the user barged in over is not reported by the runtime, so its calls are absent even though they ran.`]}
965),(0,m.jsx)(`p`,{children:`The platform stores no conversation text. If you do not register a webhook, nothing is buffered and nothing is sent — which is the right default for most apps, and the only acceptable one for some.`}),(0,m.jsx)(`h2`,{children:`Bringing your own model`}),(0,m.jsxs)(`p`,{children:[`If your agent loop is complex enough that per-turn steering feels like fighting it, you can go further: mint with `,(0,m.jsx)(`code`,{children:`listen: false`}),` (wire `,(0,m.jsx)(`code`,{children:`stt_mode: "off"`}),`), keep speech recognition on your side, and drive every turn through `,(0,m.jsx)(`code`,{children:`sendTurn`}),` with tight instructions. At that point the platform is a rendering endpoint for a character you fully control, and every tool decision is yours. That is a supported way to use it.`]}),(0,m.jsxs)(`p`,{children:[`You can also select the brain per session with `,(0,m.jsx)(`code`,{children:`llm`}),` (provider + model) when you want the hosted loop but a different model behind it.`]})]})},{slug:`api-reference`,title:`API reference`,description:`Every public endpoint on https://realtimeavatar.ai/api/v1 — what it takes, what it returns, which scope it needs, and how it fails.`,group:`reference`,order:0,updated:`2026-09-14`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsxs)(`p`,{children:[`Base URL `,(0,m.jsx)(`code`,{children:`https://realtimeavatar.ai/api/v1`}),`. Every request carries`,` `,(0,m.jsx)(`code`,{children:`Authorization: Bearer tic_…`}),`. A machine-readable spec for the realtime surface lives at `,(0,m.jsx)(`a`,{href:`/openapi.json`,children:`/openapi.json`}),`.`]}),(0,m.jsx)(h,{id:`ref-hello`,tabs:[{label:`curl`,code:`curl https://realtimeavatar.ai/api/v1/credits/balance \\
966  -H "Authorization: Bearer $REALTIME_AVATAR_API_KEY"`},{label:`TypeScript`,code:`import { RealtimeAvatar } from "realtime-avatar";
967
968const rta = new RealtimeAvatar({ apiKey: process.env.REALTIME_AVATAR_API_KEY! });
969await rta.creditBalance();`},{label:`Python`,code:`import os, httpx
970
971client = httpx.Client(
972    base_url="https://realtimeavatar.ai/api/v1",
973    headers={"Authorization": f"Bearer {os.environ['REALTIME_AVATAR_API_KEY']}"})
974
975client.get("/credits/balance").json()`}]}),(0,m.jsx)(`h2`,{children:`Realtime`}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Endpoint`}),(0,m.jsx)(`th`,{children:`Scope`}),(0,m.jsx)(`th`,{children:`Notes`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`POST /realtime/livekit/session`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`realtime:write`})}),(0,m.jsxs)(`td`,{children:[`Mint a session. Returns a grant, or `,(0,m.jsx)(`strong`,{children:`429`}),` with the queue contract when capacity is full. See `,(0,m.jsx)(`a`,{href:`/docs/sessions`,children:`Calls`}),`.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`POST /realtime/livekit/session/release`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`realtime:write`})}),(0,m.jsxs)(`td`,{children:[`Free the call's slot. Free of charge and idempotent, so calling it twice — or on a call that already ended — is safe.`,` `,(0,m.jsx)(`code`,{children:`{ session_id | queue_ticket_id, reason?, capacity_pool? }`}),` — one of the two ids is required. `,(0,m.jsx)(`code`,{children:`reason`}),` is a closed set:`,` `,(0,m.jsx)(`code`,{children:`page_hide · disconnected · superseded · unmount · manual · idle_timeout`}),`. Anything else is a `,(0,m.jsx)(`strong`,{children:`422`}),`, and a rejected release frees nothing — the slot stays held until it expires on its own, so send one of these or omit the field. The SDK sends it for you.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`GET /realtime/livekit/capacity`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`realtime:write`})}),(0,m.jsxs)(`td`,{children:[`Snapshot of `,(0,m.jsx)(`strong`,{children:`one`}),` pool — the orchestrator default, named by`,` `,(0,m.jsx)(`code`,{children:`capacity_pool`}),` in the body (`,(0,m.jsx)(`code`,{children:`primary`}),`). Mints fail over across pools and voice calls are not capacity-gated, so this does not predict whether a mint will be granted. Do not gate a call on it: mint, and treat the`,` `,(0,m.jsx)(`strong`,{children:`429`}),` as the queue.`]})]})]})]}),(0,m.jsx)(`h2`,{children:`Scenes (preview)`}),(0,m.jsxs)(`p`,{children:[`Enabled per workspace; other workspaces get `,(0,m.jsx)(`code`,{children:`403 scene_sessions_not_enabled`}),`. A scene is a clip you supply, prepared once and then played as a call's picture: mint with `,(0,m.jsx)(`code`,{children:`scene: { video_cache_id }`}),` and the avatar keeps her voice and persona. You can read back and mint only scenes your workspace prepared; any other id is `,(0,m.jsx)(`code`,{children:`404 scene_not_found`}),`. A mint also needs the scene to be `,(0,m.jsx)(`code`,{children:`ready`}),`, otherwise it answers `,(0,m.jsx)(`code`,{children:`409 scene_not_ready`}),` (retryable while it is still building). Prepared scenes are kept for a limited time: a mint of one that has expired answers `,(0,m.jsx)(`code`,{children:`404 scene_not_found`}),`, and preparing it again rebuilds it. A workspace holds at most 2 scene calls at once unless support raises it; another answers `,(0,m.jsx)(`code`,{children:`429 scene_concurrency_limit_reached`}),`, retryable once one ends.`]}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Endpoint`}),(0,m.jsx)(`th`,{children:`Scope`}),(0,m.jsx)(`th`,{children:`Notes`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`POST /scenes`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`realtime:write`})}),(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`code`,{children:`{ source: { url, md5?, bytes? }
975, continuation?, profile: "scene-v0" }`}),`. The id comes from the clip's content, not its URL, so the same bytes always get the same id, and a clip already prepared answers `,(0,m.jsx)(`code`,{children:`ready`}),` at once when you send its `,(0,m.jsx)(`code`,{children:`md5`}),` and `,(0,m.jsx)(`code`,{children:`bytes`}),`. Answers `,(0,m.jsx)(`code`,{children:`202`}),` while building and `,(0,m.jsx)(`code`,{children:`200`}),` once `,(0,m.jsx)(`code`,{children:`ready`}),` or `,(0,m.jsx)(`code`,{children:`failed`}),`. A `,(0,m.jsx)(`code`,{children:`failed`}),` state names why in `,(0,m.jsx)(`code`,{children:`error`}),`: `,(0,m.jsx)(`code`,{children:`source_unreachable`}),` (the clip could not be downloaded; retry a few times with backoff), `,(0,m.jsx)(`code`,{children:`source_unusable`}),` (use a different clip), `,(0,m.jsx)(`code`,{children:`source_mismatch`}),` (the bytes differ from your `,(0,m.jsx)(`code`,{children:`md5`}),` or `,(0,m.jsx)(`code`,{children:`bytes`}),`) or `,(0,m.jsx)(`code`,{children:`build_failed`}),`. Its `,(0,m.jsx)(`code`,{children:`scene_id`}),` is `,(0,m.jsx)(`code`,{children:`"unresolved"`}),` when the download failed and you sent no `,(0,m.jsx)(`code`,{children:`md5`}),` and `,(0,m.jsx)(`code`,{children:`bytes`}),`. A source the preparer refuses is `,(0,m.jsx)(`code`,{children:`422 scene_source_rejected`}),`; `,(0,m.jsx)(`code`,{children:`503 scene_builder_unavailable`}),` means preparation is not offered on this deployment.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`GET /scenes/{sceneId}`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`realtime:write`})}),(0,m.jsxs)(`td`,{children:[`The build state of a scene your workspace prepared. `,(0,m.jsx)(`code`,{children:`ready`}),` carries `,(0,m.jsx)(`code`,{children:`video_cache_id`}),` and the frame counts a mint may pass through.`]})]})]})]}),(0,m.jsx)(`h2`,{children:`Connection history`}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Endpoint`}),(0,m.jsx)(`th`,{children:`Scope`}),(0,m.jsx)(`th`,{children:`Notes`})]})}),(0,m.jsx)(`tbody`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`GET /sessions/{sessionId}/connection-history`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`usage:read`})}),(0,m.jsx)(`td`,{children:`Read received client connection observations for a session in your workspace. Empty data means no observations were received. Enable collection in server session policy; the SDK owns uploads.`})]})})]}),(0,m.jsx)(`h2`,{children:`Recordings`}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Endpoint`}),(0,m.jsx)(`th`,{children:`Scope`}),(0,m.jsx)(`th`,{children:`Notes`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`GET /recordings`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`recordings:read`})}),(0,m.jsxs)(`td`,{children:[`List your workspace's recording metadata. Optional `,(0,m.jsx)(`code`,{children:`sessionId`}),`, `,(0,m.jsx)(`code`,{children:`limit`}),`, and `,(0,m.jsx)(`code`,{children:`cursor`}),` filters.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`GET /recordings/{recordingId}`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`recordings:read`})}),(0,m.jsx)(`td`,{children:`Get the recording's current status and retention deadline. Media can finish processing after the call ends.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`GET /recordings/{recordingId}/access`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`recordings:read`})}),(0,m.jsx)(`td`,{children:`Get a private playback URL for a ready recording. The URL supports seeking, lasts at most one hour, and can be renewed within retention.`})]})]})]}),(0,m.jsx)(`h2`,{children:`Avatars`}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Endpoint`}),(0,m.jsx)(`th`,{children:`Scope`}),(0,m.jsx)(`th`,{children:`Notes`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`GET /avatars`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`avatars:read`})}),(0,m.jsxs)(`td`,{children:[`List the workspace's avatars (most recently updated first, capped at 100) as`,` `,(0,m.jsx)(`code`,{children:`{ data: [...] }`}),`.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`POST /avatars`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`avatars:write`})}),(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`code`,{children:`{ displayName, sourceAssetId?, motionPrompt?, voice?, defaultVoiceId?, llm?, settings?, metadata? }`}),` → 201 (`,(0,m.jsx)(`code`,{children:`preprocessing`}),` with a portrait attached; `,(0,m.jsx)(`code`,{children:`draft`}),` `,`without one). Creation is `,(0,m.jsx)(`strong`,{children:`image-only`}),`:`,` `,(0,m.jsx)(`code`,{children:`sourceAssetId`}),` names an uploaded portrait and the platform generates the looping idle video plus a multi-clip motion library in the background — poll until `,(0,m.jsx)(`code`,{children:`status`}),` is `,(0,m.jsx)(`code`,{children:`ready`}),`.`,` `,(0,m.jsx)(`code`,{children:`motionPrompt`}),` optionally art-directs the generated loop. Custom video upload is not supported.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`GET /avatars/{id}`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`avatars:read`})}),(0,m.jsxs)(`td`,{children:[`Includes `,(0,m.jsx)(`code`,{children:`status`}),`:`,` `,(0,m.jsx)(`code`,{children:`draft · preprocessing · ready · failed · disabled · deleted`}),` and`,` `,(0,m.jsx)(`code`,{children:`idleVideoStatus`}),`:`,` `,(0,m.jsx)(`code`,{children:`none · queued · generating · ready · failed`}),` (the background motion-generation progress). Mint only against `,(0,m.jsx)(`code`,{children:`ready`}),`; on`,` `,(0,m.jsx)(`code`,{children:`failed`}),`, `,(0,m.jsx)(`code`,{children:`error`}),` says why.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`PATCH /avatars/{id}`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`avatars:write`})}
975),(0,m.jsxs)(`td`,{children:[`Partial update; at least one field required. Profile fields allow 160 characters for the name, 4,000 for personality, 8,000 for background, 2,500 for art direction, and 1,000 for reply style. The merged stored profile, including custom metadata and the system template, must fit 16,000 characters of session instructions; exceeding that total returns `,(0,m.jsx)(`strong`,{children:`422`}),` before the update is saved. Limits use JavaScript string length (UTF-16 code units).`,` `,(0,m.jsx)(`code`,{children:`portraitUrl`}),` is in the schema but is `,(0,m.jsx)(`strong`,{children:`dashboard-only`}),` — over this API it is always`,` `,(0,m.jsx)(`strong`,{children:`400`}),`. To change the portrait, register the image with`,` `,(0,m.jsx)(`code`,{children:`POST /assets`}),` or `,(0,m.jsx)(`code`,{children:`POST /assets/remote`}),` and PATCH the returned id as `,(0,m.jsx)(`code`,{children:`sourceAssetId`}),`, which is its own swap lane: it consumes`,` `,(0,m.jsx)(`code`,{children:`anchorTimeMs`}),` as the new source's frame and is exclusive of every other field except `,(0,m.jsx)(`code`,{children:`sourceReview`}),`. `,(0,m.jsx)(`code`,{children:`sourceReview: { reason }`}),` `,`records that a person watched the new loop and accepts it: the automated source checks still run and are audited, but they no longer refuse the swap.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`DELETE /avatars/{id}`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`avatars:write`})}),(0,m.jsx)(`td`,{children:`Soft delete.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`GET /avatars/{id}/clips`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`avatars:read`})}),(0,m.jsxs)(`td`,{children:[`The envelope is `,(0,m.jsx)(`code`,{children:`{ data, avatarId, revision, anchorVersion, anchor, clipLibraryEligible, defaultSourceAssetId, behavior }`}),`. Each media/job row in `,(0,m.jsx)(`code`,{children:`data`}),` contains `,(0,m.jsx)(`code`,{children:`clipId, status, url, source, uploadAssetId, motionPrompt, durationSeconds, anchorVersion, poseCheck, error, createdAt, updatedAt`}),`; there is no row-level `,(0,m.jsx)(`code`,{children:`role`}),`, `,(0,m.jsx)(`code`,{children:`whenHint`}),`, or `,(0,m.jsx)(`code`,{children:`weight`}),`.`,` `,(0,m.jsx)(`code`,{children:`behavior`}),` contains optional `,(0,m.jsx)(`code`,{children:`idle`}),` and `,(0,m.jsx)(`code`,{children:`on`}),` rules. The job's `,(0,m.jsx)(`code`,{children:`status`}),` is separate from the serving `,(0,m.jsx)(`code`,{children:`url`}),`, which can remain non-null during a replacement or after its failure. Map uploaded rows back to `,(0,m.jsx)(`code`,{children:`source: { assetId: uploadAssetId }`}),` and generated rows to `,(0,m.jsx)(`code`,{children:`source: { motionPrompt, durationSeconds }`}),` for PUT (omit a null duration); older generated rows with null direction require a new prompt. Behavior moves from the GET envelope to PUT's top level. Sessions always inherit the avatar's serving clips and declared behavior. Session HTTP inputs reject `,(0,m.jsx)(`code`,{children:`clip_library`}),`,`,` `,(0,m.jsx)(`code`,{children:`clip_behavior`}),`, and `,(0,m.jsx)(`code`,{children:`choreography`}),`, including an empty clip library.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`PUT /avatars/{id}/loop`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`avatars:write`})}),(0,m.jsxs)(`td`,{children:[`Re-direct the `,(0,m.jsx)(`strong`,{children:`source loop`}),` from a new `,(0,m.jsx)(`code`,{children:`motionPrompt`}),`. This is the default resting video without an explicit idle pool; it is not privileged in idle selection. To reference a previous source asset separately, declare the `,(0,m.jsx)(`code`,{children:`defaultSourceAssetId`}),` returned by `,(0,m.jsx)(`code`,{children:`listClips(avatarId)`}),` `,`under a new clip record id using `,(0,m.jsx)(`code`,{children:`source: { assetId }`}),` and reference it in `,(0,m.jsx)(`code`,{children:`idle`}),`. That record keeps the asset it references even after a source-loop update. Answers`,` `,(0,m.jsx)(`code`,{children:`202`}),`; the render takes minutes, during which she stays`,` `,(0,m.jsx)(`code`,{children:`ready`}),` and keeps serving her previous loop (returned as`,` `,(0,m.jsx)(`code`,{children:`servingUrl`}),`), then the swap publishes at once. Your clip library is untouched. Billed as one generation. Requires a portrait to re-animate —`,` `,(0,m.jsx)(`code`,{children:`422 loop_not_generatable`}),` (terminal) when she has none: a draft created without `,(0,m.jsx)(`code`,{children:`sourceAssetId`}),`, or a grandfathered avatar built from a supplied video. Do not gate on `,(0,m.jsx)(`code`,{children:`sourceKind`}),`: it reads`,` `,(0,m.jsx)(`code`,{children:`video`}),` for every avatar once her generated loop attaches.`,` `,(0,m.jsx)(`code`,{children:`409 loop_pending`}),` means one is already in flight. The description is screened like a clip's (`,(0,m.jsx)(`code`,{children:`422 loop_prompt_rejected`}),`).`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`POST /avatars/{id}/clips/review`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`avatars:write`})}),(0,m.jsxs)(`td`,{children:[`Human quality acceptance for one uploaded clip. Send `,(0,m.jsx)(`code`,{children:`clipId`}),`, `,(0,m.jsx)(`code`,{children:`uploadAssetId`}),`,`,(0,m.jsx)(`code`,{children:`expectedRevision`}),`, `,(0,m.jsx)(`code`,{children:`expectedAnchorVersion`}),` and a `,(0,m.jsx)(`code`,{children:`reason`}),` (5–500 characters). Accepts a pose refusal or pending validation after review; keeps the original machine verdict, records the decision and prepares the serving cache. Returns `,(0,m.jsx)(`code`,{children:`204`}),`. Changed generations return `,(0,m.jsx)(`code`,{children:`409`}),`. Other processing failures and invalid media remain refused. Ten seconds 
975is a maximum, not a required duration. Requires the clip-library rollout.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`PUT /avatars/{id}/clips`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`avatars:write`})}),(0,m.jsxs)(`td`,{children:[`Full replacement: `,(0,m.jsx)(`code`,{children:`{ expectedRevision, clips, idle?, on?, actions? }`}),`. Required `,(0,m.jsx)(`code`,{children:`clips`}),` is a record keyed by clip id, each value`,` `,(0,m.jsx)(`code`,{children:`{ source }`}),`; source is exactly `,(0,m.jsx)(`code`,{children:`{ assetId }`}),` `,`or `,(0,m.jsx)(`code`,{children:`{ motionPrompt, durationSeconds? }`}),`. Generated`,` `,(0,m.jsx)(`code`,{children:`source.durationSeconds`}),` is an integer from 4–8 seconds, default 5. Optional `,(0,m.jsx)(`code`,{children:`idle`}),` is `,(0,m.jsx)(`code`,{children:`{ clips, weight? }`}),`;`,` `,(0,m.jsx)(`code`,{children:`on.userSpeechStarted`}),` contains `,(0,m.jsx)(`code`,{children:`{ clips }`}),`;`,` `,(0,m.jsx)(`code`,{children:`actions`}),` is a record keyed by action id whose values are`,` `,(0,m.jsx)(`code`,{children:`{ description, clips }`}),`. Every `,(0,m.jsx)(`code`,{children:`clips`}),` list is a nonempty array of declared clip ids. The same clip can serve several lists. Source changes queue media work; selection and action metadata changes do not re-render unchanged sources. Omitted records are retired and omitted behavior is removed. Required`,` `,(0,m.jsx)(`code`,{children:`expectedRevision`}),` is a nonnegative integer for compare-and-set: stale writes get `,(0,m.jsx)(`code`,{children:`409 revision_conflict`}),` with the current revision. Answers `,(0,m.jsx)(`code`,{children:`202`}),` with the GET envelope plus`,` `,(0,m.jsx)(`code`,{children:`plan: { kept, queued, retired }`}),`. Rolling out per tenant (`,(0,m.jsx)(`code`,{children:`403 clip_library_not_enabled`}),` until yours is on). Motion prompts and action descriptions are screened even on metadata-only writes: refusal is`,` `,(0,m.jsx)(`code`,{children:`422 clip_declaration_rejected`}),`; if the screen cannot run, the write fails closed with retryable `,(0,m.jsx)(`code`,{children:`503 clip_screen_unavailable`}),`.`]})]})]})]}),(0,m.jsx)(`h3`,{children:`Idle variations, events and actions`}),(0,m.jsxs)(`p`,{children:[`The source (`,(0,m.jsx)(`code`,{children:`primary`}),`) and authored idle clips form one weighted idle pool.`,(0,m.jsx)(`code`,{children:`idle.weights`}),` maps member IDs to finite weights from 0–100; each omitted member defaults to 1, zero disables it, and at least one must remain positive. Every member avoids immediately repeating when another is available; a sole available member loops completely. Do not combine this map with legacy`,(0,m.jsx)(`code`,{children:`idle.weight`}),`, which is converted into aggregate member weights. Lists are nonempty and cannot repeat a clip id. Unavailable members are filtered before selection and weights are renormalized. Ten idle variations are valid; there are no role-count or total-clip authoring caps beyond 32 per list. The 64 KB request-byte transport bound and worker admission remain independent constraints, not unlimited GPU capacity. If the source is disabled and every positive member is pending, mint returns retryable `,(0,m.jsx)(`code`,{children:`clip_library_not_ready`}),`.`]}),(0,m.jsxs)(`p`,{children:[(0,m.jsx)(`code`,{children:`userSpeechStarted`}),` plays one listening reaction, once per event, rather than holding a listening loop — a triggered reaction repeats freely.`,` `,(0,m.jsx)(`code`,{children:`actions`}),` exposes the requestable menu: the language model reads each`,` `,(0,m.jsx)(`code`,{children:`description`}),` to choose an appropriate action, then that action's`,` `,(0,m.jsx)(`code`,{children:`clips`}),` list chooses a take; declaring an action never plays it by itself. The same clip can serve idle, listening and any action. Speaker lip-sync runs on the current playback cursor during full-duplex speech, without restarting the body on a separate talking clip.`]}),(0,m.jsx)(`p`,{children:`Clip and action ids are 1–64 letters, digits, underscores, or hyphens; reserved keys are refused. Action descriptions are trimmed, nonempty, and at most 512 characters. Motion prompts are trimmed, nonempty, and at most 1,200 characters. Asset ids are trimmed, nonempty, and at most 160 characters. Strict objects reject unknown fields. Keep declaration writes server-owned; do not let caller-supplied prose bypass your application's policy or the platform's safety screening.`}),(0,m.jsx)(h,{id:`ref-clip-library`,tabs:[{label:`JSON`,code:`{
976  "expectedRevision": 0,
977  "clips": {
978    "sway": { "source": { "motionPrompt": "Sways gently, settles back to rest" } },
979    "nod": { "source": { "motionPrompt": "A gentle nod, then returns to rest", "durationSeconds": 5 } }
980  },
981  "idle": { "clips": ["sway", "nod"] },
982  "on": {
983    "userSpeechStarted": { "clips": ["nod"] }
984  },
985  "actions": {
986    "agree": { "description": "When agreeing with the user", "clips": ["nod"] }
987  }
988}`}]}),(0,m.jsx)(`p`,{children:`Replace the sample revision with the latest GET revision. This full replacement retires records not shown here; include all existing records and behavior you intend to keep.`}),(0,m.jsx)(`h2`,{children:`Assets`}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Endpoint`}),(0,m.jsx)(`th`,{children:`Scope`}),(0,m.jsx)(`th`,{children:`Notes`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`GET /assets`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`avatars:read`})}),(0,m.jsx)(`td`,{children:`List uploaded assets.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`POST /assets`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`avatars:write`})}),(0,m.jsxs)(`td`,{children:[`Multipart: `,(0,m.jsx)(`code`,{children:`file`}),`, optional `,(0,m.jsx)(`code`,{children:`kind`}),` (`,(0,m.jsx)(`code`,{children:`image | video | audio`}),`) → 201. Video assets are accepted as uploaded motion-clip sources; they do not reopen custom-video Avatar creation. A clip declaration pose-validates video and requires it to fit the 18 MB QC ceiling.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`POST /assets/remote`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`avatars:write`})}),(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`code`,{children:`{ kind, remoteUrl, originalFilename?, metadata? }`}),` — the platform streams the file into storage. Video assets may be referenced by`,` `,(0,m.jsx)(`code`,{children:`PUT /avatars/{id}/clips`}),`; prefer remote registration when the bytes already live in object storage.`]})]})]})]}),(0,m.jsx)(`h2`,{children:`Billing and keys`}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Endpoint`}),(0,m.jsx)(`th`,{children:`Scope`}),(0,m.jsx)(`th`,{children:`Notes`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`GET /credits/balance`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`credits:read`})}),(0,m.jsx)(`td`,{children:`Balance and currently reserved credits, in micros. Live sessions hold a reservation for their duration.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`GET /usage/sessions`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`usage:read`})}),(0,m.jsxs)(`td`,{children:[`Per-session billing detail — when each session ran, how long it was billable for, and what it cost. This is what reconciles an invoice, or re-bills your own users; the dashboard aggregate can do neither. The window defaults to the trailing 30 days and is `,(0,m.jsx)(`strong`,{children:`clamped`}),` to 90 — a wider range is served narrowed, not refused, so read back the returned `,(0,m.jsx)(`code`,{children:`from`}),`/`,(0,m.jsx)(`code`,{children:`to`}),`. Page with`,` `,(0,m.jsx)(`code`,{children:`nextCursor`}),`. `,(0,m.jsx)(`code`,{children:`?endUserId=`}),` narrows to one of your users when the call was tagged with `,(0,m.jsx)(`code`,{children:`client_metadata.user_id`}),`.`,` `,(0,m.jsxs)(`strong`,{children:[`Read `,(0,m.jsx)(`code`,{children:`status`}),` before you sum anything.`]}),` Only`,` `,(0,m.jsx)(`code`,{children:`released`}),` and `,(0,m.jsx)(`code`,{children:`failed`}),` are settled:`,` `,(0,m.jsx)(`code`,{children:`released`}),` is the normal end of a call and carries the charge,`,` `,(0,m.jsx)(`code`,{children:`failed`}),` is terminal and always `,(0,m.jsx)(`code`,{children:`billedCreditMicros: 0`}),`. The other two are not final — `,(0,m.jsx)(`code`,{children:`reserved`}),` is a slot that was held and never became a call, and `,(0,m.jsx)(`code`,{children:`started`}),` is still in flight. Both report`,` `,(0,m.jsx)(`code`,{children:`billedCreditMicros: 0`}),` `,(0,m.jsx)(`em`,{children:`provisionally`}),`: a`,` `,(0,m.jsx)(`code`,{children:`started`}
988),` row settles when the session is released, so the same session read again tomorrow can carry a different number. Reconcile on the settled rows and treat the rest as pending, not as free.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`POST /api-keys`})}),(0,m.jsx)(`td`,{children:(0,m.jsx)(`code`,{children:`api_keys:write`})}),(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`code`,{children:`{ name, scopes?, spendLimitCreditMicros?, expiresAt? }`}),`. Only `,(0,m.jsx)(`code`,{children:`name`}),` is required. All new keys use `,(0,m.jsx)(`code`,{children:`tic_<keyId>_<secret>`}),`. The deprecated `,(0,m.jsx)(`code`,{children:`environment`}),` field is accepted but ignored. `,(0,m.jsx)(`code`,{children:`scopes`}),` defaults to`,` `,(0,m.jsx)(`code`,{children:`realtime:write`}),`, `,(0,m.jsx)(`code`,{children:`credits:read`}),` and `,(0,m.jsx)(`code`,{children:`avatars:read`}),`, which starts calls and reads but writes no avatars. The creation response includes the full key. Workspace owners and admins can copy newly created keys again in`,` `,(0,m.jsx)(`a`,{href:`/platform/settings#api-keys`,children:`Settings → API keys`}),` while they are active and unexpired; the list stays masked with a `,(0,m.jsx)(`strong`,{children:`Copy`}),` action. Legacy keys stored as hashes only cannot be recovered if lost and need a replacement. Revoked or expired keys cannot be copied.`]})]})]})]}),(0,m.jsx)(`h2`,{children:`Errors`}),(0,m.jsxs)(`p`,{children:[`Switch on `,(0,m.jsx)(`code`,{children:`code`}),` and HTTP status, not the wording of `,(0,m.jsx)(`code`,{children:`error`}),`. Correlated failures include a server-generated `,(0,m.jsx)(`code`,{children:`requestId`}),`, also in`,` `,(0,m.jsx)(`code`,{children:`X-Request-ID`}),`; include it when contacting support. Session failures retain safe codes such as `,(0,m.jsx)(`code`,{children:`internal_error`}),` (500),`,` `,(0,m.jsx)(`code`,{children:`capacity_unavailable`}),` and `,(0,m.jsx)(`code`,{children:`session_dispatch_failed`}),` (503),`,` `,(0,m.jsx)(`code`,{children:`upstream_unreachable`}),` and `,(0,m.jsx)(`code`,{children:`invalid_upstream_response`}),` (502),`,` `,(0,m.jsx)(`code`,{children:`upstream_timeout`}),` (504), and `,(0,m.jsx)(`code`,{children:`request_aborted`}),` (408). Internal diagnostics require separate operational access. A capacity queue 429 keeps its existing body; its request ID is in the header only.`]}),(0,m.jsxs)(`table`,{children:[(0,m.jsx)(`thead`,{children:(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Status`}),(0,m.jsx)(`th`,{children:`Meaning`}),(0,m.jsx)(`th`,{children:`What to do`})]})}),(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`401`}),(0,m.jsx)(`td`,{children:`Missing, malformed, revoked, or expired key`}),(0,m.jsxs)(`td`,{children:[`Use a full, active, unexpired key. Owners and admins can copy eligible keys again in workspace Settings. Replace lost legacy keys, revoked keys, or expired keys. See `,(0,m.jsx)(`a`,{href:`/docs/authentication`,children:`Authentication`}),`.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`403`}),(0,m.jsxs)(`td`,{children:[`The key lacks the scope for this operation, or its workspace is not active (`,(0,m.jsx)(`code`,{children:`error`}),` reads `,(0,m.jsx)(`code`,{children:`Tenant is not active`}),`)`]}),(0,m.jsxs)(`td`,{children:[`Read `,(0,m.jsx)(`code`,{children:`error`}),`: mint a key with the scope (do not widen to`,` `,(0,m.jsx)(`code`,{children:`*`}),`), or have the workspace reactivated — there is no self-serve switch for that`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`402`}),(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`code`,{children:`insufficient_credits`}),` or `,(0,m.jsx)(`code`,{children:`spend_limit_exceeded`})]}),(0,m.jsx)(`td`,{children:`Top up, or raise the per-key spend limit. Surface this as a paywall, not as a connection failure.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`409`}),(0,m.jsxs)(`td`,{children:[`A conflict with state that already exists. Three kinds reach this API: no workspace on the account yet; an `,(0,m.jsx)(`strong`,{children:`idempotency`}),` conflict (a key still in flight, or reused with a different body); and an `,(0,m.jsx)(`strong`,{children:`avatar state`}),` conflict — it is disabled, already has a motion video, has no portrait to animate, or a change is still settling.`]}
988),(0,m.jsxs)(`td`,{children:[`Read `,(0,m.jsx)(`code`,{children:`error`}),` — the three kinds want different things. A workspace is a one-time setup in the dashboard; an idempotency conflict wants a fresh`,` `,(0,m.jsx)(`code`,{children:`Idempotency-Key`}),` (never the same one); an in-flight change wants a plain retry once it settles.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`404`}),(0,m.jsxs)(`td`,{children:[`No such avatar, voice, or key — `,(0,m.jsx)(`em`,{children:`for this tenant`}),`. The message often reads "does not belong to this tenant", which sounds like a permission problem and is usually a wrong or already-deleted id.`]}),(0,m.jsx)(`td`,{children:`Do not route this to your access-denied branch — 403 is the permission answer. Check the id, and remember a soft-deleted avatar is gone from every read.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`413`}),(0,m.jsx)(`td`,{children:`The JSON body is over this route's cap`}),(0,m.jsx)(`td`,{children:`See the cap sizes under Conventions. This is almost always inlined media — send it as an asset and reference the id.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`422`}),(0,m.jsx)(`td`,{children:`Schema rejection — an unknown, mis-cased, or invalid field`}),(0,m.jsx)(`td`,{children:`The wire schemas are strict. Check camelCase vs snake_case at the layer you are writing.`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`429`}),(0,m.jsx)(`td`,{children:`Plan concurrency ceiling, capacity full (session mint), or rate limited`}),(0,m.jsxs)(`td`,{children:[`A mint has `,(0,m.jsx)(`strong`,{children:`two`}),` 429s, and they want opposite reactions. With`,` `,(0,m.jsx)(`code`,{children:`code: "concurrency_limit_reached"`}),` the plan’s concurrent-session limit is reached. `,(0,m.jsx)(`strong`,{children:`Starting and connecting sessions also count, even when no conversation is active.`}),` The top-level fields`,` `,(0,m.jsx)(`code`,{children:`maxConcurrentSessions`}),` and `,(0,m.jsx)(`code`,{children:`liveSessions`}),` report the limit and occupied reservations; `,(0,m.jsx)(`code`,{children:`activeSessions`}),`,`,` `,(0,m.jsx)(`code`,{children:`connectingSessions`}),`, and `,(0,m.jsx)(`code`,{children:`pendingSessions`}),` break down the count at refusal time. End a session or wait for pending starts to finish or be released before retrying. Avoid parallel duplicate starts, and release sessions on cancellation or unmount. Upgrade only when more simultaneous sessions are needed. Show the error to the caller and retain `,(0,m.jsx)(`code`,{children:`requestId`}),` for support. This refusal is `,(0,m.jsx)(`strong`,{children:`not a queue position`}),`. Without a `,(0,m.jsx)(`code`,{children:`code`}),`, look at the body before deciding. A`,` `,(0,m.jsx)(`strong`,{children:`queue`}),` answer carries `,(0,m.jsx)(`code`,{children:`queue_size`}),` and`,` `,(0,m.jsx)(`code`,{children:`recommended_retry_ms`}),`: the SDK returns a queued result (`,(0,m.jsx)(`code`,{children:`isQueued`}),`) with `,(0,m.jsx)(`code`,{children:`position`}),` and `,(0,m.jsx)(`code`,{children:`retryAfterMs`}),` — render the position and retry. Deliberately not auto-retried: a blind retry would burn the backoff to receive the same answer. A body with`,` `,(0,m.jsx)(`em`,{children:`neither a code nor those fields`}),` is the`,` `,(0,m.jsx)(`strong`,{children:`per-key rate limit`}),` (see Conventions) — back off, do not treat it as a queue position. Use SDK 0.12.0 or later to preserve concurrency refusals as errors. Older SDKs, including 0.11.1 and 0.11.5, map every 429 to a queued result and can hide the refusal. A null queue position alone does not distinguish a refusal from a real queue.`]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`502`}),(0,m.jsx)(`td`,{children:`An upstream generation or render failed`}),(0,m.jsx)(`td`,{children:`Retry; if it persists it is ours, not yours`})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`td`,{children:`503`}),(0,m.jsxs)(`td`,{children:[`A dependency this route needs is unavailable — a render queue that would not accept the job, a staging pipeline that is down, or (`,(0,m.jsx)(`code`,{children:`clip_screen_unavailable`}),`) the screen that vets clip descriptions. Never your request's fault.`]}),(0,m.jsxs)(`td`,{children:[`Retry with backoff. Nothing was written, so a retry is safe and needs no`,` `,(0,m.jsx)(`code`,{children:`Idempotency-Key`}),` to be correct — though sending one costs nothing.`]})]})]})]}),(0,m.jsx)(`h2`,{children:`Conventions`}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[`All `,(0,m.jsx)(`code`,{children:`/api/v1`}),` responses are `,(0,m.jsx)(`code`,{children:`Cache-Control: no-store`}),`.`]}),(0,m.jsxs)(`li`,{children:[`JSON request bodies are size-capped per route — 4 KiB on the session release and clip review, 64 KiB on session creation and the clip-library `,(0,m.jsx)(`code`,{children:`PUT`}),`, 128 KiB on avatar writes (create and update), and 8–32 KiB on other JSON routes. Field limits still apply within each byte budget. Over the body cap is a`,` `,(0,m.jsx)(`strong`,{children:`413`}),`. Send media by URL, not inline.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsxs)(`strong`,{children:[(0,m.jsx)(`code`,{children:`Idempotency-Key`}),` is honoured on the two writes that cost money to repeat`]}),` `,`— `,(0,m.jsx)(`code`,{children:`PUT /avatars/{id}/clips`}),` and`,` `,(0,m.jsx)(`code`,{children:`PUT /avatars/{id}/loop`}),`. Replay returns the original response rather than doing the work twice. It matters most on the loop, where a repeated re-direct is a `,(0,m.jsx)(`em`,{children:`second billed render`}),`. A repeated clip declaration without the key has a stale `,(0,m.jsx)(`code`,{children:`expectedRevision`}),` after acceptance and conflicts; re-reading and declaring unchanged sources does not re-render them. Reuse the same key with a `,(0,m.jsx)(`em`,{children:`different`}),` body and you get a `,(0,m.jsx)(`strong`,{children:`409`}),` — a retry means the same request, so generate a fresh key for a changed one. No other endpoint reads the header.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`120 requests per 60 
988seconds, per key.`}),` The throttle is applied when the key is verified, so it covers every `,(0,m.jsx)(`code`,{children:`/api/v1`}),` endpoint, and it counts per`,` `,(0,m.jsx)(`em`,{children:`key`}),` rather than per workspace — splitting a workload across keys raises the ceiling. Over it is a `,(0,m.jsx)(`strong`,{children:`429`}),` whose body carries no `,(0,m.jsx)(`code`,{children:`code`}),` and no queue fields, which is how you tell it from the other two. Worth sizing against: the creation flow asks you to poll `,(0,m.jsx)(`code`,{children:`GET /avatars/{id}`}),` until`,` `,(0,m.jsx)(`code`,{children:`status`}),` is `,(0,m.jsx)(`code`,{children:`ready`}),`, and a render takes minutes — poll on the order of seconds, not milliseconds.`]}),(0,m.jsxs)(`li`,{children:[`Ids are prefixed and stable: `,(0,m.jsx)(`code`,{children:`ava_`}),` avatars, `,(0,m.jsx)(`code`,{children:`tic_`}),` API keys.`]}),(0,m.jsxs)(`li`,{children:[`The SDK covers the integration path — calls, avatars, assets, usage, credits — with a surface that is deliberately `,(0,m.jsx)(`em`,{children:`narrower`}),` than this wire, though less narrow than it once was. Avatar update and delete `,(0,m.jsx)(`em`,{children:`are`}),` in it (`,(0,m.jsx)(`code`,{children:`updateAvatar`}),`, `,(0,m.jsx)(`code`,{children:`deleteAvatar`}),`), as are `,(0,m.jsx)(`code`,{children:`getAvatar`}),`,`,` `,(0,m.jsx)(`code`,{children:`listAvatars`}),`, `,(0,m.jsx)(`code`,{children:`listClips`}),`, `,(0,m.jsx)(`code`,{children:`setClipLibrary`}),`,`,` `,(0,m.jsx)(`code`,{children:`retimeAnchor`}),`, `,(0,m.jsx)(`code`,{children:`swapSource`}),` and`,` `,(0,m.jsx)(`code`,{children:`iterateSessions`}),`. Prefer it over hand-rolled fetches wherever it reaches. What it leaves out is `,(0,m.jsx)(`strong`,{children:`key minting`}),`, the `,(0,m.jsx)(`strong`,{children:`capacity`}),` `,`snapshot and the `,(0,m.jsx)(`strong`,{children:`asset listing`}),` — those three are a plain authenticated fetch against the endpoints above.`]})]})]})},{slug:`experimental`,title:`Experimental features`,description:`What is experimental right now, what that word promises, and how to turn each one on. Included with Studio and above.`,group:`reference`,order:1,updated:`2026-08-26`,body:(0,m.jsxs)(m.Fragment,{children:[(0,m.jsxs)(`p`,{children:[`Some capabilities ship before their shape is settled. Rather than hide them behind a flag you have to ask us for, they are on your account from `,(0,m.jsx)(`strong`,{children:`Studio`}),` up, opt-in per call, and listed here so you can see exactly what you are taking on.`]}),(0,m.jsx)(`h2`,{children:`What "experimental" promises`}),(0,m.jsx)(`p`,{children:`It is a statement about the request shape, not a warning about quality:`}),(0,m.jsxs)(`ul`,{children:[(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`The shape may change in a minor release`}),`, without the deprecation window a stable field gets. Pin an exact SDK version if that matters to you.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`It may be inert while it rolls out.`}),` A call that asks for an experimental capability always connects and always behaves like a normal call; the extra behaviour may simply not happen yet. Nothing errors and nothing is billed differently.`]}),(0,m.jsxs)(`li`,{children:[(0,m.jsx)(`strong`,{children:`It is off unless you ask.`}),` Omit the field and your call is byte-identical to one made before the feature existed, so nothing that ignores this page can be affected by anything on it.`]})]}),(0,m.jsxs)(`p`,{children:[`What the word never covers is a `,(0,m.jsx)(`strong`,{children:`trust boundary`}),`. Inputs that are server-owned stay server-owned on every call — experimental or not — and that is enforced at the mint, not by convention. If a capability lets a conversation change something, the bounds are still yours to write and still unreachable from a browser.`]}),(0,m.jsx)(`h2`,{children:`The register`}),(0,m.jsx)(`p`,{children:`Everything currently experimental, in full. When this list changes, this page changes with it — the plan bullet and this table read the same register.`}),[{id:`runtime-edit`,name:`Runtime edit`,summary:`The set behind your character is re-dressed while the call is running, following the conversation within a brief you write.`,optIn:`video.edits.live`,today:`Accepted and carried end to end: the call connects and runs normally, and your brief is applied to the session. The re-dress itself does not reach every capacity tier yet, so a call may run to the end on its opening set. Nothing fails and nothing is charged differently when it does.`,docHref:`/docs/video#letting-the-look-follow-the-conversation`,availableFrom
988:`studio`}].flatMap(e=>[(0,m.jsx)(`h3`,{children:e.name},`${e.id}-h`),(0,m.jsx)(`p`,{children:e.summary},`${e.id}-p`),(0,m.jsx)(`table`,{children:(0,m.jsxs)(`tbody`,{children:[(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Turn it on`}),(0,m.jsxs)(`td`,{children:[(0,m.jsx)(`code`,{children:e.optIn}),` — see `,(0,m.jsx)(`a`,{href:e.docHref,children:`the reference`})]})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Today`}),(0,m.jsx)(`td`,{children:e.today})]}),(0,m.jsxs)(`tr`,{children:[(0,m.jsx)(`th`,{children:`Included with`}),(0,m.jsx)(`td`,{children:`Studio and above`})]})]})},`${e.id}-t`)]),(0,m.jsx)(`h2`,{children:`Using one`}),(0,m.jsxs)(`p`,{children:[`Experimental capabilities are configured exactly like stable ones — in your`,` `,(0,m.jsx)(`code`,{children:`session`}),` policy, server-side, next to your key. There is no separate endpoint, header or flag to set:`]}),(0,m.jsx)(g,{code:`session: async ({ avatarId }) => ({
989  instructions: character.prompt,
990  video: {
991    edits: {
992      instruction: "her studio at golden hour",   // stable: the opening set
993      live: {                                     // experimental: runtime edit
994        rules: "only change the room and the light, never her face or clothes",
995        cooldownSeconds: 30,
996      },
997    },
998  },
999})`}),(0,m.jsx)(`p`,{children:`Because the opt-in is a field rather than an account setting, you can ship a call path that uses one and a call path that does not, in the same app, on the same avatar.`}),(0,m.jsx)(`h2`,{children:`Telling us it went wrong`}),(0,m.jsxs)(`p`,{children:[`These are the surfaces we most want to hear about, and the ones where a small report is worth the most. Include the `,(0,m.jsx)(`code`,{children:`session_id`}),` from the grant — it is the one handle that finds a specific call.`]})]})}];function P(){let e=new Map(d.map((e,t)=>[e.id,t]));return[...N].sort((t,n)=>(e.get(t.group)??0)-(e.get(n.group)??0)||t.order-n.order)}function F(e){return N.find(t=>t.slug===e)}function I(){let e=P();return d.map(t=>({id:t.id,label:t.label,pages:e.filter(e=>e.group===t.id)})).filter(e=>e.pages.length>0)}function L(e){let t=P(),n=t.findIndex(t=>t.slug===e);return n<0?{previous:null,next:null}:{previous:t[n-1]??null,next:t[n+1]??null}}export{f as i,I as n,L as r,F as t};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.