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

# Integration: @tavus/cvi-ui MagicCanvas

> Render Magic Canvas cards inside your own React app with the @tavus/cvi-ui MagicCanvas component: install, mount, and handle every interaction.

**`<MagicCanvas />`** is the `@tavus/cvi-ui` component that renders Magic Canvas cards inside your own React app. It listens for Canvas invocations, renders each card, and posts user interactions to Tavus and your webhook.

<Note>
  The Tavus-hosted embed and widget render Canvas automatically and do not require this SDK.
</Note>

## Prerequisites

Attach the `magic_canvas` skill to your PAL; it enables every component with defaults, and every video conversation on that PAL gets Canvas automatically with nothing to declare on the client. See the [Magic Canvas overview](/sections/conversational-video-interface/magic-canvas/overview).

## Installation

`@tavus/cvi-ui` is a CLI that copies component source into your app, not a runtime dependency.

<Steps>
  <Step title="Initialize cvi-ui (skip if already initialized)">
    ```bash theme={null}
    npx @tavus/cvi-ui@latest init
    ```

    Creates `cvi-components.json` and installs the shared dependencies:
    `@daily-co/daily-react`, `@daily-co/daily-js`, and `jotai`.
  </Step>

  <Step title="Add the component">
    ```bash theme={null}
    npx @tavus/cvi-ui@latest add magic-canvas
    ```

    Copies the Magic Canvas source into
    `src/components/cvi/components/magic-canvas`
    (`app/components/cvi/components/magic-canvas` if your project has no `src`
    directory); the import paths below resolve from there. If you don't have a
    conversation UI yet, also run:

    ```bash theme={null}
    npx @tavus/cvi-ui@latest add conversation
    ```
  </Step>
</Steps>

## Creating the Conversation

The PAL's Magic Canvas skill provides the Canvas actions; the create call needs nothing Canvas-specific:

```bash theme={null}
curl -X POST https://tavusapi.com/v2/conversations \
  -H "Content-Type: application/json" \
  -H "x-api-key: <your-api-key>" \
  -d '{
    "face_id": "r79e1c033f",
    "pal_id": "p5317866"
  }'
```

<Warning>
  Create conversations from your server; never ship your Tavus API key in the
  client bundle. `npx @tavus/cvi-ui@latest add tavus-api` installs a server
  route for Next.js, Remix, and TanStack Start that keeps the key server-side.
</Warning>

<Note>
  Conversations with no rendering surface skip Canvas: `audio_only`
  conversations, text chats, and conversations with a `meeting_url` (Zoom,
  Teams, Meet) get no Canvas actions: no error and no cards.
</Note>

## Mounting the Component

Mount `<MagicCanvas />` as a **sibling** of `<Conversation />` inside one `CVIProvider` (the Daily provider, exactly one per call); both listen on the same call object:

```tsx theme={null}
import { CVIProvider } from './components/cvi/components/cvi-provider';
import { Conversation } from './components/cvi/components/conversation';
import { MagicCanvas } from './components/cvi/components/magic-canvas';

<CVIProvider>
  <Conversation conversationUrl={conversationUrl} onLeave={handleLeave} />
  <MagicCanvas />
</CVIProvider>
```

The component renders nothing until the PAL shows a card.

## Placement

`<MagicCanvas />` renders a fixed full-viewport overlay (`position: fixed; inset: 0; pointer-events: none`, high z-index) and places each card via the slot system below. The overlay never blocks clicks; each card re-enables `pointer-events` for itself.

