Cloudflare Agents SDK を使うと、Workers 上で動くステートフルな AI エージェントを構築できます。このガイドでは、独自の AI Search インスタンスをプロビジョニングし、ドキュメントをインデックスし、回答の前にツールでそのコンテンツを検索するチャットエージェントを構築します。
このガイドは、AI Search の search() をツールとしてモデルに公開する、推奨のエージェントパターンを使います。このパターンの詳細は エージェントツールとしての AI Search を参照してください。
- Cloudflare アカウント ↗ に登録します。
Node.js↗ をインストールします。
Node.js のバージョンマネージャー
権限の問題を避け、Node.js のバージョンを切り替えられるよう、Volta ↗ や nvm ↗ などの Node バージョンマネージャーを使います。このガイドの後半で説明する Wrangler には、Node バージョン 16.17.0 以降が必要です。
create-cloudflare CLI(C3)を使って、新しい Worker プロジェクトを作成します。C3 ↗ は、Cloudflare への新しいアプリケーションのセットアップとデプロイを支援するコマンドラインツールです。
次を実行して、ai-search-agent という新しいプロジェクトを作成します。
npm create cloudflare@latest -- ai-search-agentyarn create cloudflare ai-search-agentpnpm create cloudflare@latest ai-search-agentセットアップでは、次のオプションを選びます。
- What would you like to start with? では、
Hello World exampleを選びます。 - Which template would you like to use? では、
Worker onlyを選びます。 - Which language do you want to use? では、
TypeScriptを選びます。 - Do you want to use git for version control? では、
Yesを選びます。 - Do you want to deploy your application? では、
Noを選びます(デプロイ前にいくつか変更します)。
アプリケーションディレクトリへ移動します。
cd ai-search-agentAgents SDK、AI SDK、Workers AI プロバイダーをインストールします。
npm i agents @cloudflare/ai-chat ai workers-ai-provider zodyarn add agents @cloudflare/ai-chat ai workers-ai-provider zodpnpm add agents @cloudflare/ai-chat ai workers-ai-provider zodbun add agents @cloudflare/ai-chat ai workers-ai-provider zodWrangler 構成ファイル を次の内容に置き換えます。AI Search の 名前空間バインディング、応答生成用の Workers AI バインディング、エージェントのチャット履歴を保存する Durable Object を追加します。
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ai-search-agent",
"main": "src/server.ts",
// Set this to today's date
"compatibility_date": "2026-09-20",
"compatibility_flags": [
"nodejs_compat"
],
"ai": {
"binding": "AI"
},
"ai_search_namespaces": [
{
"binding": "AI_SEARCH",
"namespace": "default",
"remote": true
}
],
"durable_objects": {
"bindings": [
{
"name": "SearchAgent",
"class_name": "SearchAgent"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": [
"SearchAgent"
]
}
]
}name = "ai-search-agent"
main = "src/server.ts"
# Set this to today's date
compatibility_date = "2026-09-20"
compatibility_flags = ["nodejs_compat"]
[ai]
binding = "AI"
[[ai_search_namespaces]]
binding = "AI_SEARCH"
namespace = "default"
remote = true
[[durable_objects.bindings]]
name = "SearchAgent"
class_name = "SearchAgent"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["SearchAgent"]エージェントは実行時に create() を呼び出すため、単一インスタンスの ai_search バインディングではなく、名前空間バインディング(ai_search_namespaces)が必要です。AI Search はローカルでは動かないため、remote オプションにより wrangler dev がデプロイ済みインスタンスへリクエストをプロキシします。AIChatAgent はメッセージを SQLite に永続化するため、そのクラスを new_sqlite_classes に列挙する必要があります。
src/server.ts を作成します。エージェントは初回実行時に ハイブリッド検索 を有効にした AI Search インスタンスをプロビジョニングし、ドキュメントでシードし、2 つのツールを公開します。search_knowledge_base はコンテンツを取得し、save_resolution は新しいコンテンツを書き戻します。
import { AIChatAgent } from "@cloudflare/ai-chat";
import { routeAgentRequest } from "agents";
import { createWorkersAI } from "workers-ai-provider";
import { streamText, convertToModelMessages, tool, stepCountIs } from "ai";
import { z } from "zod";
const INSTANCE_NAME = "knowledge-base";
const SEED_DOC = `# Getting started
AI Search indexes your content so an agent can retrieve it at query time.`;
// AIChatAgent stores the conversation history and calls onChatMessage() once
// for each user message.
export class SearchAgent extends AIChatAgent {
// Guard so the one-time instance setup runs only once per running agent.
ready = false;
// Create the agent's instance with hybrid search enabled, then seed it so
// the first query has content. create() throws if the instance already
// exists, so the try/catch makes this idempotent.
async ensureInstance() {
if (this.ready) return;
try {
// index_method with both vector and keyword enables hybrid search.
await this.env.AI_SEARCH.create({
id: INSTANCE_NAME,
index_method: { vector: true, keyword: true },
});
// upload() queues the file; indexing runs in the background. Poll the
// item status until it is searchable so the first query has content.
const instance = this.env.AI_SEARCH.get(INSTANCE_NAME);
const { id } = await instance.items.upload(
"getting-started.md",
SEED_DOC,
);
let info = await instance.items.get(id).info();
while (info.status === "queued" || info.status === "running") {
await new Promise((resolve) => setTimeout(resolve, 2_000));
info = await instance.items.get(id).info();
}
} catch {
// Instance already exists.
}
this.ready = true;
}
// Runs on every chat message: make sure the instance exists, then stream a
// tool-using response.
async onChatMessage() {
await this.ensureInstance();
// The agent exposes AI Search's search() as a tool instead of using AI
// Search's own chat model, so this model must support function calling to
// decide when to search and drive the tool calls.
const workersai = createWorkersAI({ binding: this.env.AI });
const result = streamText({
model: workersai("@cf/zai-org/glm-5.2"),
system:
"You are a support assistant. Use search_knowledge_base to find " +
"relevant content before answering, and cite what you use.",
// this.messages is the stored chat history; convert it to the format
// the model expects.
messages: await convertToModelMessages(this.messages),
tools: {
search_knowledge_base: tool({
description: "Search the knowledge base for relevant content.",
inputSchema: z.object({
query: z.string().describe("The user's question or search terms"),
}),
// Hybrid search runs by default because the instance indexes
// both vectors and keywords.
execute: async ({ query }) => {
const instance = this.env.AI_SEARCH.get(INSTANCE_NAME);
return await instance.search({
query,
ai_search_options: { retrieval: { max_num_results: 5 } },
});
},
}),
save_resolution: tool({
description:
"Save a resolved answer to the knowledge base for reuse.",
inputSchema: z.object({
title: z.string().describe("Short descriptive title"),
content: z.string().describe("The resolution to save"),
}),
// upload() returns as soon as the file is queued; indexing then
// finishes in the background.
execute: async ({ title, content }) => {
const instance = this.env.AI_SEARCH.get(INSTANCE_NAME);
const item = await instance.items.upload(`${title}.md`, content);
return { key: item.key, status: item.status };
},
}),
},
// Cap the tool-call loop so the agent cannot run tools indefinitely.
stopWhen: stepCountIs(5),
});
// Stream the reply, including any tool activity, back to the client.
return result.toUIMessageStreamResponse();
}
}
export default {
async fetch(request, env) {
// Route the request to the matching agent instance, keyed by the URL.
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, tool, stepCountIs } from "ai";
import { z } from "zod";
const INSTANCE_NAME = "knowledge-base";
const SEED_DOC = `# Getting started
AI Search indexes your content so an agent can retrieve it at query time.`;
// AIChatAgent stores the conversation history and calls onChatMessage() once
// for each user message.
export class SearchAgent extends AIChatAgent {
// Guard so the one-time instance setup runs only once per running agent.
private ready = false;
// Create the agent's instance with hybrid search enabled, then seed it so
// the first query has content. create() throws if the instance already
// exists, so the try/catch makes this idempotent.
private async ensureInstance() {
if (this.ready) return;
try {
// index_method with both vector and keyword enables hybrid search.
await this.env.AI_SEARCH.create({
id: INSTANCE_NAME,
index_method: { vector: true, keyword: true },
});
// upload() queues the file; indexing runs in the background. Poll the
// item status until it is searchable so the first query has content.
const instance = this.env.AI_SEARCH.get(INSTANCE_NAME);
const { id } = await instance.items.upload(
"getting-started.md",
SEED_DOC,
);
let info = await instance.items.get(id).info();
while (info.status === "queued" || info.status === "running") {
await new Promise((resolve) => setTimeout(resolve, 2_000));
info = await instance.items.get(id).info();
}
} catch {
// Instance already exists.
}
this.ready = true;
}
// Runs on every chat message: make sure the instance exists, then stream a
// tool-using response.
async onChatMessage() {
await this.ensureInstance();
// The agent exposes AI Search's search() as a tool instead of using AI
// Search's own chat model, so this model must support function calling to
// decide when to search and drive the tool calls.
const workersai = createWorkersAI({ binding: this.env.AI });
const result = streamText({
model: workersai("@cf/zai-org/glm-5.2"),
system:
"You are a support assistant. Use search_knowledge_base to find " +
"relevant content before answering, and cite what you use.",
// this.messages is the stored chat history; convert it to the format
// the model expects.
messages: await convertToModelMessages(this.messages),
tools: {
search_knowledge_base: tool({
description: "Search the knowledge base for relevant content.",
inputSchema: z.object({
query: z.string().describe("The user's question or search terms"),
}),
// Hybrid search runs by default because the instance indexes
// both vectors and keywords.
execute: async ({ query }) => {
const instance = this.env.AI_SEARCH.get(INSTANCE_NAME);
return await instance.search({
query,
ai_search_options: { retrieval: { max_num_results: 5 } },
});
},
}),
save_resolution: tool({
description:
"Save a resolved answer to the knowledge base for reuse.",
inputSchema: z.object({
title: z.string().describe("Short descriptive title"),
content: z.string().describe("The resolution to save"),
}),
// upload() returns as soon as the file is queued; indexing then
// finishes in the background.
execute: async ({ title, content }) => {
const instance = this.env.AI_SEARCH.get(INSTANCE_NAME);
const item = await instance.items.upload(`${title}.md`, content);
return { key: item.key, status: item.status };
},
}),
},
// Cap the tool-call loop so the agent cannot run tools indefinitely.
stopWhen: stepCountIs(5),
});
// Stream the reply, including any tool activity, back to the client.
return result.toUIMessageStreamResponse();
}
}
export default {
async fetch(request: Request, env: Env) {
// Route the request to the matching agent instance, keyed by the URL.
return (
(await routeAgentRequest(request, env)) ||
new Response("Not found", { status: 404 })
);
},
} satisfies ExportedHandler<Env>;this.env.AI_SEARCH.get(INSTANCE_NAME) は同期的で、遅延解決されます。インスタンスは作成しないため、ensureInstance が先に作成します。1 回の呼び出しで複数インスタンスを検索するには、ai_search_options.instance_ids を使った名前空間レベルの検索を使います。名前空間 を参照してください。
search_knowledge_base ツールは、インスタンスの search() を呼び出します。インスタンスはベクトルとキーワードの両方をインデックスするため、取得はデフォルトでハイブリッド検索を使います。
save_resolution ツールは items.upload() を呼び出します。組み込みストレージへドキュメントをアップロードし、インデックス待ち行列に入れます。呼び出しはすぐに戻り、バックグラウンドのインデックスが完了するとコンテンツが検索可能になります。同じ名前のファイルをアップロードすると上書きされ、再インデックスされます。
型を生成し、開発サーバーを起動します。
npx wrangler types
npm run devAIChatAgent は WebSocket 上のチャットプロトコルを使うため、素の curl リクエストではなくチャットクライアントから操作します。最も簡単な方法は、Agents SDK の useAgentChat フックで構築した UI をローカルサーバーに向けることです。
How do I get started? のようなメッセージを送ります。最初のメッセージでエージェントがインスタンスを作成してシードするため、シードドキュメントのインデックス中は最初の応答に 1〜2 分かかることがあります。
連携が動いていることは、次で確認できます。
wrangler devのログに/agents/search-agent/<name>へのリクエストが表示され、そのあと返信の前にsearch_knowledge_baseツールが実行される。- エージェントの回答がシードコンテンツに基づき、それを引用している。一般的な回答になっていない。
Cloudflare アカウントでログインします。
npx wrangler loginWorker をデプロイし、インターネットからアクセスできるようにします。
npx wrangler deploy