Skip to content

非公式本サイトは非公式の日本語ドキュメントであり、Cloudflare 公式サイトではありません。最新情報はdevelopers.cloudflare.comをご確認ください。

チャットエージェント

最終更新 Markdown で表示Agent セットアップ

AI 応答をストリーミングし、サーバー側ツールを呼び出し、ブラウザーでクライアント側ツールを実行し、機密性の高い操作の前にユーザー承認を求めるチャットエージェントを構築します。

作成するもの: Workers AI で動くチャットエージェントです。自動実行、クライアント側、承認付きの 3 種類のツールを使います。

所要時間: 約 15 分

このチュートリアルは、各部品の動きが見えるように最小の Hello World Worker から始めます。同じ中核部品がすでに接続された完成済みのスターターアプリが欲しい場合は、先に クイックスタート を使い、その後ここに戻ってチャット部分の組み立てを確認してください。

前提条件:

  • Node.js 18 以上
  • Cloudflare アカウント(無料プランで問題ありません)

1. プロジェクトを作成する

npm create cloudflare@latest chat-agent

表示されたら 「Hello World」Worker を選びます。続けて依存関係をインストールします。

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

2. Wrangler を設定する

wrangler.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 ストレージを有効にします

3. サーバーを書く

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 あり あり(大きな数値のとき) ユーザー承認を待ってから、サーバー上で実行します

4. クライアントを書く

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 を呼ぶと、ブラウザーが結果を返し、会話が自動で続きます
  • addToolApprovalResponseneedsApproval を持つツールを承認または拒否します
  • メッセージ、ストリーミング、再開はすべて自動で処理されます

5. ローカルで実行する

型を生成し、開発サーバーを起動します。

npx wrangler types
npm run dev

次のプロンプトを試してください。

  • 「東京の天気は?」 — サーバー側の getWeather ツールを呼びます
  • 「今いるタイムゾーンは?」 — クライアント側の getUserTimezone ツールを呼びます(ブラウザーが答えを返します)
  • 「5000 かける 3 は?」 — 実行前に承認 UI が出ます(1000 を超える数値)

6. デプロイする

npx wrangler deploy

エージェントは Cloudflare のグローバルネットワーク上で稼働します。メッセージは SQLite に残り、切断時もストリームを再開でき、アイドル時は休止してリソースを節約します。

作成したもの

チャットエージェントには次の機能があります。

  • ストリーミング AI 応答 — Workers AI 経由(API キー不要)
  • メッセージの永続化 — SQLite に保存し、再起動後も会話が残ります
  • サーバー側ツール — 自動実行します
  • クライアント側ツール — ブラウザーで実行し、結果を LLM に戻します
  • 人が介在する承認 — 機密性の高い操作向けです
  • 再開可能なストリーミング — クライアントが途中で切断しても、続きから再開します

次のステップ

役に立ちましたか?