Elements

Elements · Structured output

Spec sheet

The most common structured answer after a table: one object, labeled.

Opus 5claude-opus-5
context500,000 tokens
fig. 01 · plays once, replay from the corner

Installation

npx shadcn@latest add "@assistant-ui/elements-spec-sheet"
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.

The structured shape for a single labeled object, the answer most tool results reach for right after a table: a title, an optional subtitle, and a column of label and value rows that reveal one at a time. With a runtime the rows come from a tool result; standalone you pass them in directly.

Getting started

This element has no assistant-ui primitive of its own, so the runtime wiring is a tool renderer rather than a primitive composition. Unlike a piece the model authors freely, a spec sheet is usually a genuine lookup, so the rows belong on the tool's result rather than on its streamed args.

Register the render function

app/toolkit.tsx
"use client";

import { defineToolkit } from "@assistant-ui/react";
import { SpecSheet } from "@/components/assistant-ui/elements/spec-sheet";

export const toolkit = defineToolkit({
  get_model_card: {
    type: "backend",
    render: ({ args, result }) => {
      if (!result) return <p>Looking up {args.modelId}</p>;
      return (
        <SpecSheet
          title={result.title}
          subtitle={result.subtitle}
          rows={result.rows}
          visibleCount={result.rows.length}
        />
      );
    },
  },
});

Because the whole sheet lands as one result rather than as streamed steps, visibleCount is simply result.rows.length; there is no partial state to size it against.

Let the message list render it

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>
  );
}

Once the toolkit is registered, Thread and any custom message list built on assistant-ui's message part primitives (MessagePrimitive.Parts or MessagePrimitive.GroupedParts) render the registered UI automatically wherever the get_model_card call appears in the message, so nothing needs to be placed by hand.

Anatomy

<div data-slot="spec-sheet">
  <div>
    <span>{/* title */}</span>
    <span>{/* optional subtitle */}</span>
  </div>

  <div>
    {/* one row per visible row, first row has no top border */}
    <span>{/* row.label, monospace */}</span>
    <span>{/* row.value, right aligned; emphasis bolds and brightens it */}</span>
  </div>
</div>

title and subtitle always render regardless of visibleCount; only the row list is sliced. visibleCount is floored and clamped into 0…rows.length the same way visibleSteps is on the math element: NaN or a negative number maps to 0, and any value past the array length maps to the array length. Rows are keyed by row.label, so two rows sharing a label collide in React's reconciliation; keep labels unique within one sheet. The first row carries no top border; every later row does.

Examples

Marking the deciding row

emphasis is the only visual variation a row carries: set it on the row that should read as the answer rather than a supporting fact, and its value renders bolder and brighter than the rest.

const rows: readonly SpecRow[] = [
  { label: "input", value: "$15.00 / M" },
  { label: "output", value: "$75.00 / M" },
  { label: "best for", value: "Long agentic runs", emphasis: true },
];

Where the rows come from

The backend entry declares only the lookup key. The model supplies modelId, your server resolves the rest, and the client renderer never sees anything but the finished shape.

app/api/chat/route.ts
get_model_card: tool({
  description: "Look up pricing and capability details for a model.",
  inputSchema: z.object({ modelId: z.string() }),
  execute: async ({ modelId }) => getModelCard(modelId),
}),

Restyle the sheet

Both lanes take className on the root. The row labels use the shared mono surface from surfaces.tsx, so restyling that token restyles every element that uses it.

<SpecSheet className="max-w-none gap-4" /* ... */ />

API reference

Render props

PropTypeDescription
args{ modelId: string }The tool's arguments, the lookup key the model supplied.
result{ title: string; subtitle?: string; rows: SpecRow[] } | undefinedThe finished spec, undefined until the backend executor resolves.

get_model_card result

FieldTypeDescription
titlestringThe sheet's title.
subtitlestringOptional caption under the title.
rows[].labelstringRow label, shown in monospace. Must be unique within the result.
rows[].valuestringRow value, right aligned.
rows[].emphasisbooleanBolds and brightens the value when true.