Build a chat agent
Build a chat agent that streams AI responses, calls server-side tools, executes client-side tools in the browser, and asks for user approval before sensitive actions.
What you will build: A chat agent powered by Workers AI with three tool types — automatic, client-side, and approval-gated.
Time: ~15 minutes
Prerequisites:
- Node.js 18+
- A Cloudflare account (free tier works)
npm create cloudflare@latest chat-agentSelect "Hello World" Worker when prompted. Then install the dependencies:
cd chat-agentnpm install agents @cloudflare/ai-chat ai workers-ai-provider zodReplace your wrangler.jsonc with:
{ "name": "chat-agent", "main": "src/server.ts", // Set this to today's date "compatibility_date": "2026-02-24", "compatibility_flags": ["nodejs_compat"], "ai": { "binding": "AI" }, "durable_objects": { "bindings": [{ "name": "ChatAgent", "class_name": "ChatAgent" }], }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ChatAgent"] }],}name = "chat-agent"main = "src/server.ts"# Set this to today's datecompatibility_date = "2026-02-24"compatibility_flags = [ "nodejs_compat" ]
[ai]binding = "AI"
[[durable_objects.bindings]]name = "ChatAgent"class_name = "ChatAgent"
[[migrations]]tag = "v1"new_sqlite_classes = [ "ChatAgent" ]Key settings:
aibinds Workers AI — no API key neededdurable_objectsregisters your chat agent classnew_sqlite_classesenables SQLite storage for message persistence
Create src/server.ts. This is where your agent lives:
import { AIChatAgent } from "@cloudflare/ai-chat";import { routeAgentRequest } from "agents";import { createWorkersAI } from "workers-ai-provider";import { streamText, convertToModelMessages, pruneMessages, tool, stepCountIs,} from "ai";import { z } from "zod";
export class ChatAgent extends AIChatAgent { async onChatMessage() { const workersai = createWorkersAI({ binding: this.env.AI });
const result = streamText({ model: workersai("@cf/meta/llama-4-scout-17b-16e-instruct"), system: "You are a helpful assistant. You can check the weather, " + "get the user's timezone, and run calculations.", messages: pruneMessages({ messages: await convertToModelMessages(this.messages), toolCalls: "before-last-2-messages", }), tools: { // Server-side tool: runs automatically on the server getWeather: tool({ description: "Get the current weather for a city", inputSchema: z.object({ city: z.string().describe("City name"), }), execute: async ({ city }) => { // Replace with a real weather API in production const conditions = ["sunny", "cloudy", "rainy"]; const temp = Math.floor(Math.random() * 30) + 5; return { city, temperature: temp, condition: conditions[Math.floor(Math.random() * conditions.length)], }; }, }),
// Client-side tool: no execute function — the browser handles it getUserTimezone: tool({ description: "Get the user's timezone from their browser", inputSchema: z.object({}), }),
// Approval tool: requires user confirmation before executing calculate: tool({ description: "Perform a math calculation with two numbers. " + "Requires user approval for large numbers.", inputSchema: z.object({ a: z.number().describe("First number"), b: z.number().describe("Second number"), operator: z .enum(["+", "-", "*", "/", "%"]) .describe("Arithmetic operator"), }), needsApproval: async ({ a, b }) => Math.abs(a) > 1000 || Math.abs(b) > 1000, execute: async ({ a, b, operator }) => { const ops = { "+": (x, y) => x + y, "-": (x, y) => x - y, "*": (x, y) => x * y, "/": (x, y) => x / y, "%": (x, y) => x % y, }; if (operator === "/" && b === 0) { return { error: "Division by zero" }; } return { expression: `${a} ${operator} ${b}`, result: ops[operator](a, b), }; }, }), }, stopWhen: stepCountIs(5), });
return result.toUIMessageStreamResponse(); }}
export default { async fetch(request, env) { return ( (await routeAgentRequest(request, env)) || new Response("Not found", { status: 404 }) ); },};import { AIChatAgent } from "@cloudflare/ai-chat";import { routeAgentRequest } from "agents";import { createWorkersAI } from "workers-ai-provider";import { streamText, convertToModelMessages, pruneMessages, tool, stepCountIs,} from "ai";import { z } from "zod";
export class ChatAgent extends AIChatAgent { async onChatMessage() { const workersai = createWorkersAI({ binding: this.env.AI });
const result = streamText({ model: workersai("@cf/meta/llama-4-scout-17b-16e-instruct"), system: "You are a helpful assistant. You can check the weather, " + "get the user's timezone, and run calculations.", messages: pruneMessages({ messages: await convertToModelMessages(this.messages), toolCalls: "before-last-2-messages", }), tools: { // Server-side tool: runs automatically on the server getWeather: tool({ description: "Get the current weather for a city", inputSchema: z.object({ city: z.string().describe("City name"), }), execute: async ({ city }) => { // Replace with a real weather API in production const conditions = ["sunny", "cloudy", "rainy"]; const temp = Math.floor(Math.random() * 30) + 5; return { city, temperature: temp, condition: conditions[Math.floor(Math.random() * conditions.length)], }; }, }),
// Client-side tool: no execute function — the browser handles it getUserTimezone: tool({ description: "Get the user's timezone from their browser", inputSchema: z.object({}), }),
// Approval tool: requires user confirmation before executing calculate: tool({ description: "Perform a math calculation with two numbers. " + "Requires user approval for large numbers.", inputSchema: z.object({ a: z.number().describe("First number"), b: z.number().describe("Second number"), operator: z .enum(["+", "-", "*", "/", "%"]) .describe("Arithmetic operator"), }), needsApproval: async ({ a, b }) => Math.abs(a) > 1000 || Math.abs(b) > 1000, execute: async ({ a, b, operator }) => { const ops: Record<string, (x: number, y: number) => number> = { "+": (x, y) => x + y, "-": (x, y) => x - y, "*": (x, y) => x * y, "/": (x, y) => x / y, "%": (x, y) => x % y, }; if (operator === "/" && b === 0) { return { error: "Division by zero" }; } return { expression: `${a} ${operator} ${b}`, result: ops[operator](a, b), }; }, }), }, stopWhen: stepCountIs(5), });
return result.toUIMessageStreamResponse(); }}
export default { async fetch(request: Request, env: Env) { return ( (await routeAgentRequest(request, env)) || new Response("Not found", { status: 404 }) ); },} satisfies ExportedHandler<Env>;| Tool | execute? | needsApproval? | Behavior |
|---|---|---|---|
getWeather | Yes | No | Runs on the server automatically |
getUserTimezone | No | No | Sent to the client; browser provides the result |
calculate | Yes | Yes (large numbers) | Pauses for user approval, then runs on server |
Create src/client.tsx:
import { useAgent } from "agents/react";import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() { const agent = useAgent({ agent: "ChatAgent" });
const { messages, sendMessage, clearHistory, addToolApprovalResponse, status, } = useAgentChat({ agent, // Handle client-side tools (tools with no server execute function) onToolCall: async ({ toolCall, addToolOutput }) => { if (toolCall.toolName === "getUserTimezone") { addToolOutput({ toolCallId: toolCall.toolCallId, output: { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, localTime: new Date().toLocaleTimeString(), }, }); } }, });
return ( <div> <div> {messages.map((msg) => ( <div key={msg.id}> <strong>{msg.role}:</strong> {msg.parts.map((part, i) => { if (part.type === "text") { return <span key={i}>{part.text}</span>; }
// Render approval UI for tools that need confirmation if (part.type === "tool" && part.state === "approval-required") { return ( <div key={part.toolCallId}> <p> Approve <strong>{part.toolName}</strong>? </p> <pre>{JSON.stringify(part.input, null, 2)}</pre> <button onClick={() => addToolApprovalResponse({ id: part.toolCallId, approved: true, }) } > Approve </button> <button onClick={() => addToolApprovalResponse({ id: part.toolCallId, approved: false, }) } > Reject </button> </div> ); }
// Show completed tool results if (part.type === "tool" && part.state === "output-available") { return ( <details key={part.toolCallId}> <summary>{part.toolName} result</summary> <pre>{JSON.stringify(part.output, null, 2)}</pre> </details> ); }
return null; })} </div> ))} </div>
<form onSubmit={(e) => { e.preventDefault(); const input = e.currentTarget.elements.namedItem("message"); sendMessage({ text: input.value }); input.value = ""; }} > <input name="message" placeholder="Try: What's the weather in Paris?" /> <button type="submit" disabled={status === "streaming"}> Send </button> </form>
<button onClick={clearHistory}>Clear history</button> </div> );}
export default function App() { return <Chat />;}import { useAgent } from "agents/react";import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() { const agent = useAgent({ agent: "ChatAgent" });
const { messages, sendMessage, clearHistory, addToolApprovalResponse, status } = useAgentChat({ agent, // Handle client-side tools (tools with no server execute function) onToolCall: async ({ toolCall, addToolOutput }) => { if (toolCall.toolName === "getUserTimezone") { addToolOutput({ toolCallId: toolCall.toolCallId, output: { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, localTime: new Date().toLocaleTimeString(), }, }); } }, });
return ( <div> <div> {messages.map((msg) => ( <div key={msg.id}> <strong>{msg.role}:</strong> {msg.parts.map((part, i) => { if (part.type === "text") { return <span key={i}>{part.text}</span>; }
// Render approval UI for tools that need confirmation if ( part.type === "tool" && part.state === "approval-required" ) { return ( <div key={part.toolCallId}> <p> Approve <strong>{part.toolName}</strong>? </p> <pre>{JSON.stringify(part.input, null, 2)}</pre> <button onClick={() => addToolApprovalResponse({ id: part.toolCallId, approved: true, }) } > Approve </button> <button onClick={() => addToolApprovalResponse({ id: part.toolCallId, approved: false, }) } > Reject </button> </div> ); }
// Show completed tool results if ( part.type === "tool" && part.state === "output-available" ) { return ( <details key={part.toolCallId}> <summary>{part.toolName} result</summary> <pre>{JSON.stringify(part.output, null, 2)}</pre> </details> ); }
return null; })} </div> ))} </div>
<form onSubmit={(e) => { e.preventDefault(); const input = e.currentTarget.elements.namedItem( "message", ) as HTMLInputElement; sendMessage({ text: input.value }); input.value = ""; }} > <input name="message" placeholder="Try: What's the weather in Paris?" /> <button type="submit" disabled={status === "streaming"}> Send </button> </form>
<button onClick={clearHistory}>Clear history</button> </div> );}
export default function App() { return <Chat />;}useAgentconnects to yourChatAgentover WebSocketuseAgentChatmanages the chat lifecycle (messages, streaming, tools)onToolCallhandles client-side tools — when the LLM callsgetUserTimezone, the browser provides the result and the conversation auto-continuesaddToolApprovalResponseapproves or rejects tools that haveneedsApproval- Messages, streaming, and resumption are all handled automatically
Generate types and start the dev server:
npx wrangler typesnpm run devTry these prompts:
- "What is the weather in Tokyo?" — calls the server-side
getWeathertool - "What timezone am I in?" — calls the client-side
getUserTimezonetool (the browser provides the answer) - "What is 5000 times 3?" — triggers the approval UI before executing (numbers over 1000)
npx wrangler deployYour agent is now live on Cloudflare's global network. Messages persist in SQLite, streams resume on disconnect, and the agent hibernates when idle to save resources.
Your chat agent has:
- Streaming AI responses via Workers AI (no API keys)
- Message persistence in SQLite — conversations survive restarts
- Server-side tools that execute automatically
- Client-side tools that run in the browser and feed results back to the LLM
- Human-in-the-loop approval for sensitive operations
- Resumable streaming — if a client disconnects mid-stream, it picks up where it left off