このチュートリアルでは、ナレッジベースを検索して追加するエージェントを構築します。各書き込みは人が承認します。エージェントにデータを変更させるのはリスクがあるため、各保存は実行前に承認で一時停止し、誤った保存はロールバックできます。
AI Search インスタンスを検索し、インデックスする新しいドキュメントを提案し、それぞれを承認するまで待ち、承認済みの保存を取り消せる Cloudflare Agent です。
- Cloudflare アカウント ↗ に登録します。
Node.js↗ をインストールします。
Node.js のバージョンマネージャー
権限の問題を避け、Node.js のバージョンを切り替えられるよう、Volta ↗ や nvm ↗ などの Node バージョンマネージャーを使います。このガイドの後半で説明する Wrangler には、Node バージョン 16.17.0 以降が必要です。
ほかに必要なものはありません。エージェントは初回実行時に、自身の AI Search インスタンスをプロビジョニングします。
エージェントは Code Mode を使います。これは、モデルが各呼び出しを個別に要求する代わりに、ツールを呼ぶ小さなプログラムを書くツール利用パターンです。durable runtime はプログラムが行うすべての呼び出しを記録し、機微な呼び出しの前に人が承認できるよう一時停止し、適用済みの呼び出しを revert で補償できます。その durable な状態は Agent の Durable Object にあり、承認はリクエストとハイバネーションをまたいで待てます。
ランタイムへ AI Search を公開するには connector を使います。AI Search の操作を、モデルが呼べるメソッドに変えるプレーンなクラスです。このチュートリアルでは、読み取り専用の search メソッドと、承認が必要な saveDocument メソッドをモデルに与えます。
create-cloudflare CLI(C3)で新しい Worker プロジェクトを作成します。C3 ↗ は、Cloudflare への新規アプリケーションのセットアップとデプロイを支援するコマンドラインツールです。
次のコマンドで、kb-agent という名前の新しいプロジェクトを作成します。
npm create cloudflare@latest -- kb-agentyarn create cloudflare kb-agentpnpm create cloudflare@latest kb-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 kb-agent依存関係をインストールします。ai と zod のバージョンは、Agents SDK が peer dependencies として想定する範囲にピン留めします。
npm i @cloudflare/codemode @cloudflare/ai-chat agents ai@6 workers-ai-provider zod@4yarn add @cloudflare/codemode @cloudflare/ai-chat agents ai@6 workers-ai-provider zod@4pnpm add @cloudflare/codemode @cloudflare/ai-chat agents ai@6 workers-ai-provider zod@4bun add @cloudflare/codemode @cloudflare/ai-chat agents ai@6 workers-ai-provider zod@4このチュートリアルは AI Search と Worker Loader のバインディングを使い、Wrangler v4 が必要です。create-cloudflare が以前のバージョンでプロジェクトをセットアップした場合は、アップグレードします。
npm i -D wrangler@4yarn add -D wrangler@4pnpm add -D wrangler@4bun add -d wrangler@4Wrangler 設定ファイル を次で置き換えます。AI Search バインディング、モデル用の Workers AI バインディング、モデルのコードを隔離された Worker で実行する Worker Loader バインディング、エージェントのチャット履歴と durable runtime 状態を保存する Durable Object を追加します。
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "kb-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
}
],
"worker_loaders": [
{
"binding": "LOADER"
}
],
"durable_objects": {
"bindings": [
{
"name": "Chat",
"class_name": "Chat"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": [
"Chat"
]
}
]
}name = "kb-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
[[worker_loaders]]
binding = "LOADER"
[[durable_objects.bindings]]
name = "Chat"
class_name = "Chat"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Chat"]AI Search にはローカルエミュレーターがないため、バインディングは常にリモートサービス(remote = true)と通信します。そのため、エージェントは wrangler dev ではなくデプロイして動かします。AIChatAgent はメッセージを SQLite に永続化するため、そのクラスは new_sqlite_classes に載せる必要があります。
src/ai-search-connector.ts を作成します。connector は AI Search バインディングを直接呼ぶため、リクエストはプロセス内に留まり、公開エンドポイントは不要です。
モデルに読み取り専用の search メソッドと saveDocument メソッドを与えます。saveDocument はコンテンツを書き込むため、requiresApproval を付け、ランタイムがロールバックできるよう revert を追加します。
import { CodemodeConnector } from "@cloudflare/codemode";
// The instance this connector reads from and writes to.
const INSTANCE_NAME = "knowledge-base";
// A connector turns AI Search operations into methods the model can call from
// its generated code. Each connector becomes one named object in the sandbox.
export class AISearchConnector extends CodemodeConnector {
// The sandbox global. The model calls `aiSearch.search()` and
// `aiSearch.saveDocument()`.
name() {
return "aiSearch";
}
// Shown to the model so it knows what this connector is for.
instructions() {
return "Use this connector to search indexed content and save new documents.";
}
// Every method the model can call. `inputSchema` is JSON Schema; the runtime
// validates the model's arguments against it before calling `execute`.
tools() {
return {
// Read-only, so it runs without approval.
search: {
description: "Search indexed content and return the matching chunks.",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
execute: async (input) => {
const { query } = input;
return this.env.AI_SEARCH.get(INSTANCE_NAME).search({
query,
ai_search_options: { retrieval: { max_num_results: 5 } },
});
},
},
// Writes content, so it is gated behind approval and made reversible.
saveDocument: {
description: "Save a new document to the knowledge base.",
inputSchema: {
type: "object",
properties: {
name: { type: "string" },
content: { type: "string" },
},
required: ["name", "content"],
},
// Pauses the run before this method executes so a human can approve it.
requiresApproval: true,
execute: async (input) => {
const { name, content } = input;
// upload() queues the document for indexing and returns right away.
// The item becomes searchable once indexing finishes, a few seconds later.
const item = await this.env.AI_SEARCH.get(INSTANCE_NAME).items.upload(
name,
content,
);
// This return value is passed to `revert` if the call is rolled back.
return { id: item.id, key: item.key, status: item.status };
},
// Compensating action for rollback: delete the document this call added.
revert: async (_input, result) => {
const { id } = result;
await this.env.AI_SEARCH.get(INSTANCE_NAME).items.delete(id);
},
},
};
}
}import { CodemodeConnector, type ConnectorTools } from "@cloudflare/codemode";
// The instance this connector reads from and writes to.
const INSTANCE_NAME = "knowledge-base";
// A connector turns AI Search operations into methods the model can call from
// its generated code. Each connector becomes one named object in the sandbox.
export class AISearchConnector extends CodemodeConnector<Env> {
// The sandbox global. The model calls `aiSearch.search()` and
// `aiSearch.saveDocument()`.
override name() {
return "aiSearch";
}
// Shown to the model so it knows what this connector is for.
protected override instructions() {
return "Use this connector to search indexed content and save new documents.";
}
// Every method the model can call. `inputSchema` is JSON Schema; the runtime
// validates the model's arguments against it before calling `execute`.
protected override tools(): ConnectorTools {
return {
// Read-only, so it runs without approval.
search: {
description: "Search indexed content and return the matching chunks.",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
execute: async (input) => {
const { query } = input as { query: string };
return this.env.AI_SEARCH.get(INSTANCE_NAME).search({
query,
ai_search_options: { retrieval: { max_num_results: 5 } },
});
},
},
// Writes content, so it is gated behind approval and made reversible.
saveDocument: {
description: "Save a new document to the knowledge base.",
inputSchema: {
type: "object",
properties: {
name: { type: "string" },
content: { type: "string" },
},
required: ["name", "content"],
},
// Pauses the run before this method executes so a human can approve it.
requiresApproval: true,
execute: async (input) => {
const { name, content } = input as { name: string; content: string };
// upload() queues the document for indexing and returns right away.
// The item becomes searchable once indexing finishes, a few seconds later.
const item = await this.env.AI_SEARCH.get(INSTANCE_NAME).items.upload(
name,
content,
);
// This return value is passed to `revert` if the call is rolled back.
return { id: item.id, key: item.key, status: item.status };
},
// Compensating action for rollback: delete the document this call added.
revert: async (_input, result) => {
const { id } = result as { id: string };
await this.env.AI_SEARCH.get(INSTANCE_NAME).items.delete(id);
},
},
};
}
}name() の結果(aiSearch)は、モデルのコードが呼ぶグローバルになります。メソッドは aiSearch.search() と aiSearch.saveDocument() として使えます。
src/server.ts を作成します。エージェントは初回実行時に hybrid search を有効にした AI Search インスタンスをプロビジョニングし、connector 付きの Code Mode runtime を作成して、モデルへ単一の codemode ツールとして公開します。@callable() メソッドにより、クライアントは保留中の承認を一覧し、書き込みを承認、拒否、またはロールバックできます。
import { AIChatAgent } from "@cloudflare/ai-chat";
import {
createCodemodeRuntime,
DynamicWorkerExecutor,
} from "@cloudflare/codemode";
import { callable, routeAgentRequest } from "agents";
import { createWorkersAI } from "workers-ai-provider";
import { convertToModelMessages, stepCountIs, streamText } from "ai";
import { AISearchConnector } from "./ai-search-connector";
// Code Mode stores its durable state (execution log, pending approvals) in a
// facet exported from the Worker entry. The runtime requires this export.
export { CodemodeRuntime } from "@cloudflare/codemode";
const INSTANCE_NAME = "knowledge-base";
// Seed content, so the agent has something to find on the first query.
const SEED_DOC = `# Getting started
AI Search indexes your content so an agent can search it and add to it.`;
export class Chat extends AIChatAgent {
// In-memory guard, so the one-time setup runs once per instance lifetime.
ready = false;
// Create the AI Search instance with hybrid search enabled, then seed it.
// create() throws if the instance already exists, so the try/catch makes
// this safe to call on every message.
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 },
});
// Queue the seed document for indexing so the first search has content.
await this.env.AI_SEARCH.get(INSTANCE_NAME).items.upload(
"getting-started.md",
SEED_DOC,
);
} catch (err) {
// create() throws if the instance already exists, which is expected on
// every run after the first. Log anything else so real failures surface.
console.error("ensureInstance:", err);
}
this.ready = true;
}
// Build the Code Mode runtime for this request. The handle is cheap to
// create; the durable state lives in the Durable Object, not the handle.
#runtime() {
return createCodemodeRuntime({
ctx: this.ctx,
// Runs the model's generated code in an isolated Worker.
executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),
connectors: [new AISearchConnector(this.ctx, this.env)],
});
}
// Runs on every chat message from the client.
async onChatMessage() {
await this.ensureInstance();
const workersai = createWorkersAI({ binding: this.env.AI });
const result = streamText({
model: workersai("@cf/zai-org/glm-5.2"),
system:
"You help maintain a knowledge base. Use codemode to search existing " +
"content and to save new documents. Search before you answer.",
messages: await convertToModelMessages(this.messages),
// The model sees one `codemode` tool and writes code that calls the connector.
tools: { codemode: this.#runtime().tool() },
// Cap the agent's tool-use loop.
stopWhen: stepCountIs(10),
});
return result.toUIMessageStreamResponse();
}
// The methods below are called from your client to drive the approval flow.
// List the writes that are paused waiting for approval.
@callable()
async pendingApprovals() {
return this.#runtime().pending();
}
// Approve a paused write. The runtime resumes the program and runs it.
@callable()
async approveExecution(executionId) {
return this.#runtime().approve({ executionId });
}
// Decline a paused write. The execution ends without saving.
@callable()
async rejectExecution(executionId, seq) {
return this.#runtime().reject({ executionId, seq });
}
// Undo an applied write by running the connector's revert.
@callable()
async rollbackExecution(executionId) {
await this.#runtime().rollback({ executionId });
}
}
export default {
async fetch(request, env) {
return (
(await routeAgentRequest(request, env)) ||
new Response("Not found", { status: 404 })
);
},
};import { AIChatAgent } from "@cloudflare/ai-chat";
import {
createCodemodeRuntime,
DynamicWorkerExecutor,
type CodemodeRuntimeHandle,
type PendingAction,
} from "@cloudflare/codemode";
import { callable, routeAgentRequest } from "agents";
import { createWorkersAI } from "workers-ai-provider";
import { convertToModelMessages, stepCountIs, streamText } from "ai";
import { AISearchConnector } from "./ai-search-connector";
// Code Mode stores its durable state (execution log, pending approvals) in a
// facet exported from the Worker entry. The runtime requires this export.
export { CodemodeRuntime } from "@cloudflare/codemode";
const INSTANCE_NAME = "knowledge-base";
// Seed content, so the agent has something to find on the first query.
const SEED_DOC = `# Getting started
AI Search indexes your content so an agent can search it and add to it.`;
export class Chat extends AIChatAgent<Env> {
// In-memory guard, so the one-time setup runs once per instance lifetime.
private ready = false;
// Create the AI Search instance with hybrid search enabled, then seed it.
// create() throws if the instance already exists, so the try/catch makes
// this safe to call on every message.
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 },
});
// Queue the seed document for indexing so the first search has content.
await this.env.AI_SEARCH.get(INSTANCE_NAME).items.upload(
"getting-started.md",
SEED_DOC,
);
} catch (err) {
// create() throws if the instance already exists, which is expected on
// every run after the first. Log anything else so real failures surface.
console.error("ensureInstance:", err);
}
this.ready = true;
}
// Build the Code Mode runtime for this request. The handle is cheap to
// create; the durable state lives in the Durable Object, not the handle.
#runtime(): CodemodeRuntimeHandle {
return createCodemodeRuntime({
ctx: this.ctx,
// Runs the model's generated code in an isolated Worker.
executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),
connectors: [new AISearchConnector(this.ctx, this.env)],
});
}
// Runs on every chat message from the client.
async onChatMessage() {
await this.ensureInstance();
const workersai = createWorkersAI({ binding: this.env.AI });
const result = streamText({
model: workersai("@cf/zai-org/glm-5.2"),
system:
"You help maintain a knowledge base. Use codemode to search existing " +
"content and to save new documents. Search before you answer.",
messages: await convertToModelMessages(this.messages),
// The model sees one `codemode` tool and writes code that calls the connector.
tools: { codemode: this.#runtime().tool() },
// Cap the agent's tool-use loop.
stopWhen: stepCountIs(10),
});
return result.toUIMessageStreamResponse();
}
// The methods below are called from your client to drive the approval flow.
// List the writes that are paused waiting for approval.
@callable()
async pendingApprovals(): Promise<PendingAction[]> {
return this.#runtime().pending();
}
// Approve a paused write. The runtime resumes the program and runs it.
@callable()
async approveExecution(executionId: string) {
return this.#runtime().approve({ executionId });
}
// Decline a paused write. The execution ends without saving.
@callable()
async rejectExecution(executionId: string, seq: number): Promise<boolean> {
return this.#runtime().reject({ executionId, seq });
}
// Undo an applied write by running the connector's revert.
@callable()
async rollbackExecution(executionId: string): Promise<void> {
await this.#runtime().rollback({ executionId });
}
}
export default {
async fetch(request: Request, env: Env) {
return (
(await routeAgentRequest(request, env)) ||
new Response("Not found", { status: 404 })
);
},
} satisfies ExportedHandler<Env>;型を生成します。
npx wrangler typesAI Search はリモートで動くため、エージェントを動かすには Worker をデプロイします。
Cloudflare アカウントでログインします。
npx wrangler loginWorker をデプロイし、インターネットからアクセスできるようにします。
npx wrangler deployWrangler は Worker の URL を出力します。たとえば https://kb-agent.<your-subdomain>.workers.dev です。次のステップで使います。
モデルは 1 つの codemode ツールを受け取ります。コンテンツの検索と保存を依頼すると、connector メソッドを呼ぶ短いプログラムを書きます。
// The model writes this program. It runs inside the Code Mode sandbox.
async () => {
// The read-only search runs immediately.
const existing = await aiSearch.search({ query: "onboarding steps" });
// Only save when the knowledge base has no matching content yet.
if (existing.chunks.length === 0) {
// saveDocument requires approval, so this call pauses the program here.
await aiSearch.saveDocument({
name: "onboarding.md",
content: "# Onboarding\nStep 1: create an account.",
});
}
return existing.chunks.length;
};aiSearch.search() はすぐに実行されます。プログラムが aiSearch.saveDocument() に達すると、ランタイムは呼び出しを pending として記録し、アップロードが走る前に実行を一時停止します。
クライアントは実行を始めるチャットメッセージを送り、その後 @callable() メソッドで承認を進めます。次のスクリプトは Agents SDK クライアント でその両方を行います。client.mjs として保存し、HOST をデプロイ済み Worker に設定して、node client.mjs で実行します。
import { AgentClient } from "agents/client";
// Your deployed Worker, without the protocol.
const HOST = "kb-agent.<your-subdomain>.workers.dev";
const client = new AgentClient({ agent: "Chat", name: "default", host: HOST });
await client.ready;
// 1. Ask the agent to find and save content. It writes a Code Mode program;
// the saveDocument call pauses for approval instead of running.
client.send(
JSON.stringify({
type: "cf_agent_use_chat_request",
id: crypto.randomUUID(),
init: {
method: "POST",
body: JSON.stringify({
messages: [
{
id: crypto.randomUUID(),
role: "user",
parts: [
{
type: "text",
text: "Search for onboarding steps. If there is none, save a document named onboarding.md with a short onboarding guide.",
},
],
},
],
}),
},
}),
);
// Give the turn time to run and pause at saveDocument.
await new Promise((r) => setTimeout(r, 20_000));
// 2. List the writes waiting for approval.
const pending = await client.call("pendingApprovals");
console.log("Pending approvals:", pending);
// 3. Approve the first one. The runtime replays the program: completed calls
// return their recorded results, and the approved saveDocument runs.
if (pending.length > 0) {
await client.call("approveExecution", [pending[0].executionId]);
console.log("Approved", pending[0].executionId);
// To roll back later, delete the uploaded document:
// await client.call("rollbackExecution", [pending[0].executionId]);
}
client.close();import { AgentClient } from "agents/client";
// Your deployed Worker, without the protocol.
const HOST = "kb-agent.<your-subdomain>.workers.dev";
const client = new AgentClient({ agent: "Chat", name: "default", host: HOST });
await client.ready;
// 1. Ask the agent to find and save content. It writes a Code Mode program;
// the saveDocument call pauses for approval instead of running.
client.send(
JSON.stringify({
type: "cf_agent_use_chat_request",
id: crypto.randomUUID(),
init: {
method: "POST",
body: JSON.stringify({
messages: [
{
id: crypto.randomUUID(),
role: "user",
parts: [
{
type: "text",
text: "Search for onboarding steps. If there is none, save a document named onboarding.md with a short onboarding guide.",
},
],
},
],
}),
},
}),
);
// Give the turn time to run and pause at saveDocument.
await new Promise((r) => setTimeout(r, 20_000));
// 2. List the writes waiting for approval.
const pending = await client.call("pendingApprovals");
console.log("Pending approvals:", pending);
// 3. Approve the first one. The runtime replays the program: completed calls
// return their recorded results, and the approved saveDocument runs.
if (pending.length > 0) {
await client.call("approveExecution", [pending[0].executionId]);
console.log("Approved", pending[0].executionId);
// To roll back later, delete the uploaded document:
// await client.call("rollbackExecution", [pending[0].executionId]);
}
client.close();pendingApprovals() からの各 PendingAction には executionId、seq 番号、メソッドと引数が含まれるため、決める前に保留中のドキュメントをユーザーに見せられます。承認メソッドの動作は次のとおりです。
approveExecution(executionId)はプログラムを再生し、承認されたsaveDocumentを実行します。ドキュメントはインデックス待ちになり、数秒後に検索可能になります。rejectExecution(executionId, seq)は保存せずに実行を終了します。rollbackExecution(executionId)は connector のrevertを実行して適用済みの書き込みを取り消し、アップロードしたドキュメントを削除します。
エージェントは次ができるようになりました。
- 読み取り専用ツールでナレッジベースを検索する。
- 人の承認で一時停止する書き込みツール経由で、新しいドキュメントを提案する。
- 承認後に同じプログラムを再開し、完了済みの作業を再実行しない。
- インデックス済みドキュメントを削除して、承認済みの保存をロールバックする。