> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tavus.io/llms.txt
> Use this file to discover all available pages before exploring further.

# CVI App Quickstart

> Create a server-authenticated Tavus CVI conversation and embed it in a web app with the returned conversation_url.

<Info>
  Use this page when you are building an app that creates and embeds Tavus CVI
  conversations. If you only want to create a persona and start your first
  Tavus-hosted conversation, use [API Conversation Quickstart](/sections/conversational-video-interface/quickstart/cvi-quickstart).
</Info>

This guide gets a new web app from an empty project to a working embedded Tavus conversation. The happy path is:

1. Keep `TAVUS_API_KEY` on your server.
2. Create a conversation with `POST /v2/conversations`.
3. Embed the returned `conversation_url` in an iframe.
4. End the conversation when the user leaves.

For API details, see [Create Conversation](/api-reference/conversations/create-conversation), [End Conversation](/api-reference/conversations/end-conversation), [Get Personas](/api-reference/personas/get-personas), and [Get Replicas](/api-reference/phoenix-replica-model/get-replicas).

<Note>
  Live conversations can count toward billing and concurrency as soon as they are
  created. Use `test_mode: true` while wiring automated tests or checking your
  integration flow. In test mode, Tavus creates the conversation without the
  replica joining and returns it with `status: "ended"`.
</Note>

## Prerequisites

* A Tavus API key from the <a href="https://platform.tavus.io/api-keys" target="_blank">Developer Portal</a>.
* A usable `persona_id`, `replica_id`, or both. The next section shows how to choose.
* Node.js 20+ for the TypeScript examples below.

## Choose a persona and replica

For CVI, a **replica** is the face and voice in the call. A **persona** is the behavior, prompt, context, and conversational configuration.

Use this decision tree:

* If you have a persona with a `default_replica_id`, create the conversation with just `persona_id`.
* If you have a persona without a `default_replica_id`, create the conversation with both `persona_id` and `replica_id`.
* If you only want to test a face and voice without a custom persona, create the conversation with just `replica_id`.

First-time builders should start with stock Tavus resources. List stock personas and stock replicas with:

```bash theme={null}
curl --request GET \
  --url "https://tavusapi.com/v2/personas?persona_type=system" \
  --header "x-api-key: $TAVUS_API_KEY"

curl --request GET \
  --url "https://tavusapi.com/v2/replicas?replica_type=system&verbose=true" \
  --header "x-api-key: $TAVUS_API_KEY"
```

In the personas response, look for `persona_id` and `default_replica_id`. In the replicas response, look for `replica_id`, `replica_type`, and `model_name`.

<Info>
  `r90bbd427f71` is the stock Anna replica ID used throughout these docs, and
  `pcb7a34da5fe` is a stock Sales Development Rep persona ID. They are stock
  IDs, not placeholder strings, and are available for quickstart use.
</Info>

For more details, see [Stock Replicas](/sections/replica/stock-replicas), [Get Personas](/api-reference/personas/get-personas), [Get Replicas](/api-reference/phoenix-replica-model/get-replicas), and [Create Conversation](/api-reference/conversations/create-conversation).

## 1. Create the app

Create a Vite React app and install the small server dependencies used in this guide:

```bash theme={null}
npm create vite@latest tavus-first-call -- --template react-ts
cd tavus-first-call
npm install
npm install express cors dotenv
npm install -D tsx @types/express @types/cors
```

Add these scripts to `package.json`:

```json theme={null}
{
  "scripts": {
    "dev": "vite",
    "server": "tsx server.ts",
    "dev:all": "npm run server & npm run dev"
  }
}
```

## 2. Keep your API key server-only

Create `.env.example`:

```bash theme={null}
TAVUS_API_KEY=tvsk_your_api_key_here
```

Copy it to `.env` locally and fill in your real key:

```bash theme={null}
cp .env.example .env
```

<Warning>
  Never expose `TAVUS_API_KEY` in browser code, client-side environment
  variables, mobile apps, or public repositories. The frontend should call your
  backend route, and your backend should call Tavus.
</Warning>

## 3. Add backend routes

Create `server.ts` at the project root. The first route creates a conversation. The second route ends it when the user leaves or your test finishes.

