Skip to main content
<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.
The Tavus-hosted embed and widget render Canvas automatically and do not require this SDK.

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.

Installation

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

Initialize cvi-ui (skip if already initialized)

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

Add the component

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:
npx @tavus/cvi-ui@latest add conversation

Creating the Conversation

The PAL’s Magic Canvas skill provides the Canvas actions; the create call needs nothing Canvas-specific:
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"
  }'
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.
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.

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:
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); className restyles the overlay without moving it; 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:
/* 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:
SlotDefault for
safe-area-rightEvery card (the effective default)
safe-area-leftNone
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:
ConditionBehavior
Multiple cardsOnly one card is on screen at a time; a new show or update replaces the current card.
Inline card heightSized 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.
FieldTypeRequiredDescription
classNamestringAppended 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) => voidCalled for malformed configs, failed posts, and renderer errors.
onLayoutEffectChange(layout: CanvasSidecarLayout) => voidCalled when the side panel opens, closes, or moves.
renderComponentCanvasRenderRegistryRegistry of your own React renderers, keyed by "<component>@<version>". See Bring your own renderer.

onInteraction

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

type CanvasErrorEvent = {
  code: CanvasErrorCode;
  message: string;
  conversation_id?: string;
  tool_call_id?: string;
  component?: string;
  cause?: unknown;
};
CodeMeaning
malformed_canvas_configA Canvas action carried a config the SDK couldn’t parse.
missing_tool_call_idA Canvas invocation arrived without a tool_call_id.
invalid_tool_argumentsThe action’s arguments failed to parse.
missing_interaction_metadataA card emitted an interaction without the required metadata.
interaction_normalization_failedAn interaction couldn’t be turned into a postable payload.
interaction_post_failedThe POST to Tavus failed or timed out (5 seconds).
on_interaction_callback_failedYour onInteraction handler threw.
bridge_connect_failedA sandboxed card failed to establish its message bridge.
send_tool_input_failedA 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:
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:
<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:
CallbackDescription
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.
main.tsx
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>
);
App.tsx
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.