Add an assistant to a Next.js app
In this tutorial you build an Orders page and an assistant beside it. The assistant answers from what the page shows, and it ships an order when you ask. The backend is a Cloudflare Agents Worker that runs on your machine.
The steps follow the example in the repo, examples/agent and examples/app, cut down to one page and one thread.
You need Node.js, npm and an OpenAI API key. No Cloudflare account is needed: wrangler dev runs the Worker locally.
1. Create the app
Section titled “1. Create the app”Create a Next.js app with assistant-ui:
npx assistant-ui@latest create my-shopThe result is a my-shop folder with Next.js, Tailwind, assistant-ui’s elements and a components.json.
2. Create the Worker
Section titled “2. Create the Worker”From the folder that holds my-shop, create a Worker beside it and install the agent packages:
npm create cloudflare@latest my-shop-agent -- --type=hello-world --ts --no-deploycd my-shop-agentnpm install agents@0.27.0 @cloudflare/ai-chat@0.12.1 ai@^7 @ai-sdk/openai@^4Replace src/index.ts and add src/chat.ts. The Chat agent hands the model the system text and the tools the browser sends:
import { AIChatAgent, createToolsFromClientSchemas, type OnChatMessageOptions,} from "@cloudflare/ai-chat";import { createOpenAI } from "@ai-sdk/openai";import { streamText, convertToModelMessages } from "ai";
export type Env = { OPENAI_API_KEY: string; Chat: DurableObjectNamespace<Chat>;};
type Body = { system?: string };
export class Chat extends AIChatAgent<Env> { async onChatMessage(_onFinish: unknown, options?: OnChatMessageOptions) { const { system } = (options?.body ?? {}) as Body; const openai = createOpenAI({ apiKey: this.env.OPENAI_API_KEY }); const result = streamText({ model: openai.responses("gpt-6-luna"), system, messages: await convertToModelMessages(this.messages), tools: createToolsFromClientSchemas(options?.clientTools), }); return result.toUIMessageStreamResponse(); }}import { routeAgentRequest } from "agents";import { Chat, type Env } from "./chat";
export { Chat };
export default { async fetch(request: Request, env: Env): Promise<Response> { return ( (await routeAgentRequest(request, env, { cors: true })) ?? new Response("Not found", { status: 404 }) ); },} satisfies ExportedHandler<Env>;Replace wrangler.jsonc so it binds the Chat Durable Object:
{ "name": "my-shop-agent", "main": "src/index.ts", "compatibility_date": "2026-01-01", "compatibility_flags": ["nodejs_compat"], "durable_objects": { "bindings": [{ "name": "Chat", "class_name": "Chat" }], }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Chat"] }],}Put your key in .dev.vars:
OPENAI_API_KEY=sk-...Start the Worker and leave it running:
npx wrangler devThe Worker now listens on http://localhost:8787.
3. Install the packages in the app
Section titled “3. Install the packages in the app”In a second terminal, go to the app and install the library and the Cloudflare client packages:
cd my-shopnpm install @super-assist/react @assistant-ui/ai-sdk assistant-stream agents@0.27.0 @cloudflare/ai-chat@0.12.1Point the app at the Worker:
NEXT_PUBLIC_AGENT_HOST=http://localhost:8787The model runs on the Worker now, so delete the scaffold’s app/api/chat/route.ts.
4. Install the panel
Section titled “4. Install the panel”The panel comes from this site’s shadcn registry. It pulls assistant-ui’s Thread from the @assistant-ui registry, so check that components.json names it:
"registries": { "@assistant-ui": "https://r.assistant-ui.com/styles/{style}/{name}.json" },Add the panel and the cards’ styles:
npx shadcn@latest add https://super-assist.dogar.biz/r/assisted-panel.json https://super-assist.dogar.biz/r/assisted-cards.jsonThe result is components/assisted-panel.tsx, the Thread element under components/assistant-ui/elements/, and the cards’ rules in app/globals.css.
5. Give the app its assistant
Section titled “5. Give the app its assistant”Create or replace app/assistant.tsx. It opens the Chat agent, sends assistant-ui’s model context with every message and every tool result, and hands the runtime to AssistedProvider. The panel renders inside the provider:
"use client";
import { type ReactNode, useEffect, useRef, useState } from "react";import { useAgent } from "agents/react";import { type AITool, useAgentChat } from "@cloudflare/ai-chat/react";import { useAISDKRuntime } from "@assistant-ui/ai-sdk";import type { AssistantRuntime } from "@assistant-ui/react";import { AssistedProvider } from "@super-assist/react";import { toToolsJSONSchema } from "assistant-stream";import { AssistedPanel } from "@/components/assisted-panel";
export const Assistant = ({ children }: { children: ReactNode }) => { const agent = useAgent({ agent: "Chat", name: "default", host: process.env.NEXT_PUBLIC_AGENT_HOST!, }); const runtimeRef = useRef<AssistantRuntime>(null); const [tools] = useState<Record<string, AITool>>({}); const chat = useAgentChat({ agent, body: () => ({ system: runtimeRef.current?.thread.getModelContext().system }), tools, }); const runtime = useAISDKRuntime(chat as Parameters<typeof useAISDKRuntime>[0]); runtimeRef.current = runtime; useEffect(() => { const sync = () => { for (const name of Object.keys(tools)) delete tools[name]; Object.assign(tools, toolsOf(runtime)); }; sync(); return runtime.thread.unstable_on("modelContextUpdate", sync); }, [runtime, tools]);
return ( <AssistedProvider runtime={runtime}> <main className="min-h-dvh p-6">{children}</main> <AssistedPanel mode="floating" /> </AssistedProvider> );};
const toolsOf = (runtime: AssistantRuntime) => Object.fromEntries( Object.entries(toToolsJSONSchema(runtime.thread.getModelContext().tools)).map( ([name, tool]) => [name, { ...tool, execute: ranByAssistantUi }], ), );
const ranByAssistantUi = () => undefined;The tools object mirrors the tools the page lends, so useAgentChat sends them with every message and tool result. Wire a Cloudflare Agents backend explains its details.
6. Wrap every page and add the Orders page
Section titled “6. Wrap every page and add the Orders page”In app/layout.tsx, wrap the children in Assistant. The smallest layout looks like this. If you keep the scaffold’s fonts and metadata, wrap its children the same way:
import type { ReactNode } from "react";import { Assistant } from "./assistant";import "./globals.css";
export default function RootLayout({ children }: { children: ReactNode }) { return ( <html lang="en"> <body> <Assistant>{children}</Assistant> </body> </html> );}Replace app/page.tsx with a plain Orders page. Its orders live in state, so shipping one changes the page:
"use client";
import { useState } from "react";import { Button } from "@/components/ui/button";
type Order = { id: string; customer: string; status: "to ship" | "shipped" };
const seed: Order[] = [ { id: "1042", customer: "Acme", status: "to ship" }, { id: "1043", customer: "Globex", status: "to ship" }, { id: "1045", customer: "Initech", status: "shipped" },];
export default function Orders() { const [orders, setOrders] = useState(seed); const ship = (id: string) => setOrders((all) => all.map((order) => (order.id === id ? { ...order, status: "shipped" } : order)), ); return ( <ul className="flex flex-col gap-2"> {orders.map((order) => ( <li key={order.id} className="flex items-center gap-4"> #{order.id} {order.customer}, {order.status} {order.status === "to ship" && ( <Button size="sm" onClick={() => ship(order.id)}> Ship </Button> )} </li> ))} </ul> );}Start the app:
npm run devOpen http://localhost:3000. The three orders show, and an Assistant button sits in the bottom-right corner. Press it and the panel opens. Ask “Which orders are waiting to ship?”.
The assistant replies, but it cannot see the orders. The page has not introduced itself yet.
7. Make the page assisted
Section titled “7. Make the page assisted”Wrap the page in AssistedPage. It gives the page a name, instructions for working there, and its content. The content is a function, read only when the assistant asks for it:
"use client";
import { AssistedPage } from "@super-assist/react";import { useState } from "react";import { Button } from "@/components/ui/button";
type Order = { id: string; customer: string; status: "to ship" | "shipped" };
const seed: Order[] = [ { id: "1042", customer: "Acme", status: "to ship" }, { id: "1043", customer: "Globex", status: "to ship" }, { id: "1045", customer: "Initech", status: "shipped" },];
export default function Orders() { const [orders, setOrders] = useState(seed); const ship = (id: string) => setOrders((all) => all.map((order) => (order.id === id ? { ...order, status: "shipped" } : order)), ); return ( <AssistedPage name="Orders" instructions="Every order of the shop. Ship an order that is waiting to ship by its id." content={() => orders} > <ul className="flex flex-col gap-2"> {orders.map((order) => ( <li key={order.id} className="flex items-center gap-4"> #{order.id} {order.customer}, {order.status} {order.status === "to ship" && ( <Button size="sm" onClick={() => ship(order.id)}> Ship </Button> )} </li> ))} </ul> </AssistedPage> );}The panel’s header now reads “On Orders”. Ask “Which orders are waiting to ship?” again.
This time the assistant calls get_page_content first, and its answer names Acme’s #1042 and Globex’s #1043.
8. Let the assistant ship an order
Section titled “8. Let the assistant ship an order”Wrap each Ship button in AssistedAction. Every row uses the name ship_order with its own id, so the assistant sees one action that picks a row by id:
"use client";
import { AssistedAction, AssistedPage } from "@super-assist/react";import { useState } from "react";import { Button } from "@/components/ui/button";
type Order = { id: string; customer: string; status: "to ship" | "shipped" };
const seed: Order[] = [ { id: "1042", customer: "Acme", status: "to ship" }, { id: "1043", customer: "Globex", status: "to ship" }, { id: "1045", customer: "Initech", status: "shipped" },];
export default function Orders() { const [orders, setOrders] = useState(seed); const ship = (id: string) => setOrders((all) => all.map((order) => (order.id === id ? { ...order, status: "shipped" } : order)), ); return ( <AssistedPage name="Orders" instructions="Every order of the shop. Ship an order that is waiting to ship by its id." content={() => orders} > <ul className="flex flex-col gap-2"> {orders.map((order) => ( <li key={order.id} className="flex items-center gap-4"> #{order.id} {order.customer}, {order.status} {order.status === "to ship" && ( <AssistedAction name="ship_order" id={order.id} label={`Ship ${order.customer}'s order #${order.id}`} > <Button size="sm" onClick={() => ship(order.id)}> Ship </Button> </AssistedAction> )} </li> ))} </ul> </AssistedPage> );}Ask “Ship Globex’s order.”
The assistant calls ship_order with the id 1043, which presses Globex’s Ship button. The row now reads “shipped”, its button is gone, and the assistant tells you the order shipped.
9. See what the assistant sees
Section titled “9. See what the assistant sees”Install assistant-ui’s devtools:
npm install @assistant-ui/react-devtoolsRender AssistedDevtools inside the provider in app/assistant.tsx:
import { AssistedDevtools } from "@super-assist/react/devtools";<AssistedProvider runtime={runtime}> <main className="min-h-dvh p-6">{children}</main> <AssistedPanel mode="floating" /> <AssistedDevtools /></AssistedProvider>A devtools launcher appears in the bottom-right corner, over the end of the panel’s button. Open it and pick the Assisted pages tab.
The tab shows the page “Orders”, the note the next turn sends, what get_page_content answers now, and under Actions ship_order with the one id still waiting to ship, 1042. Close the devtools, press Ship on Acme’s order yourself, and open them again. ship_order has left the Actions list, because no row lends it any more.
What you built
Section titled “What you built”A Next.js app whose Orders page introduces itself to the assistant, lends it its content on demand, and lends it an action per row. The Worker merges what the page lends into each model call.
Next:
- Make an action consequential, so the user presses it or approves it.
- Lend a tool that changes the page without a button.
- Add
go_to_pageonce the app has more than one page. - How model context composes explains what the Worker receives.