Research assistant web UI
Wire a CopilotKit web client to Dawn's flagship
research demo over AG-UI. You get streaming chat and a cited
report, a live plan, subagent activity, human-in-the-loop approvals, and a
memory-candidate review panel — all driven by the /research agent.
This recipe is the concrete, task-oriented walkthrough. For the protocol itself —
the /agui endpoint, the event contract, threading, and HITL resume — see
AG-UI & 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 surfaces the coordinator's work as it runs:
- Chat + report — the streamed answer, cited with
[corpus/…], in aCopilotSidebar. - Plan — the coordinator's todos, checked off as steps complete.
- Subagents — each
researcherdispatch and its corpus tool calls. - Permissions — an approve/deny card when the agent runs a non-allowlisted command.
- Memory candidates — durable facts the agent proposes, approved from the UI.
Run it
- 1
Start the server (holds the API key)
bashcd examples/research/server cp .env.example .env # set OPENAI_API_KEY here — on the server, not 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 /agui/research
The Next.js runtime route registers an AG-UI HttpAgent pointed at the research
route. Register it under CopilotKit's default agent id so every hook and
component binds to it with no per-component wiring:
import { CopilotRuntime, ExperimentalEmptyAdapter, copilotRuntimeNextJSAppRouterEndpoint } from "@copilotkit/runtime"
import { HttpAgent } from "@ag-ui/client"
import type { NextRequest } from "next/server"
const dawnUrl = process.env.DAWN_SERVER_URL ?? "http://127.0.0.1:3002"
const agUiUrl = `${dawnUrl}/agui/${encodeURIComponent("/research#agent")}`
const copilotRuntime = new CopilotRuntime({
agents: { default: new HttpAgent({ url: agUiUrl }) },
})
export const POST = async (req: NextRequest): Promise<Response> => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime: copilotRuntime,
serviceAdapter: new ExperimentalEmptyAdapter(),
endpoint: "/api/copilotkit",
})
return handleRequest(req)
}Surface the plan and subagents from agent state
Dawn's planning updates arrive as AG-UI STATE_SNAPSHOTs (read them off
agent.state), and subagent activity arrives as CUSTOM events named
dawn.subagent.*. Subscribe to the raw event stream for those — and skip the
per-token message events, which arrive one-per-token and would flood the panel:
import { UseAgentUpdate, useAgent } from "@copilotkit/react-core/v2"
export function PlanPanel() {
const { agent } = useAgent({ updates: [UseAgentUpdate.OnStateChanged] })
const todos = (agent.state?.todos ?? []) as { content?: string; status?: string }[]
if (todos.length === 0) return null
return (
<ul>
{todos.map((t, i) => (
<li key={i}>{t.status === "completed" ? "☑" : "☐"} {t.content}</li>
))}
</ul>
)
}Human-in-the-loop approvals
The research agent gates its external fetch behind a permission prompt. Dawn
emits it as a CUSTOM{on_interrupt} event; CopilotKit's useInterrupt renders
it and resolve(...) resumes the run. Resolve with a Dawn decision shape:
useInterrupt({
render: ({ event, resolve }) => {
const { interruptId } = event.value ?? {}
const decide = (decision: "once" | "always" | "deny") =>
resolve(interruptId ? { decision, interruptId } : { decision })
return (
<div>
<button onClick={() => decide("once")}>Allow once</button>
<button onClick={() => decide("deny")}>Deny</button>
</div>
)
},
})See AG-UI § Human-in-the-loop resume for how resolve maps onto
forwardedProps.command.resume.
Approve memory candidates
When the coordinator calls remember(), Dawn stores a durable-memory candidate.
The dev server exposes them over HTTP — GET /memory/candidates,
POST /memory/candidates/:id/approve, POST /memory/candidates/:id/reject — so
the UI can review them instead of the dawn memory approve CLI. The panel
refetches after each run (onRunFinishedEvent), since that's when new candidates
land:
const refetch = useCallback(() => {
fetch("/api/memory/candidates")
.then((r) => r.json())
.then((d) => setCandidates(d.candidates ?? []))
.catch(() => setCandidates([]))
}, [])
useEffect(() => {
const sub = agent.subscribe({ onRunFinishedEvent: () => refetch() })
refetch()
return () => sub.unsubscribe()
}, [agent, refetch])
const approve = async (id: string) => {
await fetch(`/api/memory/candidates/${id}/approve`, { method: "POST" })
refetch()
}A same-origin Next proxy (app/api/memory/[...path]/route.ts) forwards to the
Dawn server so the browser avoids CORS.