Skip to content
BlocksResearch agentMenu

Research agent

A deep research surface: the conversation with a live progress card, search results streaming in, every source found, read and cited, and the finished report with citations. A composer with deep research mode. Everything is derived from the messages.

Deep research in motion: progress, live search results, sources and a cited report.

The demo plays a scripted run, so it needs no API key: the agent plans, searches in five rounds with results arriving in batches, reads ten pages and writes a cited report. Ask a follow-up from the composer for a short answer drawn from the report. Open /preview/research-agent-demo to see it full screen.

Installation

$ pnpm dlx shadcn@latest add https://chatcn.dev/r/research-agent.json

The block installs to components/chatcn/research-agent/research-agent.tsx together with every component it composes. It is your code from here: rename the views, drop the sources filter, add an export.

Usage

Pass messages, status and actions from useChatcnChat. The block reads the run out of the messages: web search tool calls become the results view and the sources, page fetches count as reads, and two data parts carry the plan and the report.

app/research/page.tsx
"use client"


export default function Page() {
  const modes = useRef<string[]>(["deep-research"])
  const chat = useChatcnChat({
    transport: new DefaultChatTransport({
      api: "/api/research",
      body: () => ({ modes: modes.current }),
    }),
  })
  return (
    <ResearchAgent
      fullHeight
      messages={chat.chatcnMessages}
      status={chat.status}
      error={chat.error}
      actions={chat.actions}
      onSend={(text, options) => {
        modes.current = options.modes
        void chat.actions.send?.(text)
      }}
    />
  )
}

On the server, stream the plan and the report as data parts with a stable id. Writing the same id again replaces the part, so the report grows in place while the model writes it.

app/api/research/route.ts
const stream = createUIMessageStream({
  execute: async ({ writer }) => {
    writer.write({ type: "data-research-plan", id: "plan", data: { title, steps } })

    // web_search and web_fetch tool calls stream as usual ...

    let markdown = ""
    for await (const delta of report.textStream) {
      markdown += delta
      writer.write({
        type: "data-research-report",
        id: "report",
        data: { markdown, sources: citedUrls, state: "streaming" },
      })
    }
    writer.write({
      type: "data-research-report",
      id: "report",
      data: { markdown, sources: citedUrls, state: "done", date: "Oct 21, 2026" },
    })
  },
})

The data parts

PartDataNotes
data-research-plan{ title?, steps: { id, title, status }[] }Step status is pending, active, done or skipped. title names the topic in the header
data-research-report{ title?, markdown, sources?, state?, date? }# title, a first paragraph as the dek, ## sections, lists, quotes and pipe tables. Cite with [1] or [1, 3]
tool-web_searchinput { query }, output { results, total?, durationMs? }A preliminary output shows results as they arrive
tool-web_fetchinput { url }, output { title? }Counts as a read once done
source-url{ url, title? }Adds a source without a search

Other tool names work through toolKinds: { exa_search: "search", firecrawl: "read" }. Tools that are neither render in the thread with toolRenderers. Use researchFromMessages(messages) to read the same state outside the block, for example in a run list.

Components it composes

ComponentRole
Research progressPhases, plan, live line and activity log in the thread
Search resultsThe Results view, newest search on top, skeletons while a search runs
Research reportThe Report view with citations, contents, copy and download
CitationNumbered citations in the report that jump to their source
SourcesSource tiles in the Sources view, marked found, read or cited
ConversationThe thread with stick-to-bottom scrolling
Message partsText, reasoning and other tools, with the data parts rendered as cards
ReasoningThe agent's thinking before it plans
MarkdownStreaming prose in the thread
Prompt inputThe composer, Send becomes Stop while the run streams
Mode switcherTools menu and chips, deep research on by default
ResizableDrag or arrow keys between the thread and the workspace
Empty state and SuggestionsThe first screen with starters
Scroll to bottomBack to the live end of the thread

Responsive

The block lays out by its own width, not the viewport, so it works in a page, a sheet or this docs column. From 768 px it splits into the thread and a workspace with Results, Report and Sources tabs, resizable from 360 px. Narrower, one pane shows at a time under an Activity, Results, Report and Sources tab bar with 44 px targets on touch. A quiet dot marks the Report tab while a report you have not opened is written.

The workspace follows the run: results while it searches and reads, the report once writing starts. Picking a tab stops the following until the next question.

Props

PropTypeNotes
messagesChatcnMessage[]AI SDK UIMessage works as is
status"ready" | "submitted" | "streaming" | "error"Same values as useChat().status
errorError | nullShown in the progress card when the run fails
actionsChatcnActionssend, stop, approve, deny. Missing ones hide
onSend(text, { modes }) => voidGets the active composer modes. Without it, actions.send gets the text
researchPartial<ResearchState>Override anything derived from the messages
title, headerActions, onNewResearchHeader title, extra controls and a New research button
modes, activeModes, defaultActiveModes, onActiveModesChangeComposerMode[], string[]Composer modes. Deep research is on by default
placeholderstringWhen no mode sets one. Defaults to Ask a follow-up…
emptyTitle, emptyDescription, suggestionsThe first screen. Picking a starter sends it
toolKindsRecord<string, "search" | "read">Merged over the built-in tool names
toolRenderersToolRendererMapRenderers for other tools in the thread
startedAt, durationMsnumberThe run clock. Measured when omitted
view, onViewChange"search" | "report" | "sources"Control the workspace view
fullHeightbooleanFill the viewport. Otherwise give the parent a height

Keyboard

KeyAction
← → Home EndMove between the view tabs
TabInto the thread, the composer, the divider and the workspace
← → on the dividerResize the panes. Shift moves ten times as far
EnterSend. Shift+Enter adds a line, nothing sends during IME composition
EscapeStop the run while it streams

Accessibility

  • View tabs are a tablist with roving focus; the workspace is the tabpanel and scrolls by keyboard.
  • The progress card announces each phase change and the end of the run once, never per source.
  • The thread is a silent log; finished replies are announced through the conversation's live region.
  • The sources filter uses aria-pressed, and the fresh dot on the Report tab has a text equivalent.
  • Citations jump to their source and move focus there; modified clicks still open the page.

Motion

  • Switching views is instant, whether by pointer or keyboard: the tab fill changes color in 150 ms and nothing moves.
  • New searches, report sections and sources fade in with @starting-style; nothing slides or counts up.
  • The report streams in place with the streaming caret. With reduced motion, only short opacity fades remain.