构建流式 AI 响应、调用服务端 tool、在 browser 执行 client-side tool,并在敏感操作前请求用户审批的聊天 Agent。
你将构建: 由 Workers AI 驱动的聊天 Agent,含三种 tool 类型 — 自动、client-side 与需审批。
时间: 约 15 分钟
本教程从最小 Hello World Worker 开始,便于看清每个组成部分。若想要已接好核心组件的完整 starter,请从快速入门开始,再回来看聊天部分如何组合。
前提条件:
- Node.js 18+
- Cloudflare 账户(Free 计划即可)
npm create cloudflare@latest chat-agent提示时选择 "Hello World" Worker。然后安装依赖:
cd chat-agent
npm install agents @cloudflare/ai-chat ai workers-ai-provider zod将 wrangler.jsonc 替换为:
{
"name": "chat-agent",
"main": "src/server.ts",
// Set this to today's date
"compatibility_date": "2026-08-17",
"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 date
compatibility_date = "2026-08-17"
compatibility_flags = [ "nodejs_compat" ]
[ai]
binding = "AI"
[[durable_objects.bindings]]
name = "ChatAgent"
class_name = "ChatAgent"
[[migrations]]
tag = "v1"
new_sqlite_classes = [ "ChatAgent" ]关键设置:
ai绑定 Workers AI — 无需 API keydurable_objects注册 chat agent 类new_sqlite_classes启用消息持久化的 SQLite 存储
创建 src/server.ts,Agent 在此运行:
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.coerce.number().describe("First number"),
b: z.coerce.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.coerce.number().describe("First number"),
b: z.coerce.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? |
行为 |
|---|---|---|---|
getWeather |
是 | 否 | 在 server 上自动运行 |
getUserTimezone |
否 | 否 | 发送到 client;browser 提供结果 |
calculate |
是 | 是(大数字) | 等待用户审批后在 server 运行 |
创建 src/client.tsx:
import { useAgent } from "agents/react";
import { useAgentChat, getToolApproval } 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.state === "approval-requested") {
const approval = getToolApproval(part);
if (!approval) return null;
return (
<div key={part.toolCallId}>
<p>
Approve <strong>{part.toolName}</strong>?
</p>
<pre>{JSON.stringify(part.input, null, 2)}</pre>
<button
onClick={() =>
addToolApprovalResponse({
id: approval.id,
approved: true,
})
}
>
Approve
</button>
<button
onClick={() =>
addToolApprovalResponse({
id: approval.id,
approved: false,
})
}
>
Reject
</button>
</div>
);
}
// Show completed tool results
if (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, getToolApproval } 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.state === "approval-requested") {
const approval = getToolApproval(part);
if (!approval) return null;
return (
<div key={part.toolCallId}>
<p>
Approve <strong>{part.toolName}</strong>?
</p>
<pre>{JSON.stringify(part.input, null, 2)}</pre>
<button
onClick={() =>
addToolApprovalResponse({
id: approval.id,
approved: true,
})
}
>
Approve
</button>
<button
onClick={() =>
addToolApprovalResponse({
id: approval.id,
approved: false,
})
}
>
Reject
</button>
</div>
);
}
// Show completed tool results
if (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 />;
}useAgent通过 WebSocket 连接到ChatAgentuseAgentChat管理聊天生命周期(消息、流式、tool)onToolCall处理 client-side tool — LLM 调用getUserTimezone时 browser 提供结果并自动继续对话addToolApprovalResponse批准或拒绝带needsApproval的 tool- 消息、流式与恢复均自动处理
生成类型并启动 dev server:
npx wrangler types
npm run dev尝试这些 prompt:
- "What is the weather in Tokyo?" — 调用服务端
getWeathertool - "What timezone am I in?" — 调用 client-side
getUserTimezonetool(browser 提供答案) - "What is 5000 times 3?" — 执行前触发审批 UI(数字超过 1000)
npx wrangler deployAgent 现已在 Cloudflare 全球网络上运行。消息持久化在 SQLite,断开时流可恢复,空闲时 Agent hibernate 以节省资源。
你的 chat agent 具备:
- 通过 Workers AI 的流式 AI 响应(无需 API key)
- SQLite 消息持久化 — 对话在重启后仍保留
- 服务端 tool 自动执行
- 客户端 tool 在 browser 运行并将结果回传 LLM
- 敏感操作的 Human-in-the-loop 审批
- 可恢复流式传输 — client 在 stream 中途断开时可从中断处继续
Chat Agent API 参考
AIChatAgent 与 useAgentChat 完整参考 — provider、存储、高级模式。
存储与同步状态
在聊天消息之外添加实时状态。
Callable 方法
将 Agent 方法暴露为客户端的带类型 RPC。
Human-in-the-loop 模式
审批流程与人工干预的深入模式。