Research Assistant Web UI
Wire a CopilotKit web client to Dawn's research
demo over AG-UI. The example streams chat and a cited report from
the /research agent and provides a separate review panel for durable-memory
candidates.
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:
- Chat + report - streamed output and citations in a
CopilotSidebar. - 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 - the three discovery prompts and generic root-tool cards remain available.
- Permissions - standard interrupt UI owns approval and denial actions.
- Memory candidates - durable facts proposed by the agent and reviewed from the web UI.
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.
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 the
sidebar and other consumers bind without per-component wiring:
import { HttpAgent } from "@ag-ui/client"
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime"
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)
}Render plan and researcher activities
The client validates both public activity content types with strict runtime schemas. A payload with unknown or incompatible fields fails closed instead of dumping arbitrary JSON into the transcript:
import type {
DawnPlanActivityContent,
DawnSubagentActivityContent,
} from "@dawn-ai/ag-ui"
import { z } from "zod"
const todoSchema = z.strictObject({
content: z.string().trim().min(1),
status: z.enum(["pending", "in_progress", "completed"]),
})
export const planActivityContentSchema = z.strictObject({
todos: z.array(todoSchema),
})
function assignPlanOutputToPublicType(
content: z.output<typeof planActivityContentSchema>,
): DawnPlanActivityContent {
return content
}
void assignPlanOutputToPublicType
const toolSchema = z.strictObject({
name: z.string().trim().min(1),
status: z.enum(["running", "completed", "incomplete"]),
})
const subagentFields = {
name: z.string().trim().min(1),
depth: z.number().int().positive(),
todos: z.array(todoSchema).optional(),
tools: z.array(toolSchema).max(5),
totalToolCount: z.number().int().nonnegative(),
}
export const subagentActivityContentSchema = z
.discriminatedUnion("status", [
z.strictObject({ ...subagentFields, status: z.literal("running") }),
z.strictObject({ ...subagentFields, status: z.literal("completed") }),
z.strictObject({
...subagentFields,
status: z.literal("failed"),
error: z.string().trim().min(1).max(400),
}),
])
.refine((content) => content.totalToolCount >= content.tools.length, {
message: "totalToolCount must include every displayed tool",
path: ["totalToolCount"],
})
function assignSubagentOutputToPublicType(
content: z.output<typeof subagentActivityContentSchema>,
): DawnSubagentActivityContent {
return content
}
void assignSubagentOutputToPublicTypeRegister the renderers once at module scope. The public constants ensure the registry follows the adapter's activity-type contract, and stable array identity avoids replacing the registry on every React render:
import type { ReactActivityMessageRenderer } from "@copilotkit/react-core/v2"
import {
DAWN_PLAN_ACTIVITY_TYPE,
DAWN_SUBAGENT_ACTIVITY_TYPE,
type DawnPlanActivityContent,
type DawnSubagentActivityContent,
} from "@dawn-ai/ag-ui"
import {
planActivityContentSchema,
subagentActivityContentSchema,
} from "./ActivitySchemas"
import { PlanActivityCard } from "./PlanActivityCard"
import { SubagentActivityCard } from "./SubagentActivityCard"
const planActivityRenderer = {
activityType: DAWN_PLAN_ACTIVITY_TYPE,
content: planActivityContentSchema,
render: ({ content }) => <PlanActivityCard content={content} />,
} satisfies ReactActivityMessageRenderer<DawnPlanActivityContent>
const subagentActivityRenderer = {
activityType: DAWN_SUBAGENT_ACTIVITY_TYPE,
content: subagentActivityContentSchema,
render: ({ content }) => <SubagentActivityCard content={content} />,
} satisfies ReactActivityMessageRenderer<DawnSubagentActivityContent>
export const activityMessageRenderers = [
planActivityRenderer,
subagentActivityRenderer,
]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.
Ordinary root task tool call/result events are a separate surface. Root task
events can carry the coordinator-visible input and result. The current generic
card reduces task input to the subagent name and may show the result. These
activity renderers do not suppress or specialize those events.
Choose Research a topic on the empty chat to see the root plan and researcher progress update before the cited answer. This live flow still uses the Dawn server's model key; it is not a browser-automation fixture.
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 example's same-origin Next.js proxy forwards these requests to Dawn, which avoids browser CORS configuration. The panel refetches after each completed run, when any newly proposed candidates are available:
const refetch = useCallback(() => {
fetch("/api/memory/candidates")
.then((response) => response.json())
.then((data) => setCandidates(data.candidates ?? []))
.catch(() => setCandidates([]))
}, [])
useEffect(() => {
const subscription = agent.subscribe({ onRunFinishedEvent: refetch })
refetch()
return () => subscription.unsubscribe()
}, [agent, refetch])
const approve = async (id: string) => {
await fetch(`/api/memory/candidates/${id}/approve`, { method: "POST" })
refetch()
}