Docs

Build with Lovable, Bolt, v0 and Replit

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.

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

Each prompt uses the public example avatar seed-rin-ashfall, so the first call works on a fresh key before you have created a character. Create a key in Settings, API keys first. The free Sandbox plan includes 17 minutes of realtime, and the prompts cap every call at 120 seconds.

BuilderFastest startServer code runs inWhere the key goes
LovableThe Lovable prompt belowTanStack Start route (new projects) or Supabase Edge Function (Vite projects)Secrets: the Add secret form, or More, Cloud, Secrets
Bolt.newOpen the starter in BoltThe starter's Node route, or a Supabase Edge Function.env file, or Bolt Secrets
v0The Next.js prompt belowNext.js route handlerVars panel (Vercel environment variables)
ReplitImport the starter to ReplitThe starter's Node serverSecrets tool

The starter repo

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 AvatarCall. It carries the server half twice, sharing one policy file, so it runs wherever you put it:

  • supabase/functions/realtime-avatar/index.ts, a Supabase Edge Function, for Lovable Cloud and Bolt with Supabase.
  • server/app.ts, a Hono app that runs inside the Vite dev server (one process, one port) and behind npm start in production, for Bolt, Replit and any Node host.

The page picks the route on its own: the Supabase function when VITE_SUPABASE_URL is set, the same-origin /api/realtime-avatar otherwise. It is MIT licensed; copy any part of it.

Lovable

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.

  1. Open the prompt in Lovable (it fills the prompt box; you press send), or paste it into an existing project.
  2. When Lovable asks for REALTIME_AVATAR_API_KEY, paste your key into the secure secret field. To add it yourself: More, Cloud, Secrets, Add secret.
  3. Open the preview, press Call Rin, and allow the microphone.
Add a live AI avatar video call to this app with the Realtime Avatar SDK.

Packages: realtime-avatar (pin exactly 0.26.0), livekit-client, @livekit/components-react, zod.

Rules, do not break them:
1. 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.
2. 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.
3. The server decides the call: instructions, maxSeconds and which avatar ids are allowed. Ignore anything the browser sends for those.
4. Use the public example avatar "seed-rin-ashfall" and allow only that id for now.
Reference: https://realtimeavatar.ai/llms.txt and https://realtimeavatar.ai/docs/quickstart.md

How to build the server route depends on this project's stack. Check which one it is.

IF this project uses TanStack Start (new Lovable projects do):
Server: create the file src/routes/api/realtime-avatar/$.ts

    import { createFileRoute } from "@tanstack/react-router";
    import { realtimeAvatarServerRoute } from "realtime-avatar/tanstack-start";

    const handlers = realtimeAvatarServerRoute({
      // A factory, read per request. Read the secret the way this project reads other server secrets.
      apiKey: () => process.env.REALTIME_AVATAR_API_KEY ?? "",
      authorize: ({ operation }) =>
        operation === "connect" || operation === "end" ? undefined : new Response("Not found", { status: 404 }),
      session: ({ avatarId }) =>
        avatarId === "seed-rin-ashfall"
          ? { instructions: "You are Rin, a warm, curious guide. Short spoken sentences, one question at a time.", maxSeconds: 120 }
          : new Response("Avatar not allowed", { status: 403 }),
    });

    export const Route = createFileRoute("/api/realtime-avatar/$")({ server: { handlers } });

The trailing $ in the filename matters: it is the splat that lets one file answer /connect and /end.

Client: a page with a "Call Rin" button. Clicking it mounts the call component below (render it only in the browser, after mount, never during server rendering). When the call ends, show the button again.

    import { AvatarCall, createProxyClient } from "realtime-avatar/react";

    const client = createProxyClient({ proxyUrl: "/api/realtime-avatar" });

    <AvatarCall
      client={client}
      avatarId="seed-rin-ashfall"
      poster="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/portrait.png"
      idleVideoUrl="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/idle-10s.mp4"
      style={{ width: "100%", maxWidth: 420, aspectRatio: "3 / 4" }}
      onEnded={() => setInCall(false)}
    >
      {(call) => (
        <div style={{ position: "absolute", bottom: 16, left: 0, right: 0, textAlign: "center" }}>
          {call.status === "waiting" ? <p>In line: {call.queuePosition}</p> : null}
          <button onClick={call.end}>End call</button>
        </div>
      )}
    </AvatarCall>