Cards cannot mount in your own containers: `renderComponent` swaps **what** renders, not **where** ([Bring your own renderer](#bring-your-own-renderer)); `className` restyles the overlay without moving it; [`onLayoutEffectChange`](#onlayouteffectchange) reports the video shift for side panels.

To pin the overlay inside a container instead of the viewport, the `examples/vite-app` demo uses a CSS override:

```css theme={null}
/* Pin the Magic Canvas overlay to the player box instead of the viewport. */
.canvas-in-player {
  position: absolute !important;
}
```

Requires a `position: relative` wrapper around `<Conversation />` and `<MagicCanvas className="canvas-in-player" />`. The side-rail card then stays inside the box.

### Card Slots

Every GA card renders inline in a side rail. The model can place a card in one of two slots:

| Slot              | Default for                        |
| ----------------- | ---------------------------------- |
| `safe-area-right` | Every card (the effective default) |
| `safe-area-left`  | None                               |

No other slots are offered to the model; any other placement hint is clamped to an inline side rail, so cards always render in `safe-area-right` or `safe-area-left`. The `scheduling_embed` card is config-gated: it renders only when the PAL supplies a scheduling provider and a `scheduling_url`.

Other runtime behavior:

| Condition          | Behavior                                                                                |
| ------------------ | --------------------------------------------------------------------------------------- |
| Multiple cards     | Only one card is on screen at a time; a new show or update replaces the current card.   |
| Inline card height | Sized to the height the card reports, clamped between a 48px floor and a 720px maximum. |

## Props

All props are optional; `<MagicCanvas />` with no props is fully functional.

| Field                  | Type                                                       | Required | Description                                                                                                                        |
| ---------------------- | ---------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `className`            | `string`                                                   | ❌        | Appended to the overlay's root class list. Restyles the overlay; does not change where cards render.                               |
| `onInteraction`        | `(event: CanvasInteractionEvent) => void \| Promise<void>` | ❌        | Called for every user interaction (submit, skip, dismiss) before it is sent to Tavus.                                              |
| `onError`              | `(event: CanvasErrorEvent) => void`                        | ❌        | Called for malformed configs, failed posts, and renderer errors.                                                                   |
| `onLayoutEffectChange` | `(layout: CanvasSidecarLayout) => void`                    | ❌        | Called when the side panel opens, closes, or moves.                                                                                |
| `renderComponent`      | `CanvasRenderRegistry`                                     | ❌        | Registry of your own React renderers, keyed by `"<component>@<version>"`. See [Bring your own renderer](#bring-your-own-renderer). |

### `onInteraction`

```ts theme={null}
type CanvasInteractionEvent = {
  interaction_id: string;      // unique id for this interaction
  conversation_id: string;
  tool_call_id: string;        // ties back to the invocation that showed the card
  component: string;           // e.g. "canvas.question"
  component_version: string;   // e.g. "v1"
  type: string;                // "submit", "skip", "dismiss", ...
  value: unknown;              // component-specific payload
  metadata: JsonRecord;
};
```

The handler is awaited **before** the interaction is posted to Tavus. If it throws, an `on_interaction_callback_failed` error event fires and the post still proceeds; the interaction reaches your webhook either way.

### `onError`

```ts theme={null}
type CanvasErrorEvent = {
  code: CanvasErrorCode;
  message: string;
  conversation_id?: string;
  tool_call_id?: string;
  component?: string;
  cause?: unknown;
};
```

| Code                               | Meaning                                                      |
| ---------------------------------- | ------------------------------------------------------------ |
| `malformed_canvas_config`          | A Canvas action carried a config the SDK couldn't parse.     |
| `missing_tool_call_id`             | A Canvas invocation arrived without a `tool_call_id`.        |
| `invalid_tool_arguments`           | The action's arguments failed to parse.                      |
| `missing_interaction_metadata`     | A card emitted an interaction without the required metadata. |
| `interaction_normalization_failed` | An interaction couldn't be turned into a postable payload.   |
| `interaction_post_failed`          | The POST to Tavus failed or timed out (5 seconds).           |
| `on_interaction_callback_failed`   | Your `onInteraction` handler threw.                          |
| `bridge_connect_failed`            | A sandboxed card failed to establish its message bridge.     |
| `send_tool_input_failed`           | A live update couldn't be relayed to a card.                 |

Errors never throw into your render tree; they all arrive here.

### `onLayoutEffectChange`

When a side-slot card opens, the canvas reserves a 448px panel and reports how far centered video content should shift to clear it:

```ts theme={null}
type CanvasSidecarLayout = {
  active: boolean;             // a side panel is currently open
  side?: 'left' | 'right';
  video_shift_x: number;       // px to shift your centered video content
  safe_area?: CanvasSafeArea;  // normalized region the cards avoid
  backdrop: CanvasBackdropConfig;
};
```

Nothing applies the shift for you; wire this callback if you render your own video layout. The shift only activates on viewports 900px and wider; `active` is `false` below that, and the video stays centered.

## Interaction Delivery

Every interaction is posted to:

```
POST https://tavusapi.com/v2/conversations/{conversation_id}/canvas/interactions
```

The post times out after 5 seconds; failures fire an `interaction_post_failed` error event. Successful interactions arrive at your conversation webhook as `canvas.interaction` events, and the PAL responds in the conversation.

## Bring Your Own Renderer

By default, cards render inside a sandboxed iframe; sandboxed component UIs load from Tavus-approved hosts only. Pass `renderComponent` (a registry keyed by `"<component>@<version>"`) to render a component natively instead:

```tsx theme={null}
<MagicCanvas
  renderComponent={{
    'canvas.question@v1': ({ args, submit }) => (
      <MyQuestionCard
        {...args}
        onAnswer={(optionId) =>
          submit({ value: { selected_option_ids: [optionId], skipped: false } })
        }
      />
    ),
  }}
/>
```

Your renderer receives the validated `component`, `version`, and `args` (runtime/layout keys already stripped), plus callbacks:

| Callback                                        | Description                                                                                                                                                                                          |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `submit(interaction)`                           | Reports a user interaction: `{ type?, value, metadata? }`; `type` defaults to `"submit"`. Runs normalization, `onInteraction`, the interaction POST, and auto-dismiss, identical to the iframe path. |
| `sendContext({ content?, structuredContent? })` | Appends model context to the conversation.                                                                                                                                                           |
| `respond(text)`                                 | Sends a free-text reply the PAL responds to.                                                                                                                                                         |
| `onError(error)`                                | Surfaces a renderer-side error through the host's `onError`.                                                                                                                                         |

The interaction payload, `canvas.interaction` webhook, and auto-dismiss behavior are identical for both paths, and native cards use the same slot and layout model as iframe cards. Components without a matching registry entry keep using the sandboxed iframe.

## Complete Example

Adapted from `examples/vite-app` in the cvi-ui repository. The installed `tavus-api` route expects a JSON body of `{ "action": "create", "params": { ... } }`, forwards `params` to Tavus verbatim, and is assumed mounted at `POST /api/tavus`.

```tsx main.tsx theme={null}
import React from 'react';
import { createRoot } from 'react-dom/client';
import { CVIProvider } from './components/cvi/components/cvi-provider';
import { App } from './App';

createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <CVIProvider>
      <App />
    </CVIProvider>
  </React.StrictMode>
);
```

```tsx App.tsx [expandable] theme={null}
import { useState } from 'react';
import { Conversation } from './components/cvi/components/conversation';
import { MagicCanvas } from './components/cvi/components/magic-canvas';

type Call = { id: string; url: string };

export function App() {
  const [call, setCall] = useState<Call | null>(null);

  const start = async () => {
    // The installed tavus-api route forwards `params` to
    // POST https://tavusapi.com/v2/conversations with your API key.
    const res = await fetch('/api/tavus', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        action: 'create',
        params: {
          pal_id: 'p5317866',
          face_id: 'r79e1c033f',
        },
      }),
    });
    const { conversation_id, conversation_url } = await res.json();
    setCall({ id: conversation_id, url: conversation_url });
  };

  if (!call) {
    return <button onClick={start}>Start conversation</button>;
  }

  return (
    <div style={{ maxWidth: '1024px', width: '100%', margin: '2rem auto' }}>
      <Conversation conversationUrl={call.url} onLeave={() => setCall(null)} />
      {/* Shares the call through the root CVIProvider; cards render in a
          fixed full-viewport overlay (see Placement). */}
      <MagicCanvas
        onInteraction={(event) => {
          console.log('canvas interaction', event.component, event.type, event.value);
        }}
        onError={(event) => {
          console.error('canvas error', event.code, event.message);
        }}
      />
    </div>
  );
}
```

Start a conversation and ask the PAL to "show me a multiple choice question." A card appears next to the PAL video, and the console logs the interaction.
