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. 1

    Configure the server

    bash
    cd examples/research/server
    cp .env.example .env   # set OPENAI_API_KEY here, not in the web app
  2. 2

    Start both apps

    bash
    cd examples/research
    pnpm install
    pnpm dev          # Dawn server on :3002, web client on :3010

    Open 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:

examples/research/web/app/components/ActivitySchemas.ts
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 assignSubagentOutputToPublicType

Register 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:

examples/research/web/app/components/ActivityRenderers.tsx
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:

text
GET  /memory/candidates
POST /memory/candidates/:id/approve
POST /memory/candidates/:id/reject

The 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:

examples/research/web/app/components/MemoryCandidates.tsx
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()
}