The microphone only works on https or http://localhost.

OTHERWISE (a React + Vite project with Lovable Cloud):
Server: a Supabase Edge Function named "realtime-avatar".

supabase/functions/realtime-avatar/index.ts:

    import { realtimeAvatarHono } from "npm:realtime-avatar@0.26.0/hono";

    const cors = {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
      "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
    };

    const route = realtimeAvatarHono({
      apiKey: () => Deno.env.get("REALTIME_AVATAR_API_KEY") ?? "",
      authorize: ({ operation }) =>
        operation === "connect" || operation === "end" ? undefined : new Response("Not found", { status: 404 }),
      session: ({ avatarId }) =>
        avatarId === "seed-rin-ashfall"
          ? { instructions: "You are Rin, a warm, curious guide. Short spoken sentences, one question at a time.", maxSeconds: 120 }
          : new Response("Avatar not allowed", { status: 403 }),
    });

    Deno.serve(async (req) => {
      if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
      const res = await route({ req: { raw: req } });
      const headers = new Headers(res.headers);
      for (const [k, v] of Object.entries(cors)) headers.set(k, v);
      return new Response(res.body, { status: res.status, headers });
    });

In supabase/config.toml add:

    [functions.realtime-avatar]
    verify_jwt = false

(The gateway's JWT check rejects the browser's CORS preflight; the function does its own checks.)
Store REALTIME_AVATAR_API_KEY as an Edge Function secret.

Client: a page with a "Call Rin" button. Clicking it mounts the call component below (it only runs in the browser). When the call ends, show the button again.

    import { AvatarCall, createProxyClient } from "realtime-avatar/react";

    const base = import.meta.env.VITE_SUPABASE_URL + "/functions/v1/realtime-avatar";
    const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY ?? import.meta.env.VITE_SUPABASE_ANON_KEY;
    const client = createProxyClient({
      proxyUrl: base,
      // Supabase's gateway wants the project's public key on every request.
      fetch: (input, init) => {
        const headers = new Headers(init?.headers);
        headers.set("apikey", key);
        headers.set("authorization", "Bearer " + key);
        return fetch(input, { ...init, headers });
      },
    });

    <AvatarCall
      client={client}
      avatarId="seed-rin-ashfall"
      poster="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/portrait.png"
      idleVideoUrl="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/idle-10s.mp4"
      style={{ width: "100%", maxWidth: 420, aspectRatio: "3 / 4" }}
      onEnded={() => setInCall(false)}
    >
      {(call) => (
        <div style={{ position: "absolute", bottom: 16, left: 0, right: 0, textAlign: "center" }}>
          {call.status === "waiting" ? <p>In line: {call.queuePosition}</p> : null}
          <button onClick={call.end}>End call</button>
        </div>
      )}
    </AvatarCall>

The microphone only works on https or http://localhost.

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.

Bolt

Open the starter in Bolt. Bolt opens the repo in its in-browser environment, where npm run dev serves the page and the API route on one port.

  1. Create a file named .env with REALTIME_AVATAR_API_KEY=your_key and restart the dev server.
  2. Press Call Rin in the preview and allow the microphone.

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:

Add a live AI avatar video call to this app with the Realtime Avatar SDK.

Packages: realtime-avatar (pin exactly 0.26.0), livekit-client, @livekit/components-react, zod.

Rules, do not break them:
1. 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.
2. 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.
3. The server decides the call: instructions, maxSeconds and which avatar ids are allowed. Ignore anything the browser sends for those.
4. Use the public example avatar "seed-rin-ashfall" and allow only that id for now.
Reference: https://realtimeavatar.ai/llms.txt and https://realtimeavatar.ai/docs/quickstart.md

