Skip to content

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.

Create a Next.js app with assistant-ui:

Terminal window
npx assistant-ui@latest create my-shop

The result is a my-shop folder with Next.js, Tailwind, assistant-ui’s elements and a components.json.

From the folder that holds my-shop, create a Worker beside it and install the agent packages:

Terminal window
npm create cloudflare@latest my-shop-agent -- --type=hello-world --ts --no-deploy
cd my-shop-agent
npm install agents@0.27.0 @cloudflare/ai-chat@0.12.1 ai@^7 @ai-sdk/openai@^4

Replace src/index.ts and add src/chat.ts. The Chat agent hands the model the system text and the tools the browser sends:

my-shop-agent/src/chat.ts
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();
}
}
my-shop-agent/src/index.ts
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:

my-shop-agent/wrangler.jsonc
{
"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:

my-shop-agent/.dev.vars
OPENAI_API_KEY=sk-...

Start the Worker and leave it running:

Terminal window
npx wrangler dev

The Worker now listens on http://localhost:8787.

In a second terminal, go to the app and install the library and the Cloudflare client packages:

Terminal window
cd my-shop
npm install @super-assist/react @assistant-ui/ai-sdk assistant-stream agents@0.27.0 @cloudflare/ai-chat@0.12.1

Point the app at the Worker:

my-shop/.env.local
NEXT_PUBLIC_AGENT_HOST=http://localhost:8787

The model runs on the Worker now, so delete the scaffold’s app/api/chat/route.ts.

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:

my-shop/components.json
"registries": {
"@assistant-ui": "https://r.assistant-ui.com/styles/{style}/{name}.json"
},

Add the panel and the cards’ styles:

Terminal window
npx shadcn@latest add https://super-assist.dogar.biz/r/assisted-panel.json https://super-assist.dogar.biz/r/assisted-cards.json

The result is components/assisted-panel.tsx, the Thread element under components/assistant-ui/elements/, and the cards’ rules in app/globals.css.

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:

my-shop/app/assistant.tsx
"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:

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

my-shop/app/page.tsx
"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:

Terminal window
npm run dev

Open 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.

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:

my-shop/app/page.tsx
"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.

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:

my-shop/app/page.tsx
"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.

Install assistant-ui’s devtools:

Terminal window
npm install @assistant-ui/react-devtools

Render AssistedDevtools inside the provider in app/assistant.tsx:

my-shop/app/assistant.tsx
import { AssistedDevtools } from "@super-assist/react/devtools";
my-shop/app/assistant.tsx
<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.

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: