AI 応答をストリーミングし、サーバー側ツールを呼び出し、ブラウザーでクライアント側ツールを実行し、機密性の高い操作の前にユーザー承認を求めるチャットエージェントを構築します。
作成するもの: Workers AI で動くチャットエージェントです。自動実行、クライアント側、承認付きの 3 種類のツールを使います。
所要時間: 約 15 分
このチュートリアルは、各部品の動きが見えるように最小の Hello World Worker から始めます。同じ中核部品がすでに接続された完成済みのスターターアプリが欲しい場合は、先に クイックスタート を使い、その後ここに戻ってチャット部分の組み立てを確認してください。
前提条件:
- Node.js 18 以上
- Cloudflare アカウント(無料プランで問題ありません)
npm create cloudflare@latest chat-agent表示されたら 「Hello World」Worker を選びます。続けて依存関係をインストールします。
cd chat-agent
npm install agents @cloudflare/ai-chat ai workers-ai-provider zodwrangler.jsonc を次の内容に置き換えます。
{
"name": "chat-agent",
"main": "src/server.ts",
// Set this to today's date
"compatibility_date": "2026-09-20",
"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-09-20"
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 キーは不要ですdurable_objectsはチャットエージェントのクラスを登録しますnew_sqlite_classesはメッセージ永続化用の SQLite ストレージを有効にします
src/server.ts を作成します。エージェント本体はここに置きます。
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>;| ツール | execute? |
needsApproval? |
動作 |
|---|---|---|---|
getWeather |
あり | なし | サーバー上で自動実行します |
getUserTimezone |
なし | なし | クライアントへ送り、ブラウザーが結果を返します |
calculate |
あり | あり(大きな数値のとき) | ユーザー承認を待ってから、サーバー上で実行します |
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 経由でChatAgentに接続しますuseAgentChatはチャットのライフサイクル(メッセージ、ストリーミング、ツール)を管理しますonToolCallはクライアント側ツールを処理します。LLM がgetUserTimezoneを呼ぶと、ブラウザーが結果を返し、会話が自動で続きますaddToolApprovalResponseはneedsApprovalを持つツールを承認または拒否します- メッセージ、ストリーミング、再開はすべて自動で処理されます
型を生成し、開発サーバーを起動します。
npx wrangler types
npm run dev次のプロンプトを試してください。
- 「東京の天気は?」 — サーバー側の
getWeatherツールを呼びます - 「今いるタイムゾーンは?」 — クライアント側の
getUserTimezoneツールを呼びます(ブラウザーが答えを返します) - 「5000 かける 3 は?」 — 実行前に承認 UI が出ます(1000 を超える数値)
npx wrangler deployエージェントは Cloudflare のグローバルネットワーク上で稼働します。メッセージは SQLite に残り、切断時もストリームを再開でき、アイドル時は休止してリソースを節約します。
チャットエージェントには次の機能があります。
- ストリーミング AI 応答 — Workers AI 経由(API キー不要)
- メッセージの永続化 — SQLite に保存し、再起動後も会話が残ります
- サーバー側ツール — 自動実行します
- クライアント側ツール — ブラウザーで実行し、結果を LLM に戻します
- 人が介在する承認 — 機密性の高い操作向けです
- 再開可能なストリーミング — クライアントが途中で切断しても、続きから再開します
チャットエージェント API リファレンス
AIChatAgent と useAgentChat の完全なリファレンスです。プロバイダー、ストレージ、高度なパターンを扱います。
状態の保存と同期
チャットメッセージ以外のリアルタイム状態を追加します。
呼び出し可能なメソッド
エージェントのメソッドを、クライアント向けの型付き RPC として公開します。
ヒューマンインザループ
承認フローと手動介入の、より深いパターンです。