This project uses Supabase (connect it from Bolt's Supabase integration first).

Server: a Supabase Edge Function named "realtime-avatar".

supabase/functions/realtime-avatar/index.ts:

    import { realtimeAvatarHono } from "npm:realtime-avatar@0.26.0/hono";

    const cors = {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
      "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
    };

    const route = realtimeAvatarHono({
      apiKey: () => Deno.env.get("REALTIME_AVATAR_API_KEY") ?? "",
      authorize: ({ operation }) =>
        operation === "connect" || operation === "end" ? undefined : new Response("Not found", { status: 404 }),
      session: ({ avatarId }) =>
        avatarId === "seed-rin-ashfall"
          ? { instructions: "You are Rin, a warm, curious guide. Short spoken sentences, one question at a time.", maxSeconds: 120 }
          : new Response("Avatar not allowed", { status: 403 }),
    });

    Deno.serve(async (req) => {
      if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
      const res = await route({ req: { raw: req } });
      const headers = new Headers(res.headers);
      for (const [k, v] of Object.entries(cors)) headers.set(k, v);
      return new Response(res.body, { status: res.status, headers });
    });

In supabase/config.toml add:

    [functions.realtime-avatar]
    verify_jwt = false

(The gateway's JWT check rejects the browser's CORS preflight; the function does its own checks.)
Store REALTIME_AVATAR_API_KEY as an Edge Function secret.

Client: a page with a "Call Rin" button. Clicking it mounts the call component below (it only runs in the browser). When the call ends, show the button again.

    import { AvatarCall, createProxyClient } from "realtime-avatar/react";

    const base = import.meta.env.VITE_SUPABASE_URL + "/functions/v1/realtime-avatar";
    const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY ?? import.meta.env.VITE_SUPABASE_ANON_KEY;
    const client = createProxyClient({
      proxyUrl: base,
      // Supabase's gateway wants the project's public key on every request.
      fetch: (input, init) => {
        const headers = new Headers(init?.headers);
        headers.set("apikey", key);
        headers.set("authorization", "Bearer " + key);
        return fetch(input, { ...init, headers });
      },
    });

    <AvatarCall
      client={client}
      avatarId="seed-rin-ashfall"
      poster="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/portrait.png"
      idleVideoUrl="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/idle-10s.mp4"
      style={{ width: "100%", maxWidth: 420, aspectRatio: "3 / 4" }}
      onEnded={() => setInCall(false)}
    >
      {(call) => (
        <div style={{ position: "absolute", bottom: 16, left: 0, right: 0, textAlign: "center" }}>
          {call.status === "waiting" ? <p>In line: {call.queuePosition}</p> : null}
          <button onClick={call.end}>End call</button>
        </div>
      )}
    </AvatarCall>

The microphone only works on https or http://localhost.

v0

v0 generates Next.js App Router apps, where the SDK's Next.js route is one file.

  1. Paste the prompt into a new or existing v0 chat.
  2. Add REALTIME_AVATAR_API_KEY in the Vars panel (or Project settings, Environment Variables). The preview reads the Development value.
  3. Press Call Rin in the preview and allow the microphone.
Add a live AI avatar video call to this app with the Realtime Avatar SDK.

Packages: realtime-avatar (pin exactly 0.26.0), livekit-client, @livekit/components-react, zod.

Rules, do not break them:
1. 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.
2. 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.
3. The server decides the call: instructions, maxSeconds and which avatar ids are allowed. Ignore anything the browser sends for those.
4. Use the public example avatar "seed-rin-ashfall" and allow only that id for now.
Reference: https://realtimeavatar.ai/llms.txt and https://realtimeavatar.ai/docs/quickstart.md

This is a Next.js App Router project.

Server: create 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 ?? "",
      authorize: ({ operation }) =>
        operation === "connect" || operation === "end" ? undefined : new Response("Not found", { status: 404 }),
      session: ({ avatarId }) =>
        avatarId === "seed-rin-ashfall"
          ? { instructions: "You are Rin, a warm, curious guide. Short spoken sentences, one question at a time.", maxSeconds: 120 }
          : new Response("Avatar not allowed", { status: 403 }),
    });

The [...path] catch-all matters: one file answers /connect and /end.
Read REALTIME_AVATAR_API_KEY from the environment (set in the Vars panel). Never NEXT_PUBLIC_.