```ts theme={null}
import "dotenv/config";
import cors from "cors";
import express from "express";

const app = express();
const port = 3001;
const tavusApiKey = process.env.TAVUS_API_KEY;

if (!tavusApiKey) {
  throw new Error("Missing TAVUS_API_KEY in .env");
}

app.use(cors({ origin: "http://localhost:5173" }));
app.use(express.json());

type CreateConversationRequest = {
  persona_id?: string;
  replica_id?: string;
  conversation_name?: string;
  test_mode?: boolean;
};

app.post("/api/conversations", async (req, res) => {
  const {
    persona_id,
    replica_id,
    conversation_name = "My first Tavus video chat",
    test_mode = false,
  } = req.body as CreateConversationRequest;

  if (!persona_id && !replica_id) {
    return res
      .status(400)
      .json({ error: "Provide persona_id, replica_id, or both" });
  }

  const tavusResponse = await fetch("https://tavusapi.com/v2/conversations", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-api-key": tavusApiKey,
    },
    body: JSON.stringify({
      ...(persona_id ? { persona_id } : {}),
      ...(replica_id ? { replica_id } : {}),
      conversation_name,
      test_mode,
    }),
  });

  const data = await tavusResponse.json();

  if (!tavusResponse.ok) {
    return res.status(tavusResponse.status).json(data);
  }

  return res.json(data);
});

app.post("/api/conversations/:conversationId/end", async (req, res) => {
  const { conversationId } = req.params;

  const tavusResponse = await fetch(
    `https://tavusapi.com/v2/conversations/${conversationId}/end`,
    {
      method: "POST",
      headers: {
        "x-api-key": tavusApiKey,
      },
    }
  );

  if (tavusResponse.status === 204) {
    return res.status(204).send();
  }

  const data = await tavusResponse.json().catch(() => ({}));

  if (!tavusResponse.ok) {
    return res.status(tavusResponse.status).json(data);
  }

  return res.json(data);
});

app.listen(port, () => {
  console.log(`Tavus backend listening on http://localhost:${port}`);
});
```

## 4. Embed the conversation URL

Replace `src/App.tsx` with this frontend. It calls your backend, receives the Tavus `conversation_url`, and embeds it in an iframe. This quickstart uses an iframe because it is the fastest path to a working CVI app. For Tavus-provided React components, including the complete `CVIProvider` + `Conversation` + server-helper example, see the [`@tavus/cvi-ui` component library](/sections/conversational-video-interface/component-library/overview). For Daily JS/React or LiveKit guidance, see [Embed CVI](/sections/integrations/embedding-cvi).

```tsx theme={null}
import { useState } from "react";

type ConversationResponse = {
  conversation_id: string;
  conversation_name?: string;
  conversation_url: string;
  status: "active" | "ended";
  callback_url?: string;
  created_at?: string;
  meeting_token?: string;
};

const API_BASE_URL = "http://localhost:3001";

export default function App() {
  const [personaId, setPersonaId] = useState("");
  const [replicaId, setReplicaId] = useState("");
  const [testMode, setTestMode] = useState(false);
  const [conversation, setConversation] = useState<ConversationResponse | null>(
    null
  );
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  async function startConversation() {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(`${API_BASE_URL}/api/conversations`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          ...(personaId ? { persona_id: personaId } : {}),
          ...(replicaId ? { replica_id: replicaId } : {}),
          test_mode: testMode,
        }),
      });

      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || "Failed to create conversation");
      }

      setConversation(data);
    } catch (error) {
      setError(error instanceof Error ? error.message : "Unknown error");
    } finally {
      setLoading(false);
    }
  }

  async function endConversation() {
    if (!conversation) return;

    await fetch(
      `${API_BASE_URL}/api/conversations/${conversation.conversation_id}/end`,
      { method: "POST" }
    );

    setConversation(null);
  }

  return (
    <main style={{ maxWidth: 960, margin: "40px auto", fontFamily: "system-ui" }}>
      <h1>My first Tavus video chat</h1>

      {!conversation ? (
        <section style={{ display: "grid", gap: 12 }}>
          <label>
            Persona ID, optional if you provide a replica ID
            <input
              value={personaId}
              onChange={(event) => setPersonaId(event.target.value)}
              placeholder="p... or use pcb7a34da5fe"
              style={{ display: "block", width: "100%" }}
            />
          </label>

          <label>
            Replica ID, optional if your persona has a default replica
            <input
              value={replicaId}
              onChange={(event) => setReplicaId(event.target.value)}
              placeholder="r... or use r90bbd427f71"
              style={{ display: "block", width: "100%" }}
            />
          </label>

          <label>
            <input
              type="checkbox"
              checked={testMode}
              onChange={(event) => setTestMode(event.target.checked)}
            />
            Create in test mode
          </label>

          <button
            onClick={startConversation}
            disabled={loading || (!personaId && !replicaId)}
          >
            {loading ? "Creating..." : "Start conversation"}
          </button>

          {error ? <p role="alert">{error}</p> : null}
        </section>
      ) : (
        <section style={{ display: "grid", gap: 12 }}>
          <button onClick={endConversation}>End conversation</button>
          <iframe
            title="Tavus conversation"
            src={conversation.conversation_url}
            allow="camera; microphone; fullscreen; display-capture; autoplay"
            style={{
              width: "100%",
              height: 640,
              border: "1px solid #ddd",
              borderRadius: 12,
            }}
          />
        </section>
      )}
    </main>
  );
}
```

Run both servers:

```bash theme={null}
npm run dev:all
```

Open `http://localhost:5173`, enter your `persona_id`, and click **Start conversation**.

