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.
On this page
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
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.
"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.
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
| Part | Data | Notes |
|---|---|---|
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_search | input { query }, output { results, total?, durationMs? } | A preliminary output shows results as they arrive |
tool-web_fetch | input { 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
| Component | Role |
|---|---|
| Research progress | Phases, plan, live line and activity log in the thread |
| Search results | The Results view, newest search on top, skeletons while a search runs |
| Research report | The Report view with citations, contents, copy and download |
| Citation | Numbered citations in the report that jump to their source |
| Sources | Source tiles in the Sources view, marked found, read or cited |
| Conversation | The thread with stick-to-bottom scrolling |
| Message parts | Text, reasoning and other tools, with the data parts rendered as cards |
| Reasoning | The agent's thinking before it plans |
| Markdown | Streaming prose in the thread |
| Prompt input | The composer, Send becomes Stop while the run streams |
| Mode switcher | Tools menu and chips, deep research on by default |
| Resizable | Drag or arrow keys between the thread and the workspace |
| Empty state and Suggestions | The first screen with starters |
| Scroll to bottom | Back 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
| Prop | Type | Notes |
|---|---|---|
messages | ChatcnMessage[] | AI SDK UIMessage works as is |
status | "ready" | "submitted" | "streaming" | "error" | Same values as useChat().status |
error | Error | null | Shown in the progress card when the run fails |
actions | ChatcnActions | send, stop, approve, deny. Missing ones hide |
onSend | (text, { modes }) => void | Gets the active composer modes. Without it, actions.send gets the text |
research | Partial<ResearchState> | Override anything derived from the messages |
title, headerActions, onNewResearch | Header title, extra controls and a New research button | |
modes, activeModes, defaultActiveModes, onActiveModesChange | ComposerMode[], string[] | Composer modes. Deep research is on by default |
placeholder | string | When no mode sets one. Defaults to Ask a follow-up… |
emptyTitle, emptyDescription, suggestions | The first screen. Picking a starter sends it | |
toolKinds | Record<string, "search" | "read"> | Merged over the built-in tool names |
toolRenderers | ToolRendererMap | Renderers for other tools in the thread |
startedAt, durationMs | number | The run clock. Measured when omitted |
view, onViewChange | "search" | "report" | "sources" | Control the workspace view |
fullHeight | boolean | Fill the viewport. Otherwise give the parent a height |
Keyboard
| Key | Action |
|---|---|
← → Home End | Move between the view tabs |
Tab | Into the thread, the composer, the divider and the workspace |
← → on the divider | Resize the panes. Shift moves ten times as far |
Enter | Send. Shift+Enter adds a line, nothing sends during IME composition |
Escape | Stop the run while it streams |
Accessibility
- View tabs are a
tablistwith roving focus; the workspace is thetabpaneland 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.