Skip to content

Wire a Cloudflare Agents backend

useAISDKRuntime over useAgentChat sends no model context by itself. The browser sends the system text in the message body and the tools through useAgentChat’s tools option, and the agent hands both to the model.

  1. Give useAgentChat a body function. It returns the runtime’s system text at send time, through a ref set after useAISDKRuntime returns.
  2. Give useAgentChat a tools object that mirrors the runtime’s model context tools. Keep one object and refill it in place whenever the model context changes, through runtime.thread.unstable_on("modelContextUpdate", …).
  3. Cast at the useAISDKRuntime call. useAgentChat’s return type differs from useChat’s on addToolOutput.
  4. Pass the runtime to AssistedProvider.
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;

useAgentChat reads tools each time it sends a message or a tool result, and sends the schemas as clientTools. Mind three details:

  • Every tool needs an execute. useAgentChat sends only the schemas of tools that have one. assistant-ui runs the tools, so the function does nothing. Leave experimental_automaticToolResolution off, so useAgentChat never calls it.
  • Refill the object, do not replace it. A tool result goes out as soon as the tool returns, before React renders Assistant again. A new object passed through state would arrive one render late, and the result of go_to_page would miss part of the new page’s tools.
  • Install assistant-stream as a direct dependency for toToolsJSONSchema. Leave out sendAutomaticallyWhen: useAgentChat continues the turn after tool results on its own.

In onChatMessage, pass system from the body to the model and build the tools from options.clientTools with createToolsFromClientSchemas. From examples/agent/src/chat.ts:

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();
}
}

The tools carry no server execute. The browser runs them and sends each result back over the socket, together with the tools as they are at that moment.

A continuation after a tool result reuses the system text of the user’s last message. When a tool opens another page mid-turn, the model gets the new page’s tools on its next step, while the page section of the system text still names the old page. go_to_page and every press answer with the new page’s note, and the universal instructions tell the model that such a note is newer than the page section.

For several threads per user, see Bring your own thread list. For why the backend works this way, see What the backend must do.