Quickstart
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.
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.
1. Get a key and install the SDK
Create a key in the dashboard — it looks like tic_live_… or tic_test_… and is shown once. It arrives with every scope enabled, so nothing 403s on your first call — untick the ones this key should not have.
The SDK is on npm — no registry configuration, no auth token:
npm install realtime-avatarSelf-contained: the contracts package is bundled into the build, so there is no private dependency to resolve. Pin an exact version for reproducible CI.
pip install httpxThere 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.
2. Build the app on an example avatar
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 settings.
The server half — the connect endpoint
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 who may start a call and what the character knows when they do:
// app/api/realtime-avatar/[...path]/route.ts
import { createRealtimeAvatarRoute } from "realtime-avatar/nextjs";
export const { GET, POST } = createRealtimeAvatarRoute({
apiKey: process.env.REALTIME_AVATAR_API_KEY!,
// Who may do this. "connect" is the only operation that costs money to start,
// so that is where the wallet check belongs; the reads stay cheap.
authorize: async ({ request, operation }) => {
const user = await currentUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
if (operation === "connect" && !(await hasCredits(user))) {
return Response.json({ code: "insufficient_credits" }, { status: 402 });
}
},
// What the character knows for THIS call. Whatever the browser sent for these
// concerns is discarded — this callback is the only source.
session: async ({ request, avatarId, mode }) => {
const user = await currentUser(request);
const character = await db.character(avatarId);
return {
instructions: character.prompt,
context: await db.recentTurns(user.id, avatarId),
maxSeconds: secondsTheBalanceAffords(user, mode),
};
},
});The catch-all segment matters — the route answers four paths under one prefix (POST /connect, POST /end, GET /avatars, GET /credits), so a single [...path] segment serves all of them.
// src/routes/api/realtime-avatar/$.ts
import { createFileRoute } from "@tanstack/react-router";
import { realtimeAvatarServerRoute } from "realtime-avatar/tanstack-start";
const handlers = realtimeAvatarServerRoute({
apiKey: process.env.REALTIME_AVATAR_API_KEY!,
authorize: async ({ request, operation }) => {
const user = await requireUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
if (operation === "connect" && !(await hasCredits(user))) {
return Response.json({ code: "insufficient_credits" }, { status: 402 });
}
},
session: async ({ request, avatarId, mode }) => {
const user = await requireUser(request);
const character = await loadCharacter(avatarId);
return {
instructions: buildPrompt(character),
context: await loadRecentTurns(user.id, avatarId),
maxSeconds: secondsTheBalanceAffords(user, mode),
};
},
});
export const Route = createFileRoute("/api/realtime-avatar/$")({
server: { handlers },
});The trailing $ 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 — apiKey: () => getEnv().KEY — since there is no process.env.
# Your backend decides; the browser renders. Same split, no SDK needed.
import os, httpx
from fastapi import Depends, FastAPI
from fastapi.responses import JSONResponse
from pydantic import BaseModel
BASE = "https://realtimeavatar.ai/api/v1"
client = httpx.Client(base_url=BASE, timeout=30.0, headers={
"Authorization": f"Bearer {os.environ['REALTIME_AVATAR_API_KEY']}"})
app = FastAPI()
class StartCallRequest(BaseModel):
avatar_id: str
mode: str = "avatar" # "avatar" | "voice"
queue_ticket_id: str | None = None # a client retrying after a 429 hands its ticket back
@app.post("/api/calls")
def start_call(req: StartCallRequest, user=Depends(current_user)):
if not has_credits(user):
return JSONResponse({"code": "insufficient_credits"}, 402)
character = load_character(req.avatar_id)
seconds = int(seconds_the_balance_affords(user, req.mode)) # wire: int, 1..1800
body = {
"avatar_id": character.avatar_id,
"mode": req.mode,
"stt_mode": "server",
# Decided HERE. The client sends none of this.
"instructions": build_prompt(character),
# ≤32 of exactly {"role": "system" | "user" | "assistant", "content": 1..4000 chars} —
# strict: any other key is a 422
"initial_context": [{"role": t.role, "content": t.text}
for t in recent_turns(user.id, character.avatar_id)][-32:],
"max_session_seconds": max(1, min(seconds, 1800)),
# Optional: a signed POST of the transcript when the call ends (receiver: Tool calling).
"transcript_webhook": {"url": "https://your.app/api/rta/transcript", # https only
"secret": os.environ["TRANSCRIPT_SECRET"]}, # 16..200 chars
"client_metadata": {"user_id": str(user.id), # ≤16 string pairs,
"character_id": character.avatar_id}, # echoed back to you
}
if req.queue_ticket_id:
body["queue_ticket_id"] = req.queue_ticket_id
r = client.post("/realtime/livekit/session", json=body)
data = r.json()
if r.status_code == 429 and "queue_ticket_id" in data:
# Every slot busy — the queue, not an error. Relay it VERBATIM: the busy body
# carries queue_ticket_id (the only handle that RELEASES the slot when the
# caller gives up) and recommended_retry_ms (when to come back). The client
# retries WITH the ticket; reshaping this strands it for its whole TTL.
return JSONResponse(data, 429)
if not r.is_success:
# 402 / 409 / 422 — and the 429s that are NOT the queue (plan concurrency
# ceiling, rate limit). Forward {error, code?}, but not at 429: an SDK
# client reads every 429 as the queue.
return JSONResponse(data, 503 if r.status_code == 429 else r.status_code)
return data # return this VERBATIMTwo rules when hand-rolling. First, casing is per endpoint, and every endpoint is strict — an unknown or mis-cased key is a rejected call. The two realtime routes shown here (/realtime/livekit/session and its /release) are snake_case; every REST resource endpoint — avatars, clips, keys, assets — is camelCase. Measured against the published spec: 2 of the 9 request bodies are snake_case at the top level and the other 7 are camelCase — except POST /avatars, which is camelCase outside and snake_case inside voice (auto_description, voice_id), as the sample above shows. And the SDK's policy names are not the wire names: context is initial_context, maxSeconds is max_session_seconds, transcript is transcript_webhook, metadata is client_metadata. Second, if your client uses this SDK the response must reach it 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 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 429: it is the queue only when the body carries queue_ticket_id, and a retry that does not present that ticket mints a fresh one every poll — its position never advances.
Everything in 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 inherited, so an omission fails closed rather than open.
maxSeconds is the one to get right on day one — it is what stops a call your balance cannot cover. See Authentication for the full list of what belongs on the server and why.
The client half — render the call
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 toavatarId — seed-rin-ashfall is one — and this call is live before you have created anything; step 3 swaps in your own:
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 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 seed-rin-ashfall’s. 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 poster and idleVideoUrl at them and the page is never empty: she is on screen while the call is still connecting.
import { AvatarCall, createProxyClient } from "realtime-avatar/react";
const client = createProxyClient({ proxyUrl: "/api/realtime-avatar" });
export function Call({ avatarId }: { avatarId: string }) {
return (
<AvatarCall
client={client}
avatarId={avatarId}
idleVideoUrl="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/idle-10s.mp4"
poster="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/portrait.png"
onEnded={({ reason }) => showEndScreen(reason)}
>
{(call) =>
call.status === "waiting" ? <Banner>In line: {call.queuePosition}</Banner> : null
}
</AvatarCall>
);
}Live media needs the DOM. In Next.js mark the file "use client" and import it with { ssr: false }; in TanStack Start gate it on a mounted flag or load it lazily.
import {
AvatarVideoSurface,
RealtimeAvatarLiveKitRoom,
SessionLifecycleRoomBridge,
createProxyClient,
registerGlobals,
useSessionLifecycle,
} from "realtime-avatar/react-native";
registerGlobals(); // once, at app startup
// Native has no page origin — the proxy URL must be ABSOLUTE.
const client = createProxyClient({
proxyUrl: "https://your-app.example.com/api/realtime-avatar",
});
export function Call({ avatarId }: { avatarId: string }) {
const lifecycle = useSessionLifecycle({ client, session: { avatarId } });
if (!lifecycle.grant) return null; // map lifecycle.phase to your UI
return (
<RealtimeAvatarLiveKitRoom
grant={lifecycle.grant}
onConnected={lifecycle.onConnected}
onDisconnected={lifecycle.onDisconnected}
onError={lifecycle.onConnectionError}
>
<SessionLifecycleRoomBridge lifecycle={lifecycle} />
<AvatarVideoSurface
idleVideoUrl={idleClipUrl}
poster={posterUrl}
// Native has no <video> — hand the idle clip to YOUR player, looped and muted.
renderIdleVideo={({ url, style }) => <IdleClip url={url} style={style} />}
/>
</RealtimeAvatarLiveKitRoom>
);
}Native is the lower-level surface today. <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 (expo-video, react-native-video). Live media does not run in Expo Go; you need a dev client or an EAS build.
status is five values — connecting · waiting · live · recovering · ended — and you write the copy for the ones you care about. The SDK ships no strings.
The children render prop hands you a call handle: say(), sayAndEnd(), keepAlive(), end(), and secondsRemaining. Want your own layout around the video rather than on top of it? useAvatarCall() returns { call, view } so you can place the view anywhere.
React Native runs the same session brain with native media — 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 /react-native, call registerGlobals() at startup, and pass an absolute proxyUrl (native has no page origin). The one-component <AvatarCall> wrapper is web-only for now.
3. Make the character yours
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 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:
import { RealtimeAvatar } from "realtime-avatar";
const rta = new RealtimeAvatar({ apiKey: process.env.REALTIME_AVATAR_API_KEY! });
// Upload the portrait, then create the avatar from it.
const asset = await rta.uploadAsset(portraitBlob, { kind: "image", filename: "rin.png" });
const avatar = await rta.createAvatar({
displayName: "Rin",
sourceKind: "image", // one frontal portrait: jpeg/png/webp, up to 8MB
sourceAssetId: asset.id,
voice: { auto_description: "Warm, clear, mid-pitch — natural and conversational." },
});
console.log(avatar.id); // ava_…
console.log(avatar.status); // "preprocessing" — generation has startedimport os, time, httpx
client = httpx.Client(
base_url="https://realtimeavatar.ai/api/v1", timeout=60.0,
headers={"Authorization": f"Bearer {os.environ['REALTIME_AVATAR_API_KEY']}"})
# Register the portrait, then create the avatar from it.
asset = client.post("/assets/remote", json={
"kind": "image",
"remoteUrl": "https://cdn.example.com/rin/portrait.png",
}).json()
avatar = client.post("/avatars", json={
"displayName": "Rin",
"sourceAssetId": asset["id"],
"voice": {"auto_description": "Warm, clear, mid-pitch — natural and conversational."},
}).json()
print(avatar["id"]) # ava_…
print(avatar["status"]) # "preprocessing" — generation has started
# ("failed" + avatar["error"] if it could not be queued)
# Poll until the loop and clips have rendered — a minute or two.
while avatar["status"] == "preprocessing":
time.sleep(15)
avatar = client.get(f"/avatars/{avatar['id']}").json()
print(avatar["status"]) # "ready" — mint calls; "failed" — avatar["error"] says whyCreation is asynchronous. The create call returns in milliseconds withstatus: "preprocessing"; the idle loop and the clip library render in the background over the next minute or two. PollgetAvatar(id)(GET /avatars/{id}on the wire) untilstatusisreadybefore minting a call. Before the loop attaches a mint is refused with a clear error — never a broken call.
Why a failed status happened is not on the SDK object. getAvatar() returns the five fields an integration branches on — id, displayName, sourceKind, status, defaultVoiceId — and nothing else. The wire's GET /avatars/{id} carries the diagnostics on top of those: error (the reason a failed avatar failed) and 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 sourceKind to tell a finished avatar from a fresh one: once the generated loop attaches it reads video, and sourceAssetId names the loop now serving rather than the portrait you uploaded (that is anchor.url on GET /avatars/{id}/clips). Gate on status — or idleVideoStatus — never on sourceKind == "image".
motionPrompt optionally art-directs the generated loop ("soft cafe light, gentle idle energy") — set it as Resting motion direction on the Avatars page or on the wire's POST /avatars; omit it for the house default. Once ready, GET /avatars/{id}/clips lists the generated motion library — render calls inherit it automatically. The Avatars page's Clips action provides the same full-library declaration flow for generated prompt clips when the workspace rollout is enabled.
voice: { auto_description } lets the platform pick the best-matching Fish Audio voice from your description; pass voice: { voice: { provider, voice_id } } to choose one explicitly — an explicit voice is nested under voice.voice (in Python, "voice": {"voice": {"provider": "fish", "voice_id": "…"}}); a top-level voice: { provider, voice_id } is a 422. Omit voice entirely to keep the avatar's current default.
You can also do all of this on the Avatars page with no code — see Creating an avatar. Either way you end up with an ava_… id, which is the only thing your client needs to know.
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 preprocessing until the idle loop and the clip library are rendered, and only then does it mint calls.
There is no other lane. sourceKind: "video" — registering a looping clip you host — is closed to new callers and answers 422. (That is the field you send on create; a ready image-built avatar reads 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.
Your editor may still suggestcreateAvatarFromVideo(). 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. UsecreateAvatarFromImage().
That is the whole surface
Two calls: createRealtimeAvatarRoute on the server, 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.
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.
What to read next
- Calls — the policy your server decides, the five states, and how to end a call without cutting her off mid-sentence.
- Tool calling — if your character needs to actually do things.