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/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 (
+
+ )
+}
+```
+
+```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
+
+
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",