Elements

Elements · Structured output

Diagram

A drawn answer with zoom, reset, and a full-bleed view; you hand it the rendered graphic.

message flow100%
composerruntimeadapterprovider
fig. 01

Installation

npx shadcn@latest add "@assistant-ui/elements-diagram"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

Diagram is chrome for a graphic someone else already rendered: a title, a zoom percentage, and zoom controls wrapped around whatever you pass as children. With a runtime you hand it whatever a tool rendered; standalone you own the zoom level and the graphic yourself.

Getting started

With a runtime, Diagram renders whatever a tool has already turned into a graphic: an image URL, output from a charting library, or markup from a renderer like Mermaid. It owns only the zoom chrome; the zoom level itself stays with the caller, since every zoom callback is optional and the current zoom is a plain prop.

Render the tool call

components/assistant-ui/elements/render-diagram-tool-ui.tsx
"use client";

import { useState } from "react";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { Diagram } from "@/components/assistant-ui/elements/diagram";

type RenderDiagramResult = { title: string; imageUrl: string };

export const RenderDiagramToolUI: ToolCallMessagePartComponent<
  { prompt: string },
  RenderDiagramResult
> = ({ result }) => {
  const [zoom, setZoom] = useState(1);
  if (!result) return null;

  return (
    <Diagram
      title={result.title}
      zoom={zoom}
      onZoomIn={() => setZoom((z) => Math.min(2, z + 0.2))}
      onZoomOut={() => setZoom((z) => Math.max(0.4, z - 0.2))}
      onReset={() => setZoom(1)}
    >
      <img src={result.imageUrl} alt={result.title} className="max-w-none" />
    </Diagram>
  );
};

Register the tool

app/toolkit.ts
import { defineToolkit } from "@assistant-ui/react";
import { z } from "zod";
import { RenderDiagramToolUI } from "@/components/assistant-ui/elements/render-diagram-tool-ui";

export const toolkit = defineToolkit({
  render_diagram: {
    type: "frontend",
    description: "Render a labeled diagram from a text description.",
    parameters: z.object({ prompt: z.string() }),
    execute: async ({ prompt }) => renderDiagramImage(prompt),
    render: RenderDiagramToolUI,
  },
});
app/MyRuntimeProvider.tsx
import { AssistantRuntimeProvider, AuiConfig, Tools } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/ai-sdk";
import { toolkit } from "./toolkit";

export function MyRuntimeProvider({ children }: { children: React.ReactNode }) {
  const runtime = useChatRuntime();
  const config = AuiConfig({ tools: Tools({ toolkit }) });
  return (
    <AssistantRuntimeProvider runtime={runtime} config={config}>
      {children}
    </AssistantRuntimeProvider>
  );
}

See Tool UI for backend-defined tools and approval gates.

Anatomy

<div data-slot="diagram">
  <div>{/* title, zoom percentage, zoom-out / zoom-in / reset / expand buttons */}</div>
  <div>{/* children, scaled by `zoom` via a CSS transform */}</div>
</div>

The expand button is the only one with a disabled state: it renders disabled and dimmed whenever onExpand is omitted, since there is nowhere for it to go. The other three buttons have no disabled state, and Diagram does not clamp zoom itself, so an out-of-range value is whatever the caller passed in. The percentage label rounds zoom to the nearest integer.

Examples

Restyle the frame

Both lanes take className on the root. The header buttons read the shared ghostButton token and the percentage reads mono, both from surfaces.tsx.

<Diagram className="max-w-none" /* ... */ />

Clamping the zoom range

Diagram reports button presses but never bounds zoom on its own, so clamp it in the same handlers that change it:

const clamp = (z: number) => Math.min(2, Math.max(0.4, z));

<Diagram
  zoom={zoom}
  onZoomIn={() => setZoom((z) => clamp(z + 0.2))}
  onZoomOut={() => setZoom((z) => clamp(z - 0.2))}
  /* ... */
/>;

API reference

Tool-call render props

PropTypeDescription
argsTArgsParsed arguments. Partial while the model is still streaming them.
argsTextstringRaw JSON argument text streamed by the model.
resultTResult | undefinedThe tool's return value once it completes. undefined while running.
statusToolCallMessagePartStatusstatus.type is "running", "requires-action", "complete", or "incomplete".
toolNamestringName of the tool the model called.
toolCallIdstringStable id for this invocation.
isErrorboolean | undefinedWhether result represents a tool execution error.

Register the renderer on a toolkit entry's render field and attach the toolkit with Tools({ toolkit }). See Tool UI for the full render-prop surface, including addResult, human tools, and approval gates.