From 75c86db772eeced2e62c8f66ac5d2dc087a551cb Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 12:19:11 -0700 Subject: [PATCH 1/2] docs(web): add 'Research assistant web UI' recipe Task-oriented recipe for wiring a CopilotKit client to the flagship research demo over AG-UI: run the demo, connect /agui/research, surface plan + subagent activity, HITL approvals, and memory-candidate review. Complements the AG-UI protocol reference page (cross-links rather than restating the event contract). Co-Authored-By: Claude Opus 4.8 --- apps/web/app/components/docs/nav.ts | 1 + .../app/docs/recipes/research-web-ui/page.tsx | 9 + apps/web/content/docs/recipes/index.mdx | 1 + .../content/docs/recipes/research-web-ui.mdx | 240 ++++++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 apps/web/app/docs/recipes/research-web-ui/page.tsx create mode 100644 apps/web/content/docs/recipes/research-web-ui.mdx diff --git a/apps/web/app/components/docs/nav.ts b/apps/web/app/components/docs/nav.ts index a7892cd1..10bb963b 100644 --- a/apps/web/app/components/docs/nav.ts +++ b/apps/web/app/components/docs/nav.ts @@ -60,6 +60,7 @@ export const DOCS_NAV: readonly DocsNavSection[] = [ { label: "Stream output", href: "/docs/recipes/stream-output" }, { label: "Retry flaky tools", href: "/docs/recipes/retry-flaky-tools" }, { label: "Dispatch from a route", href: "/docs/recipes/dispatch-from-route" }, + { label: "Research web UI", href: "/docs/recipes/research-web-ui" }, ], }, { diff --git a/apps/web/app/docs/recipes/research-web-ui/page.tsx b/apps/web/app/docs/recipes/research-web-ui/page.tsx new file mode 100644 index 00000000..298fdf0b --- /dev/null +++ b/apps/web/app/docs/recipes/research-web-ui/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next" +import Content from "../../../../content/docs/recipes/research-web-ui.mdx" +import { DocsPage } from "../../../components/docs/DocsPage" + +export const metadata: Metadata = { title: "Research assistant web UI" } + +export default function Page() { + return +} diff --git a/apps/web/content/docs/recipes/index.mdx b/apps/web/content/docs/recipes/index.mdx index d4d4b24d..f4d4659d 100644 --- a/apps/web/content/docs/recipes/index.mdx +++ b/apps/web/content/docs/recipes/index.mdx @@ -14,6 +14,7 @@ For route-level agent behavior, start with [Memory](/docs/memory), [Planning](/d - [Stream output](/docs/recipes/stream-output) — incremental responses via `runs/stream` - [Retry flaky tools](/docs/recipes/retry-flaky-tools) — recover from transient failures - [Dispatch from a route](/docs/recipes/dispatch-from-route) — call one route from another +- [Research web UI](/docs/recipes/research-web-ui) — wire a CopilotKit client to the research demo over AG-UI ## Related diff --git a/apps/web/content/docs/recipes/research-web-ui.mdx b/apps/web/content/docs/recipes/research-web-ui.mdx new file mode 100644 index 00000000..ee4df404 --- /dev/null +++ b/apps/web/content/docs/recipes/research-web-ui.mdx @@ -0,0 +1,240 @@ +# Research assistant web UI + +Wire a [CopilotKit](https://docs.copilotkit.ai) web client to Dawn's flagship +research demo over [AG-UI](/docs/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](/docs/ag-ui). + +## 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 + + + + + +```bash +cd examples/research/server +cp .env.example .env # set OPENAI_API_KEY here — on the server, not the web app +``` + + + + + +```bash +cd examples/research +pnpm install +pnpm dev # Dawn server on :3002, web client on :3010 +``` + +Open [http://localhost:3010](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: + + + +```ts title="examples/research/web/app/api/copilotkit/route.ts" +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 => { + const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({ + runtime: copilotRuntime, + serviceAdapter: new ExperimentalEmptyAdapter(), + endpoint: "/api/copilotkit", + }) + return handleRequest(req) +} +``` + +```tsx title="examples/research/web/app/page.tsx" +"use client" +import { CopilotKit, CopilotSidebar } from "@copilotkit/react-core/v2" +import { PermissionInterrupt } from "./components/PermissionInterrupt" +import { PlanPanel } from "./components/PlanPanel" +import { SubagentActivity } from "./components/SubagentActivity" +import { MemoryCandidates } from "./components/MemoryCandidates" + +export default function Home() { + return ( + + +
+
+ + + +
+
+ +
+
+
+ ) +} +``` + +
+ + +Setting `agentId` on the sidebar prop alone does **not** reach the chat +component's internal `useAgent`, which falls back to the id `default`. Register +your agent under `default` (or set `agentId` on *every* consumer) — otherwise you +get `Agent 'default' not found`. + + +## Surface the plan and subagents from agent state + +Dawn's planning updates arrive as AG-UI `STATE_SNAPSHOT`s (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: + + + +```tsx title="examples/research/web/app/components/PlanPanel.tsx" +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 ( +
    + {todos.map((t, i) => ( +
  • {t.status === "completed" ? "☑" : "☐"} {t.content}
  • + ))} +
+ ) +} +``` + +```tsx title="examples/research/web/app/components/SubagentActivity.tsx" +import { useAgent } from "@copilotkit/react-core/v2" +import { useEffect, useState } from "react" + +export function SubagentActivity() { + const { agent } = useAgent() + const [events, setEvents] = useState<{ name: string; subagent?: string }[]>([]) + useEffect(() => { + const sub = agent.subscribe({ + onCustomEvent: ({ event }: { event: { name?: string; value?: unknown } }) => { + if (typeof event.name !== "string" || !event.name.startsWith("dawn.subagent.")) return + // Skip the per-token message stream — show only start/tool/end lifecycle. + const suffix = event.name.replace("dawn.subagent.", "") + if (suffix === "message" || suffix === "token" || suffix === "chunk") return + const v = (event.value ?? {}) as { subagent?: string } + setEvents((prev) => [...prev, { name: event.name as string, ...v }]) + }, + }) + return () => sub.unsubscribe() + }, [agent]) + // …render events… +} +``` + +
+ +## 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: + +```tsx title="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 ( +
+ + +
+ ) + }, +}) +``` + +See [AG-UI § Human-in-the-loop resume](/docs/ag-ui) 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: + +```tsx title="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. + + +The coordinator plans, dispatches a subagent per sub-question, and makes many +tool calls — enough to exceed LangGraph's default 25-step ceiling. The research +route sets `recursionLimit: 100` in `agent({ … })` so a full run completes. + + +## Related + + From ce3cec84ca5cab02bacc3960afe8cdd69016ec89 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 12:27:35 -0700 Subject: [PATCH 2/2] feat(create-dawn-app): improve getting-started for scaffolded apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create-dawn-app prints next-steps after scaffolding (cd / install / test / run it live) instead of exiting silently. - Templates gain a 'dev' script (dawn dev --port 3000) so a scaffolded app can actually be run — previously only offline tests/evals were wired up. - Research README adds a 'Run it live' section (ask a question via /agui) and a pointer to the web-UI recipe. - Update generated-app package.json snapshots for the new dev script. Co-Authored-By: Claude Opus 4.8 --- .changeset/research-getting-started.md | 10 ++++++ packages/create-dawn-app/src/index.ts | 23 +++++++++++++ .../templates/app-basic/package.json.template | 1 + .../devkit/templates/app-research/README.md | 32 ++++++++++++++++--- .../app-research/package.json.template | 1 + test/generated/fixtures/basic.expected.json | 1 + .../fixtures/custom-app-dir.expected.json | 1 + 7 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 .changeset/research-getting-started.md diff --git a/.changeset/research-getting-started.md b/.changeset/research-getting-started.md new file mode 100644 index 00000000..9554c3f6 --- /dev/null +++ b/.changeset/research-getting-started.md @@ -0,0 +1,10 @@ +--- +"create-dawn-ai-app": patch +"@dawn-ai/devkit": patch +--- + +Improve the getting-started experience for scaffolded apps. `create-dawn-app` +now prints next-steps guidance after creating an app (cd / install / test / run +it live), the templates gain a `dev` script (`dawn dev --port 3000`) so you can +actually run the agent, and the research template README shows the live path +(ask a question via `/agui`) plus a pointer to the web-UI recipe. diff --git a/packages/create-dawn-app/src/index.ts b/packages/create-dawn-app/src/index.ts index ef95b5b4..d2e91c50 100644 --- a/packages/create-dawn-app/src/index.ts +++ b/packages/create-dawn-app/src/index.ts @@ -19,6 +19,7 @@ export async function run(argv: readonly string[] = process.argv.slice(2)): Prom try { const options = parseArgs(argv) await scaffoldApp(options) + printNextSteps(options) return 0 } catch (error) { console.error(error instanceof Error ? error.message : String(error)) @@ -26,6 +27,28 @@ export async function run(argv: readonly string[] = process.argv.slice(2)): Prom } } +function printNextSteps(options: CliOptions): void { + const appName = basename(resolve(options.targetDir)) + const lines = [ + "", + `✔ Created ${appName} (${options.template} template)`, + "", + "Next steps:", + ` cd ${options.targetDir}`, + " npm install", + " npm run check # generate route + tool types", + " npm test # offline tests — no API key needed", + "", + "Run it live (needs an OpenAI key):", + " export OPENAI_API_KEY=sk-...", + " npm run dev # Dawn dev server on http://127.0.0.1:3000", + "", + "See README.md for the full tour, or https://github.com/cacheplane/dawn", + "", + ] + process.stdout.write(`${lines.join("\n")}\n`) +} + async function scaffoldApp(options: CliOptions): Promise { const appRoot = resolve(options.targetDir) const templateDir = await resolveTemplateDir(options.template) diff --git a/packages/devkit/templates/app-basic/package.json.template b/packages/devkit/templates/app-basic/package.json.template index a5e4392f..abe05ce6 100644 --- a/packages/devkit/templates/app-basic/package.json.template +++ b/packages/devkit/templates/app-basic/package.json.template @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "dev": "dawn dev --port 3000", "check": "dawn check", "eval": "dawn eval", "build": "tsc -p tsconfig.json", diff --git a/packages/devkit/templates/app-research/README.md b/packages/devkit/templates/app-research/README.md index 00a9d8bb..f38ce740 100644 --- a/packages/devkit/templates/app-research/README.md +++ b/packages/devkit/templates/app-research/README.md @@ -15,10 +15,34 @@ npm run eval # quality evals, offline (replay fixtures) npm run memory:list ``` -To run against a real model, set `OPENAI_API_KEY` and add `--live` -(e.g. `npm run eval -- --live`). The offline path uses recorded fixtures for the -agent run and the generated `llmJudge` scorer, so tests and evals are -deterministic and need no API key. +## Run it live + +Set a key and start the dev server: + +```bash +export OPENAI_API_KEY=sk-... +npm run dev # Dawn dev server on http://127.0.0.1:3000 +``` + +Ask the research agent a question — it plans, dispatches a researcher subagent, +and streams back a cited report: + +```bash +curl -N "http://127.0.0.1:3000/agui/%2Fresearch%23agent" \ + -H 'content-type: application/json' \ + -d '{"threadId":"t1","runId":"r1","state":{},"tools":[],"context":[],"forwardedProps":{}, + "messages":[{"id":"1","role":"user","content":"What are common agent architectures?"}]}' +``` + +That's the [AG-UI](https://github.com/cacheplane/dawn) endpoint (`/agui/`). +For a full **web UI** — streaming chat, a live plan, subagent activity, +human-in-the-loop approvals, and memory-candidate review — follow the +*Research assistant web UI* recipe in the Dawn docs; it wires a CopilotKit client +to this app's `/agui` endpoint. + +For evals against a real model, add `--live` (e.g. `npm run eval -- --live`). The +offline path uses recorded fixtures for the agent run and the generated +`llmJudge` scorer, so tests and evals are deterministic and need no API key. To dogfood the Docker sandbox, start Docker and run: diff --git a/packages/devkit/templates/app-research/package.json.template b/packages/devkit/templates/app-research/package.json.template index d0465fa2..9a6d8849 100644 --- a/packages/devkit/templates/app-research/package.json.template +++ b/packages/devkit/templates/app-research/package.json.template @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "dev": "dawn dev --port 3000", "check": "dawn check", "eval": "dawn eval", "build": "tsc -p tsconfig.json", diff --git a/test/generated/fixtures/basic.expected.json b/test/generated/fixtures/basic.expected.json index a9f6d81f..991c3246 100644 --- a/test/generated/fixtures/basic.expected.json +++ b/test/generated/fixtures/basic.expected.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "dev": "dawn dev --port 3000", "check": "dawn check", "eval": "dawn eval", "build": "tsc -p tsconfig.json", diff --git a/test/generated/fixtures/custom-app-dir.expected.json b/test/generated/fixtures/custom-app-dir.expected.json index 227b3d15..7d8fecad 100644 --- a/test/generated/fixtures/custom-app-dir.expected.json +++ b/test/generated/fixtures/custom-app-dir.expected.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "dev": "dawn dev --port 3000", "check": "dawn check", "eval": "dawn eval", "build": "tsc -p tsconfig.json",