Skip to content
Cloudflare Docs

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)

1. Create the project

Terminal window
npm create cloudflare@latest chat-agent

Select "Hello World" Worker when prompted. Then install the dependencies:

Terminal window
cd chat-agent
npm install agents @cloudflare/ai-chat ai workers-ai-provider zod

2. Configure Wrangler

Replace 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"] }],
}

Key settings:

  • ai binds Workers AI — no API key needed
  • durable_objects registers your chat agent class
  • new_sqlite_classes enables SQLite storage for message persistence

3. Write the server

Create src/server.ts. This is where your agent lives:

JavaScript
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 })
);
},
};

What each tool type does

Toolexecute?needsApproval?Behavior
getWeatherYesNoRuns on the server automatically
getUserTimezoneNoNoSent to the client; browser provides the result
calculateYesYes (large numbers)Pauses for user approval, then runs on server

4. Write the client

Create src/client.tsx:

JavaScript
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 />;
}

Key client concepts

  • useAgent connects to your ChatAgent over WebSocket
  • useAgentChat manages the chat lifecycle (messages, streaming, tools)
  • onToolCall handles client-side tools — when the LLM calls getUserTimezone, the browser provides the result and the conversation auto-continues
  • addToolApprovalResponse approves or rejects tools that have needsApproval
  • Messages, streaming, and resumption are all handled automatically

5. Run locally

Generate types and start the dev server:

Terminal window
npx wrangler types
npm run dev

Try these prompts:

  • "What is the weather in Tokyo?" — calls the server-side getWeather tool
  • "What timezone am I in?" — calls the client-side getUserTimezone tool (the browser provides the answer)
  • "What is 5000 times 3?" — triggers the approval UI before executing (numbers over 1000)

6. Deploy

Terminal window
npx wrangler deploy

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

What you built

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

Next steps