Elements

Elements · Knowledge

Research report

An outline that fills in section by section, each carrying the sources behind it.

Draft ownership in 0.140/4 sections · 9 sources read
What changed in 0.14
Who is affected
Migration path
Open questions
fig. 01 · plays once, replay from the corner

Installation

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

ResearchReport is a live outline: a heading list that tracks pending, writing, and done as an agent works through it, with a source count per section. With a runtime the sections stream in as the model writes them; standalone you already hold the full list.

Getting started

assistant-ui streams a tool call's arguments as partial JSON while the model writes them, so a report can be the argument itself rather than something you wait for a finished result to show. Ask the model for the report as structured args and read them live.

Render the live args

components/assistant-ui/elements/write-report-tool-ui.tsx
"use client";

import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import {
  ResearchReport,
  type ReportSection,
} from "@/components/assistant-ui/elements/research-report";

type WriteReportArgs = {
  title: string;
  sections: ReportSection[];
  sourcesRead: number;
};

export const WriteReportToolUI: ToolCallMessagePartComponent<
  WriteReportArgs,
  void
> = ({ args }) => (
  <ResearchReport
    title={args.title ?? "Untitled report"}
    sections={args.sections ?? []}
    sourcesRead={args.sourcesRead ?? 0}
  />
);

args is a partial parse while the call is running: title can be undefined before the model writes it, sections can be shorter than the final list, and the last entry's preview can be a half-written sentence. The fallbacks keep the render from breaking on a field that has not arrived yet.

Register the tool

This tool has nothing to execute: the report is the argument, not something fetched afterward. Give the schema the same shape as ReportSection so the model's own output already carries state.

app/toolkit.ts
import { defineToolkit } from "@assistant-ui/react";
import { z } from "zod";
import { WriteReportToolUI } from "@/components/assistant-ui/elements/write-report-tool-ui";

const section = z.object({
  id: z.string(),
  heading: z.string(),
  state: z.enum(["pending", "writing", "done"]),
  sources: z.number(),
  preview: z.string().optional(),
});

export const toolkit = defineToolkit({
  write_report: {
    type: "frontend",
    description: "Write a structured research report, section by section.",
    parameters: z.object({
      title: z.string(),
      sections: z.array(section),
      sourcesRead: z.number(),
    }),
    execute: async () => {},
    render: WriteReportToolUI,
  },
});

execute does no work; it only lets the call complete once the model finishes writing, the same as any other frontend tool. Wire the toolkit in with Tools({ toolkit }); see Tool UI.

Anatomy

<div data-slot="research-report">
  <div>
    <span>{title}</span>
    <span>{/* "D/T sections · S sources read" */}</span>
  </div>
  <div>
    {/* one row per section, top-bordered except the first */}
    <span>{/* check, spinner, or pending dot */}</span>
    <span>{/* heading */}</span>
    <span>{/* "N src", only when sources > 0 */}</span>
    <p>{/* preview, only when present */}</p>
  </div>
</div>

The header's done count is computed by filtering sections for state === "done"; sourcesRead is a separate number you supply and is never cross-checked against each section's own sources. Heading brightness marks two states, not three: pending dims the heading, while both writing and done render it at full opacity, so only the glyph beside it, a static dot, a spinning loader, or a check, tells writing and done apart. A section's source count renders only when greater than zero; a 0 is hidden rather than shown as 0 src. preview renders only for a non-empty string; an explicit empty string hides it the same as omitting the field. The first section's row carries no top border; every one after it does. An empty sections array renders the header alone, reading 0/0 sections, with no placeholder row.

Examples

Section state comes from the model, not from assistant-ui

state is plain data the model writes as part of sections; assistant-ui does not infer pending, writing, or done from streaming progress on its own. If you would rather not trust the model to self-report accurately, useToolArgsStatus reports whether the whole sections argument has finished streaming, which you can use to force every section to "done" once the call settles, regardless of what the model wrote for individual entries.

import { useToolArgsStatus } from "@assistant-ui/react";

function useIsReportSettled() {
  const { status } = useToolArgsStatus<WriteReportArgs>();
  return status === "complete";
}

Restyle the report

Both lanes take className on the root. The header meta line and the per-section source count read from the shared mono token, and the card itself from paper, both in surfaces.tsx.

<ResearchReport className="max-w-none p-6" /* ... */ />

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.