Client: a page with a "Call Rin" button. Clicking it mounts the call component below (put it in a "use client" component and load that with next/dynamic and ssr: false). When the call ends, show the button again.

    import { AvatarCall, createProxyClient } from "realtime-avatar/react";

    const client = createProxyClient({ proxyUrl: "/api/realtime-avatar" });

    <AvatarCall
      client={client}
      avatarId="seed-rin-ashfall"
      poster="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/portrait.png"
      idleVideoUrl="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/idle-10s.mp4"
      style={{ width: "100%", maxWidth: 420, aspectRatio: "3 / 4" }}
      onEnded={() => setInCall(false)}
    >
      {(call) => (
        <div style={{ position: "absolute", bottom: 16, left: 0, right: 0, textAlign: "center" }}>
          {call.status === "waiting" ? <p>In line: {call.queuePosition}</p> : null}
          <button onClick={call.end}>End call</button>
        </div>
      )}
    </AvatarCall>

The microphone only works on https or http://localhost.

Replit

Import the starter to Replit. Its .replit runs npm run dev on one port and deploys with npm run build and npm start.

  1. Open the Secrets tool and add REALTIME_AVATAR_API_KEY.
  2. Press Run, then Call Rin in the webview, and allow the microphone.
  3. To publish, deploy it, and check the deployment has the same secret.

To have Replit Agent build it into an existing app instead, give it this prompt:

Add a live AI avatar video call to this app with the Realtime Avatar SDK.

Packages: realtime-avatar (pin exactly 0.26.0), livekit-client, @livekit/components-react, zod.

Rules, do not break them:
1. 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.
2. 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.
3. The server decides the call: instructions, maxSeconds and which avatar ids are allowed. Ignore anything the browser sends for those.
4. Use the public example avatar "seed-rin-ashfall" and allow only that id for now.
Reference: https://realtimeavatar.ai/llms.txt and https://realtimeavatar.ai/docs/quickstart.md

Build it as a Vite + React + TypeScript app with a small Node server (Hono) on the same port.

Server: mount the SDK's route on Hono:

    import { Hono } from "hono";
    import { realtimeAvatarHono } from "realtime-avatar/hono";

    const app = new Hono();
    app.all(
      "/api/realtime-avatar/*",
      realtimeAvatarHono({
        apiKey: () => process.env.REALTIME_AVATAR_API_KEY ?? "",
        authorize: ({ operation }) =>
          operation === "connect" || operation === "end" ? undefined : new Response("Not found", { status: 404 }),
        session: ({ avatarId }) =>
          avatarId === "seed-rin-ashfall"
            ? { instructions: "You are Rin, a warm, curious guide. Short spoken sentences, one question at a time.", maxSeconds: 120 }
            : new Response("Avatar not allowed", { status: 403 }),
      }),
    );

Serve the built page from the same server, listen on 0.0.0.0, and read REALTIME_AVATAR_API_KEY from Replit Secrets.

Client: a page with a "Call Rin" button. Clicking it mounts the call component below (it only runs in the browser). When the call ends, show the button again.

    import { AvatarCall, createProxyClient } from "realtime-avatar/react";

    const client = createProxyClient({ proxyUrl: "/api/realtime-avatar" });

    <AvatarCall
      client={client}
      avatarId="seed-rin-ashfall"
      poster="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/portrait.png"
      idleVideoUrl="https://realtimeavatar.ai/api/assets/public/characters/rin-ashfall/idle-10s.mp4"
      style={{ width: "100%", maxWidth: 420, aspectRatio: "3 / 4" }}
      onEnded={() => setInCall(false)}
    >
      {(call) => (
        <div style={{ position: "absolute", bottom: 16, left: 0, right: 0, textAlign: "center" }}>
          {call.status === "waiting" ? <p>In line: {call.queuePosition}</p> : null}
          <button onClick={call.end}>End call</button>
        </div>
      )}
    </AvatarCall>

The microphone only works on https or http://localhost.

What every prompt makes the builder do

  • 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.
  • Grant relayed unchanged. The browser SDK validates the grant strictly, so a route that wraps it produces a call that never connects.
  • 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.

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 authorize hook (return a 401 when there is none), and set maxSeconds to what you will pay for per call. See Authentication for what authorize gates, and pricing for what a minute costs.

Your own character

Create an avatar from one portrait on the Avatars page, then ask the builder to replace seed-rin-ashfall with its ava_… id in both the allowlist and the page, and to drop or replace the poster and idleVideoUrl, which belong to the example avatar.