Research Assistant Web UI
Wire a CopilotKit web client to Dawn's research
demo over AG-UI. The example is a workbench rather than a chat
widget: it renders its own thread rail, transcript, and composer instead of
mounting CopilotSidebar, so the streamed report and the plan, researcher,
tool, and permission cards all appear inline in message order.
This recipe focuses on application wiring. For the endpoint, event contract, threading, and standard interrupt outcome, see AG-UI and Web Clients.
What you'll build
The demo lives in examples/research: a Dawn server (server/) and a Next.js
CopilotKit client (web/). The client provides:
- Thread rail - "New conversation" plus the list of threads, each titled from its first user message.
- Chat + report - streamed markdown output and citations in the app's own transcript.
- Plan cards - the root checklist updates in place while research runs.
- Researcher cards - delegated-work status, child checklist progress, and a bounded trail of child-tool names and statuses.
- Suggestions + tools - three discovery prompts on the empty state, and generic root-tool cards in the transcript.
- Permissions - standard interrupt UI owns approval and denial actions, rendered at the end of the transcript where the run stopped.
- Composer - send, and stop while a run is in flight; blocked while the agent is running or waiting on an approval, with the header saying which.
The plan and researcher cards are informational. They never resolve or cancel an interrupt; interrupt outcomes and answers use the standard AG-UI fields documented on the protocol page.
Durable-memory review is part of the Workbench: a bounded panel reaches Dawn through the same allowlisted, same-origin proxy used for thread hydration. See Approve memory candidates below.
Run it
- 1
Configure the server
bashcd examples/research/server cp .env.example .env # set OPENAI_API_KEY here, not in the web app - 2
Start both apps
bashcd examples/research pnpm install pnpm dev # Dawn server on :3002, web client on :3010Open http://localhost:3010 and ask a research question.
Connect the client to Dawn
The Next.js runtime route registers an AG-UI HttpAgent pointed at the encoded
Dawn assistant id. Register it under CopilotKit's default agent id so every
hook binds without per-component wiring:
import { HttpAgent } from "@ag-ui/client"
import { CopilotRuntime, createCopilotRuntimeHandler } from "@copilotkit/runtime/v2"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
const dawnUrl = process.env.DAWN_SERVER_URL ?? "http://127.0.0.1:3002"
const agUiUrl = `${dawnUrl}/agui/${encodeURIComponent("/research#agent")}`
const handler = createCopilotRuntimeHandler({
runtime: new CopilotRuntime({
agents: { default: new HttpAgent({ url: agUiUrl }) },
}),
basePath: "/api/copilotkit",
})
export const GET = handler
export const POST = handlerThe required catch-all route exposes CopilotKit's V2 REST and SSE paths under
/api/copilotkit/*. Setting useSingleEndpoint={false} makes the browser begin
with GET /api/copilotkit/info instead of sending the legacy method envelope to
the base URL. The web runtime holds no model credential; only the Dawn server has
OPENAI_API_KEY.
Three pieces of that tree are load-bearing:
defaultThrottleMs={100}— the re-render throttle defaults to unthrottled, and a full research run streams hundreds of events, which pegs the renderer.CopilotChatConfigurationProvider—CopilotKitdoes not provide one;<CopilotChat>and<CopilotSidebar>did. Without it every thread-aware hook falls back to the agent's own auto-minted thread and selecting a row in the rail would change nothing.<DemoSuggestions />and<ToolCallCard />render nothing. They publish into CopilotKit's registries, and the transcript reads them back.
The workbench shell
AppShell is the only component that talks to the agent. It calls useAgent()
with no arguments — the unscoped form, which takes its thread from the
surrounding chat configuration — so the transcript, useInterrupt,
useSuggestions, and the tool-call renderers all resolve the same agent and the
same thread.
Two consequences of dropping the sidebar are worth copying if you build your own shell:
- Run failures need a subscription.
copilotkit.runAgentdoes not reject when a run fails; it catches, emits an error, and resolves normally. Failures surface throughcopilotkit.subscribe({ onError }), which is what the sidebar used to do for you. - The permission gate needs
renderInChat: false. The default (true) publishes the element into<CopilotChat>/<CopilotSidebar>. With neither mounted, the gate would render nowhere: the run parks with no approve or deny UI, no error, and green tests. With it set,useInterruptreturns the element andTranscriptplaces it at the end of the message list.
Threads are local to the browser. Dawn's server can create and fetch a thread by
id but cannot enumerate threads, so the rail keeps its own list in
localStorage behind a ThreadSource interface
(examples/research/web/app/lib/thread-source.ts); CopilotKit's useThreads is
deliberately unused. That same seam is how history comes back on a switch:
CopilotKit's own replay path (connectAgent) is reached only from inside
<CopilotChat>, which this app does not mount, so the shell reads
GET /threads/:id/state through the proxy instead and maps the checkpoint's
LangChain envelopes into the shapes the transcript already renders
(app/lib/hydrate.ts). Messages, tool calls and results, and the plan come
back; subagent activity cards from earlier runs are not checkpointed and do
not, which the app says in a line above the restored messages.
The same seam carries GET /threads/:id/pending_interrupts, which is what makes
a permission prompt survive a reload. useInterrupt's state is fed only by live
run events, so after a reload the server is still holding the gate and nothing
on screen says so; app/components/HydratedInterrupts.tsx asks for the parked
interrupts and reports their count upward so the composer stays blocked until
one is answered.
Restyling is one file: examples/research/web/app/theme.css defines the palette
as CSS variables and re-exports it as Tailwind tokens through @theme inline,
which is why the app's utilities read bg-wb-surface, border-wb-border, and
rounded-wb.
Render plan and researcher activities
The plan and researcher cards ship with the adapter. Install the package and hand its renderer array to CopilotKit:
pnpm add @dawn-ai/ag-uiimport { dawnActivityRenderers } from "@dawn-ai/ag-ui/react"
<CopilotKit
runtimeUrl="/api/copilotkit"
useSingleEndpoint={false}
renderActivityMessages={dawnActivityRenderers}
>Then import the stylesheet once, in your root layout:
import "@dawn-ai/ag-ui/react/styles.css"That import is not optional polish. The cards carry no inline styles, so without
it they render as bare markup. Once it is in place, restyle by overriding the
--dawn-activity-* custom properties in your own CSS — see the
package README
for the full customization ladder.
dawnActivityRenderers is a module-scope constant, so the registry keeps a
stable identity across React renders, and each renderer is keyed by the
adapter's own activity-type constant. Both renderers validate the public
activity content with a strict runtime schema: a payload with unknown or
incompatible fields fails closed instead of dumping arbitrary JSON into the
transcript.
The example passes workbenchActivityRenderers instead — its own two renderers,
in examples/research/web/app/components/activity-renderers.tsx. They are not
forks: they wrap the packaged PlanActivityCard and SubagentActivityCard and
pass the packaged content schemas, adding only per-part classes through the
classNames prop, so validation and bounds stay in the package where they are
tested. One constraint governs which classes work: a classNames entry can only
set a property the package stylesheet leaves unset on that element, because the
package's CSS is unlayered and Tailwind's utilities are not. What the sheet does
claim on the card's own box — background, border color, radius, text color,
font-size, margin and padding — plus the header's font weight and the depth
badge's background, is reachable at rung 1 instead, through the
--dawn-activity-* tokens the app sets in app/theme.css.
React and @copilotkit/react-core are optional peer dependencies of
@dawn-ai/ag-ui, and only the ./react subpath needs them. A server that uses
the root or ./sse entry installs nothing extra.
To present the same activities your own way, the subpath also exports the pieces
behind that array: dawnPlanActivityRenderer and dawnSubagentActivityRenderer
for registering one without the other; PlanActivityCard, SubagentActivityCard,
and ActivityChecklist as plain React components; and
planActivityContentSchema and subagentActivityContentSchema for validating
content before rendering it yourself. The cards take a content prop and need
no CopilotKit context.
Root plan snapshots replace dawn:plan:${runId} and carry the complete todo
list. Subagent snapshots replace dawn:subagent:${call_id} and carry the name,
depth, running/completed/failed status, optional todos, up to five recent
tool name/status summaries, the total tool count, and an optional 400-character
failure summary. Both root and child checklist views display at most eight
todos, while their snapshots retain the complete valid todo lists.
The dawn.plan and dawn.subagent activity content supplied to these cards
excludes child reasoning or prose, prompts, tool inputs, tool outputs, final
child answers, route ids, and raw runtime ids.
These activities are the whole presentation of the two built-in orchestration
tools. When a writeTodos or task call produced its activity, Dawn's AG-UI
adapter emits no tool call/result events for that call, so the wildcard tool
card receives the ordinary tools (recall, searchCorpus, readDoc,
writeFile, and runBash once approved) but never writeTodos or task. The
suppression happens in the adapter, not in
CopilotKit or in these renderers.
The wildcard card is still the fail-open fallback. If an activity cannot be
produced — no tool-call id, a malformed payload, delegation that never starts —
the ordinary tool events survive and the generic card renders them, which is why
its task argument summary is worth keeping.
Choose Research a topic on the empty transcript to see the root plan and researcher progress update before the cited answer. This live flow still uses the Dawn server's model key. The automated browser check proves only V2 transport selection; it does not replace this live-model flow.
Approve memory candidates
When the coordinator calls remember(), Dawn stores a durable-memory candidate.
The runtime exposes candidates over HTTP:
GET /memory/candidates
POST /memory/candidates/:id/approve
POST /memory/candidates/:id/rejectThe browser cannot call those routes directly — the Dawn dev server sets no CORS
headers — so the example reaches them through a same-origin catch-all,
app/api/dawn/[...path]/route.ts. That proxy is allowlisted, not
pass-through: app/lib/proxy-allowlist.ts is a pure function listing the exact
method and path shape of every route the browser may reach — the three memory
routes above plus GET /threads/:id/state and
GET /threads/:id/pending_interrupts. Anything else is rejected with 403 and
never forwarded, which is what keeps POST /threads/:id/resume and the rest of
the agent surface out of reach of a template every Dawn developer copies.
app/components/MemoryPanel.tsx renders the candidates in the thread rail with
Approve and Delete on each — Delete maps to /reject, a hard delete on the
server. It reads on mount and again at the end of every run, because
remember() lands mid-run and a memory proposed in the answer you are reading
should be reviewable without a reload:
useEffect(() => {
const controller = new AbortController()
void load(controller.signal)
return () => {
controller.abort()
}
}, [load])
useEffect(() => {
const subscription = agent.subscribe({
onRunFinishedEvent: () => {
void load()
},
})
return () => {
subscription.unsubscribe()
}
}, [agent, load])The panel is review-only, and only of candidates: at most three are listed and
the rest are counted. Browsing, searching, and editing stored memories remain
the dawn memory CLI's job.