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

- Canonical: https://realtimeavatar.ai/docs/quickstart
- Updated: 2026-09-14

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

Open [workspace Settings → API keys](https://realtimeavatar.ai/platform/settings#api-keys), give your key a name, and select **Create API key**. Copy the full key and save it as `REALTIME_AVATAR_API_KEY` in your server’s environment. The default permissions work with this quickstart; advanced settings are optional.

The list stays masked. Workspace owners and admins can return to Settings and select **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 [Authentication](https://realtimeavatar.ai/docs/authentication) for details.

The SDK is on npm — no registry configuration, no auth token:

**TypeScript**

```bash
npm install --save-exact realtime-avatar@0.16.0
```

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.

**Python**

```bash
pip install httpx
```

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.

## 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](https://realtimeavatar.ai/platform/api-keys).

### 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:

**Next.js**

```tsx
// 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.

**TanStack Start**

```tsx
// 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`.

**Python**

```python
# 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..8000 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 VERBATIM
```

Two 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 10 request bodies are snake_case at the top level and the other 8 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](https://realtimeavatar.ai/docs/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 to`avatarId` — `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.

**React**

```tsx
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.

**React Native**

```tsx
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:

**TypeScript**

```ts
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 started
```

**Python**

```python
import 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 why
```

> **Creation is asynchronous.** The create call returns in milliseconds with `status: "preprocessing"`; the idle loop and the clip library render in the background over the next minute or two. Poll `getAvatar(id)` (`GET /avatars/{id}` on the wire) until `status` is `ready` before 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](https://realtimeavatar.ai/platform/avatars) 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` and one is cast the same way from the avatar's name and persona, so every avatar is created with a voice.

You can also do all of this on the [Avatars page](https://realtimeavatar.ai/platform/avatars) with no code — see [Creating an avatar](https://realtimeavatar.ai/docs/video). 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 suggest `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 `createAvatarFromImage()`.

## 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](https://realtimeavatar.ai/docs/sessions) — the policy your server decides, the five states, and how to end a call without cutting her off mid-sentence.
- [Tool calling](https://realtimeavatar.ai/docs/tool-calling) — if your character needs to actually do things.

---

Realtime Avatar — realtime AI avatar API & SDK. Docs: https://realtimeavatar.ai/docs · Agent guide: https://realtimeavatar.ai/llms.txt
