Flight Planner Web UI
B4.run's navlog demo, a VFR flight planner, ships with a CopilotKit web client that talks to the server over AG-UI. Let's walk through how it's wired. The client is a workbench, not a chat widget. CopilotKit's stock <CopilotChat> sits in a floating dock over a route map, inside B4.run's <B4Activity>, so the streamed brief, the turn's plan, subagent and tool steps, and the approval card all appear in message order, while the map, the navlog sheet, and the weather strip read the same turns from outside the chat.
This recipe covers the application wiring. For the endpoint, events, threading, and interrupt outcomes, see AG-UI and Web Clients.
What you'll buildCopy link to section: What you'll build
The demo lives in examples/navlog: a B4.run 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
<CopilotChat>, with B4.run's chat slots (useB4ChatSlots) swapped in. - Activity: one
TurnActivityper turn: a summary line, then the plan, theweatherandperformancesubagents (nested, with their own plan and tool steps), and each tool call as a step.computeNavlogandrenderChartget their own opened views. - Map, sheet and weather strip: the route, the navlog legs, and the weather chips are derived from the activity turns (
useB4ActivityContext), not from a second subscription. - Suggestions: three discovery prompts on the empty chat.
- Permissions: the kit's
ApprovalCardowns approve and deny, rendered in the chat where the run stopped. - Input:
<CopilotChat>'s own input, with an image attachment when the route's model takes one. It waits while an approval is open. - Media: images, audio, video, and documents in user messages and tool results, plus a notice for each part the model never saw.
The activity steps only display progress. Interrupts are resolved through the standard AG-UI fields described on the protocol page.
The Workbench also reviews durable memory. A small panel reaches B4.run through an allowlisted, same-origin proxy. See Approve memory candidates below.
Run itCopy link to section: Run it
- 1
Configure the server
bashcd examples/navlog/server cp .env.example .env # set OPENAI_API_KEY here, not in the web app - 2
Start both apps
bashcd examples/navlog pnpm install pnpm dev # B4.run server on :3002, web client on :3010Open http://localhost:3010 and choose Plan a flight.
Connect the client to B4.runCopy link to section: Connect the client to B4.run
The Next.js runtime route registers a B4HttpAgent, an AG-UI HttpAgent that also reports the route's capabilities, pointed at the encoded B4.run assistant id. Register it under CopilotKit's default agent id so every hook binds without per-component wiring:
import { B4HttpAgent } from "@b4run/ag-ui/client"
import { createB4AgentRunner } from "@b4run/ag-ui/copilotkit-runtime"
import {
CopilotRuntime,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
const b4Url = process.env.B4_SERVER_URL ?? "http://127.0.0.1:3002"
const agUiUrl = `${b4Url}/agui/${encodeURIComponent("/navlog#agent")}`
const handler = createCopilotRuntimeHandler({
runtime: new CopilotRuntime({
agents: { default: new B4HttpAgent({ url: agUiUrl }) },
// Restores a thread on `connect` from B4.run's storage.
runner: createB4AgentRunner(InMemoryAgentRunner, { url: b4Url }),
}),
basePath: "/api/copilotkit",
})
export const GET = handler
export const POST = handlerThe catch-all route is required. It exposes CopilotKit's V2 REST and SSE paths under /api/copilotkit/*. Setting useSingleEndpoint={false} makes the browser start with GET /api/copilotkit/info rather than send the legacy method envelope to the base URL. Only the B4.run server has OPENAI_API_KEY. The web runtime holds no model credential.
Two pieces of that tree matter most, plus the suggestions:
defaultThrottleMs={100}: the re-render throttle defaults to unthrottled, and a full planning run streams hundreds of events, which pegs the renderer.CopilotChatConfigurationProvider:CopilotKitdoes 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 />renders nothing. It publishes the starter prompts into CopilotKit's suggestion registry, and<CopilotChat>shows them on an empty thread.
The workbench shellCopy link to section: The workbench shell
AppShell is the only component that talks to the agent. It calls useAgent() with no arguments. That unscoped form takes its thread from the surrounding chat configuration, so <CopilotChat>, B4Activity's interrupt handling, useSuggestions, and the shell all resolve the same agent and thread.
The workbench lives inside <B4Activity key={threadId}>. The key matters: B4Activity's pending approval card is cleared only by a new run, so an activity that outlived a thread switch would show the previous thread's approval on the next one. The key also restarts the turns from empty and remounts the chat, which connects the new thread.
// Inside B4Activity: the stock chat with B4.run's slots.
const slots = useB4ChatSlots()
<CopilotChat threadId={threadId} messageView={slots.messageView} input={input} attachments={attachments} />useB4ChatSlots renders each turn as a TurnActivity and a parked interrupt as an ApprovalCard. The example's NavlogChat adds echo-free prose, named input controls, and the image attachment below. renderStep on B4Activity (app/components/StepViews.tsx) gives computeNavlog a one-line totals view with a link to the sheet, and renderChart its image.
Run failures still 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 the shell turns into a banner above the chat.
Threads are local to the browser. B4.run's server can create and fetch a thread by id, but it has no endpoint to list threads. So the rail keeps its own list in localStorage behind a ThreadSource interface (examples/navlog/web/app/lib/thread-source.ts), and the app skips CopilotKit's useThreads. The rail holds ids, titles and recency only.
The map and the sheet sit outside the chat but inside B4Activity. useB4ActivityContext() returns the same turns the chat renders, and pure selectors (app/lib/navlog-selectors.ts, weather-selectors.ts) pick the latest computeNavlog result and weather brief from them.
Restoring a threadCopy link to section: Restoring a thread
<CopilotChat> calls connect when it mounts, and the runtime route's runner (createB4AgentRunner) answers it by replaying GET /threads/:id/events from B4.run's checkpoints as AG-UI events, so a restored thread and a live one go through the same path: the chat messages, the turns, and a parked approval all come back, with the media the thread carried. The browser never reads thread state itself, so the proxy has no thread routes. A brand-new thread has nothing to replay and restores as an empty chat. See Restoring a conversation after a reload for the runner and the per-request identity it needs.
To restyle the app, edit examples/navlog/web/app/theme.css. It defines the palette as CSS variables and re-exports it as Tailwind tokens through @theme inline. That's why the app's utilities read bg-wb-surface, border-wb-border, and rounded-wb.
Attach an imageCopy link to section: Attach an image
The attach control appears only when the route's model takes an image. AppShell reads that from the route's capability document:
const capabilities = useCapabilities()
const canAttachImages = capabilities?.multimodal?.input?.image === trueThere is no browser call to make here. In the browser, the agent is CopilotKit's runtime proxy, and it carries what the runtime's /info sync fetched through B4HttpAgent.getCapabilities(). useCapabilities() returns undefined until that handshake lands, which keeps the control hidden until then.
NavlogChat hands <CopilotChat> an attachments prop with enabled: canAttachImages, the accepted image types, and a size cap. The chat reads a picked file into an inline image part and sends it with the message; a text-only turn stays a plain string.
const attachments = {
enabled: canAttachImages,
accept: ATTACHABLE_IMAGE_TYPES.join(","),
maxSize: MAX_ATTACHMENT_BYTES,
onUploadFailed: (failure) => setAttachNotice(attachmentFailureText(failure)),
}It accepts PNG, JPEG, GIF and WebP images, the types OpenAI's vision input takes, and refuses any file over 4 MB with one dismissible line above the chat. The cap matters because every turn sends the whole conversation to /agui, whose request body limit is 8 MiB, so one large image would break every turn after it.
Media in the chatCopy link to section: Media in the chat
Tool steps draw their media with app/components/MediaParts.tsx (user messages carry theirs through <CopilotChat>): an <img> for an image, the native players for audio and video, a download chip for a document, and a chip naming the handle for a provider file source, which the browser can't load. A url source is loaded only when it's http(s), because tool results are model-driven.
Each part's MIME type has to match what the part claims to be. An image part whose type isn't image/* renders as a chip, and so does audio or video with the wrong type. Only PDF, CSV, Markdown and plain-text documents get a download link, and the downloaded file is named from the type (document.pdf), never from the part's filename, which is shown only as the label.
A tool result's media arrives on the step as parts, live and after a restore, so ChartStepView (app/components/StepViews.tsx, registered for renderChart through B4Activity's renderStep) draws it inside the opened step with MediaParts.
Parts the model didn't seeCopy link to section: Parts the model didn't see
When the model or its provider can't take a part, B4.run drops it and emits a CUSTOM event named b4.content_parts_dropped. AppShell subscribes with onCustomEvent and shows each event as a muted line in the dock above the chat, such as 1 content part was not sent to the model: image (tool_result_media_unsupported).
CopilotKit keeps no record of CUSTOM events, so the notices live only in the shell's state. Switching threads clears them, and a reload or a restored thread doesn't bring them back.
Media after a restoreCopy link to section: Media after a restore
The replay carries media. A user message that held an attachment comes back as AG-UI content parts, and a tool that returned parts keeps the full list in the tool message's additional_kwargs.b4_content_parts, which the replay sends as the tool result's parts. The UI shows what the tool produced, even where the model's own copy held only text.
A user image the adapter dropped is a different case. It never reached the model, so it isn't in the checkpoint, and it doesn't come back when the thread is restored.
A chart the model doesn't seeCopy link to section: A chart the model doesn't see
The navlog server ships renderChart (server/src/tools/renderChart.ts). Given a title and up to 12 { label, value } pairs, it returns two parts: a one-line text summary and an inline SVG bar chart.
return [
{ type: "text", text: summary },
{ type: "image", source: { type: "data", value: base64(svg), mimeType: "image/svg+xml" } },
]The user sees the chart in the opened renderChart step. The model, gpt-5-mini, sees only the summary, because media in a tool result never reaches an OpenAI model. So every renderChart call also produces a dropped-part notice. That's expected: the notice is telling you the truth about what the model saw.
Render plan and subagent activitiesCopy link to section: Render plan and subagent activities
The navlog example gets the plan and the subagents from B4Activity: each turn's TurnActivity shows the plan as a step and nests the weather and performance subagents with their own plan and tool steps, from the same AG-UI events. Choose Plan a flight on the empty chat to watch them update before the brief.
A client that doesn't use B4Activity gets the same view from the same events. useB4Turns(agent) from @b4run/ag-ui/react/copilotkit (or reduceTurns from @b4run/ag-ui/view, with no React) folds the agent's event stream into turns, and TurnActivity from @b4run/ag-ui/react renders one:
import "@b4run/ag-ui/styles.css"
import { useB4Turns } from "@b4run/ag-ui/react/copilotkit"
import { TurnActivity } from "@b4run/ag-ui/react"
import { useAgent } from "@copilotkit/react-core/v2"
function Activity() {
const { agent } = useAgent()
const { turns } = useB4Turns(agent)
return turns.turns.map((turn) => <TurnActivity key={turn.runId} turn={turn} />)
}The plan is a step in its turn, "Made a plan · 2 of 4 done", with the checklist inside. A subagent is a step too, "Asked weather" over the subagent's description, with the child's own plan and tool steps nested under it. The stylesheet carries no inline styles, so without that import the kit renders as bare markup. To restyle it, override the --b4-activity-* custom properties in your own CSS, as the navlog example does in app/theme.css.
React and @copilotkit/react-core are optional peer dependencies of @b4run/ag-ui, and only the ./react and ./react/copilotkit subpaths need them. A server that uses the root or ./sse entry installs nothing extra.
Plan snapshots replace b4:plan:${runId} (the root agent's) or b4:plan:${call_id} (a subagent's, tagged subagentRunId) and carry the complete todo list; the plan step shows every todo.
The plan activity is the whole presentation of writeTodos: when a writeTodos call produced its activity, B4.run's AG-UI adapter emits no tool call/result events for that call. A task call is an ordinary tool call, and the turn shows it as the subagent step it starts.
If the plan activity cannot be produced (no tool-call id, a malformed payload), the adapter fails open and the ordinary tool events survive; the view still places a plan step for the call, empty until a snapshot arrives.
The live planning flow uses the B4.run server's model key. The automated browser check only proves V2 transport selection.
Approve memory candidatesCopy link to section: Approve memory candidates
When the coordinator calls remember(), B4.run stores a durable-memory candidate. The runtime exposes candidates over HTTP:
GET /memory/candidates
POST /memory/candidates/:id/approve
POST /memory/candidates/:id/rejectThe browser cannot call those routes directly unless the server sets server.cors. Without it, the B4.run dev server sends no CORS headers, so the example reaches them through a same-origin catch-all, app/api/b4/[...path]/route.ts.
That proxy is allowlisted. 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 and nothing else. Thread history isn't proxied: the runtime route's runner reads it server to server. Anything else is rejected with 403 and never forwarded. That keeps POST /threads/:id/resume and the rest of the agent surface out of reach in a template every B4.run developer copies.
app/components/MemoryPanel.tsx renders the candidates in the thread rail with Approve and Delete on each. Delete maps to /reject, which is a hard delete on the server. The panel loads on mount and again at the end of every run. remember() lands mid-run, so a memory proposed in the answer you are reading should be reviewable without a reload:
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 only reviews candidates. It lists at most three and counts the rest. Browsing, searching, and editing stored memories is still the job of the b4 memory CLI.