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 a CopilotSidebar.
  • Plan — the coordinator's todos, checked off as steps complete.
  • Subagents — each researcher dispatch 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. 1

    Start the server (holds the API key)

    bash
    cd examples/research/server
    cp .env.example .env   # set OPENAI_API_KEY here — on the server, not 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 /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:

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

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

Related