<Info>
  The iframe must include browser permissions in the `allow` attribute. At
  minimum, include `camera` and `microphone`. `fullscreen`, `display-capture`,
  and `autoplay` are recommended for the default Tavus/Daily in-call experience.
</Info>

## Expected create response

`POST /v2/conversations` returns the join URL your app should embed:

```json theme={null}
{
  "conversation_id": "c123456",
  "conversation_name": "My first Tavus video chat",
  "conversation_url": "https://tavus.daily.co/c123456",
  "status": "active",
  "callback_url": "",
  "created_at": "2026-05-20T14:30:00.000000Z"
}
```

When `test_mode` is `true`, expect the same shape, but `status` is `ended` and the replica does not join.

## Cleanup

End live conversations when the user leaves, when a test completes, or when your app no longer needs the room:

```bash theme={null}
curl --request POST \
  --url https://tavusapi.com/v2/conversations/<conversation_id>/end \
  --header "x-api-key: $TAVUS_API_KEY"
```

The sample app calls the same endpoint through your backend route:

```ts theme={null}
await fetch(
  `http://localhost:3001/api/conversations/${conversationId}/end`,
  { method: "POST" }
);
```

## Errors and cleanup

For automated tests, scaffolding, and agent-generated validation, create conversations with `test_mode: true`. The replica does not join, the response returns `status: "ended"`, and the conversation does not affect billing or concurrency.

For live conversations, call [End Conversation](/api-reference/conversations/end-conversation) when the user leaves or your app no longer needs the room. Use [Delete Conversation](/api-reference/conversations/delete-conversation) only when you want destructive data removal, not routine call cleanup.

If conversation creation fails:

* `400` usually means the request body is invalid. Check that you sent a valid `persona_id`, `replica_id`, or both, and that customizations are in the expected location.
* `401` means the Tavus API key is missing or invalid. Keep `TAVUS_API_KEY` on the server and never send it from browser code.
* Quota or concurrency errors mean your app should stop creating live conversations, surface a retry/support path, and use `test_mode: true` for validation flows.
* For private rooms, join with the returned `meeting_token`. If a token is invalid or expired, create a new authenticated conversation instead of reusing the old token.

## Where to go next

* Use the [React component library](/sections/conversational-video-interface/component-library/overview) when you want Tavus CVI components instead of a plain iframe.
* Use [Embed CVI](/sections/integrations/embedding-cvi) for iframe, vanilla JavaScript, and Daily JS patterns.
* Use [customize the conversation UI](/sections/conversational-video-interface/quickstart/customize-conversation-ui) for Daily Prebuilt styling.
* Use [LiveKit Agent](/sections/integrations/livekit) only if you already run a LiveKit Agents pipeline and want Tavus as the avatar video layer. It is not the recommended path for most CVI apps because LiveKit only provides rendering, while Tavus's Full Pipeline includes perception, turn-taking, and rendering for complete conversational intelligence.
* Use [conversation customizations](/sections/conversational-video-interface/conversation/overview) for recording, language, participant limits, private rooms, backgrounds, captions, and timeouts.
* Point coding agents at [Agents & automation](/sections/agents-and-automation) for `llms.txt`, OpenAPI, Agent Skills, and MCP access.
