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. 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 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 = handler

The 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.
  • CopilotChatConfigurationProviderCopilotKit does 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.runAgent does not reject when a run fails; it catches, emits an error, and resolves normally. Failures surface through copilotkit.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, useInterrupt returns the element and Transcript places 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:

bash
pnpm add @dawn-ai/ag-ui
tsx
import { dawnActivityRenderers } from "@dawn-ai/ag-ui/react"
 
<CopilotKit
  runtimeUrl="/api/copilotkit"
  useSingleEndpoint={false}
  renderActivityMessages={dawnActivityRenderers}
>

Then import the stylesheet once, in your root layout:

app/layout.tsx
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:

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

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

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