Elements

Web search

A search query and its results landing one by one as the agent reads.

assistant-ui draft persistence
Searching
fig. 01 · plays once, replay from the corner

Installation

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

Web search shows a live query in a pill, a status line, and a growing list of results, each with a domain-lettered avatar, a title, and the source domain. With a runtime the query and results come from a tool call the model made; standalone you drive query, results, and how many of them are visible.

Getting started

A search tool renders through that tool's own toolkit entry: the model's arguments arrive as args, the provider's results as result, and the call's lifecycle as status.

Define the search toolkit

app/toolkit.tsx
"use generative";

import { defineToolkit, externalTool } from "@assistant-ui/react";
import { WebSearch } from "@/components/assistant-ui/elements/web-search";

export default defineToolkit({
  web_search: {
    execute: externalTool(),
    render: ({ args, result, status }) => (
      <WebSearch
        query={args.query ?? ""}
        results={result?.results ?? []}
        visibleResults={result?.results.length ?? 0}
        searching={status.type === "running"}
        cycle={0}
      />
    ),
  },
});

execute: externalTool() marks a tool that a backend route or provider runs, not the browser: the compiler drops it from the client bundle and keeps only render. Give execute a real function instead when the search itself should run client-side.

Register the toolkit

app/MyRuntimeProvider.tsx
"use client";

import { AssistantRuntimeProvider, AuiConfig, Tools } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/ai-sdk";
import { Thread } from "@/components/assistant-ui/elements/thread.aui";
import toolkit from "./toolkit";

export function App() {
  const runtime = useChatRuntime();
  const config = AuiConfig({ tools: Tools({ toolkit }) });

  return (
    <AssistantRuntimeProvider runtime={runtime} config={config}>
      <Thread />
    </AssistantRuntimeProvider>
  );
}

Every web_search tool call in the thread now renders as this element, wherever MessagePrimitive.Parts places it in the assistant's reply.

Anatomy

<div data-slot="web-search">
  <span>{/* query, in a rounded field with a search icon */}</span>
  <div>{/* "Searching" (shimmer) while searching, else fixed "Read 3 sources" text */}</div>
  <div>{/* the first `visibleResults` results: domain-initial avatar, title, domain */}</div>
</div>

visibleResults clamps to 0…results.length, so a value out of range never throws: negative or NaN shows nothing, one past the end shows everything. The "Read 3 sources" line is literal text in the source, not derived from results.length; edit it directly if the count should track what you pass in. cycle is mixed into each visible result's key, so bumping it re-triggers the entrance animation even when results and visibleResults are unchanged, useful for a search that runs again with the same shape of answer. An empty results array renders the status line with no rows beneath it; the results region keeps its reserved height either way.

Examples

Streaming the query as it arrives

args.query is a partial parse while the model is still writing the tool call. useToolArgsStatus reports which argument fields are still streaming, so the pill can hold a placeholder instead of a half-typed string:

import { useToolArgsStatus, type ToolCallMessagePartProps } from "@assistant-ui/react";
import {
  WebSearch,
  type WebSearchResult,
} from "@/components/assistant-ui/elements/web-search";

type SearchArgs = { query: string };
type SearchResult = { results: WebSearchResult[] };

function SearchToolUI({
  args,
  result,
  status,
}: ToolCallMessagePartProps<SearchArgs, SearchResult>) {
  const { propStatus } = useToolArgsStatus<SearchArgs>();

  return (
    <WebSearch
      query={propStatus.query === "streaming" ? "Searching…" : (args.query ?? "")}
      results={result?.results ?? []}
      visibleResults={result?.results.length ?? 0}
      searching={status.type === "running"}
      cycle={0}
    />
  );
}

Restyle the results

Both lanes take className on the root. The query pill uses the shared field surface and the domain labels use mono, both from surfaces.tsx, so retheming those tokens retheme every element built on them.

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

API reference

Tool render props

PropTypeDescription
args{ query?: string }Model-supplied arguments. Partial while streaming; use useToolArgsStatus to tell a finished field from one still arriving.
result{ results: WebSearchResult[] } | undefinedSet once the tool call resolves.
status.type"running" | "complete" | "incomplete" | "requires-action"Lifecycle of the call. "running" is the only state that should show the searching shimmer.

WebSearchResult is { title: string; domain: string }. See Tool UI for the full tool-call part shape.