名前付きサポート Agent を作成する

CloudflareBeginner
オンラインで実践に進む

はじめに

AI Agent は、推論したりツールを使ったりできるモデルとして説明されることがよくあります。しかし、モデルを追加する前に、アプリケーションにはもっと単純で重要な問いへの確実な答えが必要です。このリクエストを、現在継続中のどのセッションに届けるべきかという問いです。サポートアプリケーションでは、planning に関するすべてのやり取りを同じ論理セッションに戻し、billing とは分離する必要があります。

Cloudflare の Agents SDK は、この処理のために高レベルな Agent クラスを提供します。名前付き Agent はそれぞれ、1 つの SQLite Durable Object インスタンスによって支えられます。SDK が保存状態とリクエストのルーティングを管理し、Durable Objects がその下で安定した ID とストレージを提供します。この実験では、SDK を魔法のように扱わず、両方の層を確認します。

次の小さな、意図的に LLM を使わないサポートアプリケーションを構築します。

  1. SupportAgent は、1 つのサポートセッションが保存・実行する内容を定義します。
  2. SupportAgent バインディングは、クラスの namespace を表します。
  3. /agents/support-agent/planning は、planning という名前のインスタンスを選択します。
  4. initialStatethis.statesetState() によって、SDK はそのインスタンスの小さな状態を永続化します。

1 つの名前付きセッションに 2 つのメモを書き込み、別のセッションが分離されたままであることを確認します。その後、ローカルランタイム全体を停止・再起動し、同じコードを Cloudflare にデプロイします。Dashboard で実際のバインディングと namespace を確認し、使い捨てのリソースをすべて削除します。

このコースを始める前に、LabEx を Cloudflare アカウントに接続するを完了してください。 この実験では、LabEx VM のターミナル、Wrangler のデバイス認証、アカウント確認、アカウント ID の設定を学びます。また、O01〜O06 で扱う小さな TypeScript Worker と Durable Object の ID モデルをすでに理解していることを前提とします。Agents SDK、React、モデルに関する知識は必要ありません。

現在、公式ドキュメントでは SQLite ベースの Durable Objects を Workers Free で利用できます。この実験では、使い捨てのクラス namespace を 1 つ、少数の小さな Agent インスタンス、上限付きのリクエストだけを作成します。モデルは呼び出さず、Workers Paid も必要ありません。セットアップでは、/home/labex/project/named-support-agent に Node.js 22.22.0、Agents SDK 0.23.0、プロジェクトローカルの Wrangler 4.134.0 をインストールします。ログイン、クラウド状態の作成、コードのデプロイ、学習者による実装の完了は行いません。

VM を認証し、Agent を設定する

このステップでは、Wrangler を認証し、使用する学習用アカウントを確認し、まだデプロイせずに 1 つの Agent クラスを定義します。この新しい VM には独自のファイルシステムがあるため、Cloudflare Dashboard にログイン済みでも、その状態だけでは VM のターミナルは認証されません。

準備済みのプロジェクトに移動し、固定されたバージョンを確認します。

cd /home/labex/project/named-support-agent
node --version
npx wrangler --version
npm list agents --depth=0

Node.js v22.22.0、Wrangler 4.134.0agents@0.23.0 が表示されます。Agents SDK は基本的な Worker API よりも速く変更されるため、バージョンを固定することが重要です。

デバイス認証フローを開始します。

npx wrangler login --device --browser=false

Wrangler はブラウザー URL と短いデバイスコードを表示します。その URL を開き、コードを入力して、選択されたアカウントが専用の学習用アカウントであることを確認します。認証する前に、要求された権限も確認してください。Cloudflare のパスワードや API トークンをターミナルに入力してはいけません。

ブラウザーに成功が表示されたら、ターミナルに戻り、Wrangler の処理が完了するまで待ちます。構造化された ID 情報を取得します。

npx wrangler whoami --json

loggedIn: true であることを確認します。次に、アカウント名だけを表示し、LabEx Learning に対応する ID を非公開で選択します。

WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$ACCOUNT_ID"

専用の学習用アカウントの表示名が異なる場合は、正しい名前を確認してから LabEx Learning だけを置き換えてください。アカウント ID は秘密情報ではありませんが、このコマンドでは不要な表示を避けています。

一意な使い捨て Worker 名を作成します。

RUN="labex-c11-s01-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"

wrangler.jsonc を作成します。

cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-18",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "observability": {
    "enabled": true,
    "head_sampling_rate": 1
  },
  "durable_objects": {
    "bindings": [
      { "name": "SupportAgent", "class_name": "SupportAgent" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["SupportAgent"] }
  ]
}
JSON

SupportAgent バインディングは、Worker からクラス namespace にアクセスするためのハンドルです。v1 マイグレーションは、そのクラスを SQLite ストレージ付きで作成するよう Cloudflare に指示します。Agents は Durable Object の基盤を利用しており、SDK がこのリソース層を取り除くわけではありません。現在、SDK では nodejs_compat が必要です。これらの宣言だけでは、デプロイするまでクラウドリソースは作成されません。

名前付きサポート Agent を実装する

このステップでは、名前付きサポートセッションごとに共通する状態と HTTP 動作を実装します。Agent クラスは再利用可能な動作を表し、Agent インスタンスplanning のような 1 つの名前付きセッションを表します。Cloudflare では、同じクラスから多数のインスタンスを実行でき、各インスタンスが独立した状態を保持します。

src/index.ts を作成します。

cat > src/index.ts <<'TS'
import { Agent, routeAgentRequest } from "agents";

export interface SupportState {
  status: "new" | "active";
  noteCount: number;
  lastNote: string | null;
}

interface Env {
  SupportAgent: DurableObjectNamespace<SupportAgent>;
}

function json(value: unknown, init: ResponseInit = {}): Response {
  const headers = new Headers(init.headers);
  headers.set("content-type", "application/json; charset=utf-8");
  return new Response(JSON.stringify(value, null, 2), { ...init, headers });
}

export class SupportAgent extends Agent<Env, SupportState> {
  initialState: SupportState = {
    status: "new",
    noteCount: 0,
    lastNote: null
  };

  async onRequest(request: Request): Promise<Response> {
    if (request.method === "GET") {
      console.log(JSON.stringify({ event: "support_agent_read", instance: this.name, noteCount: this.state.noteCount }));
      return json({ instance: this.name, ...this.state });
    }

    if (request.method === "POST") {
      const body = await request.json<{ note?: unknown }>().catch(() => null);
      const note = typeof body?.note === "string" ? body.note.trim() : "";
      if (note.length < 1 || note.length > 120) {
        return json({ error: "note must contain 1-120 characters" }, { status: 400 });
      }

      this.setState({
        status: "active",
        noteCount: this.state.noteCount + 1,
        lastNote: note
      });
      console.log(JSON.stringify({ event: "support_agent_updated", instance: this.name, noteCount: this.state.noteCount }));
      return json({ instance: this.name, ...this.state });
    }

    return json({ error: "method not allowed" }, { status: 405 });
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname === "/health") {
      return json({ status: "ok" });
    }

    const agentResponse = await routeAgentRequest(request, env, {
      onBeforeRequest(incoming, { name }) {
        if (!/^[a-z][a-z0-9-]{1,31}$/.test(name)) {
          return json({ error: "invalid support session name" }, { status: 400 });
        }
        return incoming;
      }
    });
    return agentResponse ?? json({ error: "not found" }, { status: 404 });
  }
} satisfies ExportedHandler<Env>;
TS

重要な部分を内側から順に確認します。

  • initialState は、新しく作成された名前付きインスタンスが最初に持つ値です。
  • this.state は、そのインスタンスが現在保持している SDK 管理の状態を読み取ります。
  • setState() は、置き換える状態を同期的に検証し、インスタンスの SQLite ストレージに保存します。後の実験では、接続中のクライアントとの同期にも使用します。
  • this.name は、ルーティングによって選択された安定したインスタンス名です。クラス名やランダムなプロセス ID ではありません。
  • routeAgentRequest() は、/agents/<binding>/<name> を正しい Agent に対応付けます。SupportAgent バインディングは URL 上で support-agent になります。
  • onBeforeRequest は、Durable Object インスタンスを選択する前に形式の正しくない名前を拒否し、不要な永続 ID が作られるのを防ぎます。

ログには、合成したインスタンス名とカウントだけを記録します。後の Dashboard 演習でサポート内容が保持されないよう、メモ本文は意図的に除外しています。

実行前に型を生成してビルドする

このステップでは、設定を反映した型を生成し、デプロイせずに Worker をビルドします。生成された Worker 型は設定と TypeScript を接続し、ローカルプロセスやクラウドへのデプロイに時間を使う前に、誤記したバインディングやクラスを検出できるようにします。

wrangler.jsonc から型を生成します。

npx wrangler types

Wrangler は worker-configuration.d.ts を作成します。関係のない生成内容を表示せず、設定した Agent バインディングが含まれていることだけを確認します。

grep -n "SupportAgent" worker-configuration.d.ts | head

TypeScript コンパイラーを実行します。

npm run check

スクリプトのヘッダー以降に何も表示されなければ、コンパイラーはエラーを検出していません。次に、Cloudflare に接続したりリソースを作成したりせず、デプロイ用バンドルをビルドするよう Wrangler に指示します。

npx wrangler deploy --dry-run --outdir .labex/dry-run

アップロードサイズの概要と SupportAgent Durable Object バインディングが表示され、成功することを確認します。ドライランはバンドルと設定をローカルで検証するだけであり、認証、リモートストレージ、エッジでの動作を証明するものではありません。

ローカルで ID と再起動後の永続性を確認する

このステップでは、3 つの異なる性質を確認します。1 つの名前を繰り返し使うと同じ状態に到達すること、別の名前は分離されたままであること、保存された状態が開発プロセス全体の再起動後も残ることです。

ローカル Workers ランタイムをバックグラウンドで起動します。

npm run dev > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
  if curl --silent --fail http://127.0.0.1:8787/health; then
    break
  fi
  sleep 1
done

{"status":"ok"} が表示されます。新しい planning Agent を読み取ります。

curl --silent http://127.0.0.1:8787/agents/support-agent/planning | jq

最初は status: "new"noteCount: 0lastNote: null です。合成したメモを 2 つ追加します。

curl --silent --request POST --header 'content-type: application/json' \
  --data '{"note":"Customer cannot open the invoice"}' \
  http://127.0.0.1:8787/agents/support-agent/planning | jq
curl --silent --request POST --header 'content-type: application/json' \
  --data '{"note":"Asked customer to retry"}' \
  http://127.0.0.1:8787/agents/support-agent/planning | jq

2 回目のレスポンスには、instance: "planning"status: "active"noteCount: 2、および 2 番目のメモが表示されます。2 つのリクエストは同じ URL 名を使っているため、同じ論理 Agent に到達しました。

別のインスタンスを読み取ります。

curl --silent http://127.0.0.1:8787/agents/support-agent/support | jq

support は、カウント 0 の初期状態を独自に保持しています。2 つの名前はクラスの動作を共有しますが、保存される値は共有しません。

形式の正しくない名前を拒否します。

curl --silent --write-out '\nHTTP %{http_code}\n' \
  http://127.0.0.1:8787/agents/support-agent/INVALID

invalid support session name と HTTP 400 が表示されます。

起動したプロセスを正確に停止し、同じローカル永続化ディレクトリを使って新しいプロセスを起動します。

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npm run dev > .labex/dev-restart.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
  if curl --silent --fail http://127.0.0.1:8787/health; then
    break
  fi
  sleep 1
done
curl --silent http://127.0.0.1:8787/agents/support-agent/planning | jq
curl --silent http://127.0.0.1:8787/agents/support-agent/support | jq

Wrangler を完全に再起動した後も、planning2support0 のままです。これは 1 つの JavaScript プロセス内で 2 回読み取るより強い証拠です。データはローカルの Durable Object 永続化ディレクトリから復元されています。

デプロイしてクラウド上の Agent インスタンスを操作する

このステップでは、変更していないアプリケーションをデプロイし、実際にクラウドが所有する Agent インスタンスを操作します。ローカル環境で得た証拠だけでは、選択した Cloudflare アカウントがリソースを所有していることや、エッジランタイムが同じ名前付き ID を提供することを証明できません。

ローカルプロセスを停止してデプロイします。

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npx wrangler deploy

Wrangler はマイグレーション v1 を適用し、SQLite ベースの SupportAgent クラス namespace を作成し、公開 workers.dev URL を表示します。例の URL を置き換えて、その正確な URL を保存します。

WORKER_URL="https://YOUR_WORKER_URL"

ステートレスな health ルートを待ちます。

for attempt in $(seq 1 30); do
  if curl --silent --fail "$WORKER_URL/health"; then
    break
  fi
  sleep 2
done

次に、合成データを使ってクラウド上のインスタンスを操作します。

curl --silent --request POST --header 'content-type: application/json' \
  --data '{"note":"Cloud planning note one"}' \
  "$WORKER_URL/agents/support-agent/planning" | jq
curl --silent --request POST --header 'content-type: application/json' \
  --data '{"note":"Cloud planning note two"}' \
  "$WORKER_URL/agents/support-agent/planning" | jq
curl --silent --request POST --header 'content-type: application/json' \
  --data '{"note":"Independent support note"}' \
  "$WORKER_URL/agents/support-agent/support" | jq

両方のインスタンスを読み取ります。

curl --silent "$WORKER_URL/agents/support-agent/planning" | jq
curl --silent "$WORKER_URL/agents/support-agent/support" | jq

クラウド上の planning Agent のカウントは 2、独立した support Agent のカウントは 1 です。ローカルストレージとクラウドストレージは意図的に分離されていますが、どちらの環境も同じ「名前からインスタンスを選択する」契約を実装しています。

検証プログラムは、実行ごとに一意な Agent 名を 2 つ作成し、初期状態、同じ名前の永続性、異なる名前の分離、形式の正しくない名前の拒否を繰り返し確認します。ローカルファイルやコマンド履歴を、リモート動作の証拠として扱うことはありません。

ランタイムの証拠を Dashboard に結び付ける

このステップでは、ターミナルで確認した動作を、Dashboard に表示されるバインディング、namespace、ログに結び付けます。スクリーンショットにある名前、時刻、合計値は検証時の実行例です。自分のターミナルで作成した一意な labex-c11-s01-... 名を使ってください。

Cloudflare Dashboard で Workers & Pages を開き、使い捨ての Worker を選択します。概要には、デプロイされたアプリケーションと最近のトラフィックが表示されます。

Workers and Pages に表示されたデプロイ済みの名前付きサポート Agent Worker

Worker の Bindings タブを開きます。SupportAgentSupportAgent Durable Object クラスに接続されていることを確認します。最初のラベルは Worker コードとルーティングで使用される名前です。クラス名は、src/index.ts からエクスポートされた実装を識別します。

SupportAgent バインディングと Durable Object クラスの接続

Developer Platform のナビゲーションから Durable Objects を開き、正確な Worker が所有する namespace を選択します。クラスが SupportAgent で、Storage: SQL になっていることを確認します。namespace はクラス単位のコレクションです。planningsupport、検証プログラムの名前は、その中にある個々のインスタンスです。例の画像では、実行ごとに異なる namespace ID をプライバシー保護のため省略しています。

SQL ストレージを使用する SupportAgent namespace

Worker に戻り、Observability → Logs を開きます。support_agent_read または support_agent_updated アプリケーションイベントを見つけて展開します。合成された instancenoteCount を、上限付きのリクエストと照合します。アプリケーションはメモ本文をログに記録しません。

インスタンス名とメモのカウントを含む構造化されたサポート Agent イベント

Dashboard のメトリクスとログは遅れて表示されることがあるため、最近のグラフが空でも、それだけでは判断できません。認証済み API、namespace の所有関係、稼働中のランタイムチェックが引き続き正式な根拠です。スクリーンショットは、同じ関係が画面上でどこに表示されるかを示すものであり、学習者の提出物ではありません。

Agent namespace と Worker を削除する

このステップでは、VM の認証が有効なうちに、対象の Agent namespace と Worker を完全に削除します。Agent の状態は Durable Object クラス namespace に属するため、Worker スクリプトだけを削除しても、その保存状態を消去する明示的な要求にはなりません。Cloudflare のマイグレーションは追記専用です。v1 を残し、対象クラスを削除する v2 マイグレーションを追加します。

Agent のエクスポートを含まない、小さなクリーンアップ用エントリーポイントを作成します。

cat > src/cleanup.ts <<'TS'
export default {
  fetch() {
    return Response.json({ status: "cleanup" }, { status: 410 });
  }
};
TS

元の設定から正確な名前とアカウントを読み取り、wrangler.cleanup.jsonc を作成します。

RUN="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).name)')"
ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/cleanup.ts",
  "compatibility_date": "2026-09-18",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["SupportAgent"] },
    { "tag": "v2", "deleted_classes": ["SupportAgent"] }
  ]
}
JSON

v1 を残すことが重要です。マイグレーション履歴は、書き換える説明文ではなく一連の履歴だからです。v2 によって、クラス namespace と、その中にある使い捨ての名前付きインスタンスがすべて完全に削除されます。

削除マイグレーションをデプロイします。

npx wrangler deploy --config wrangler.cleanup.jsonc

マイグレーションの出力を読み、自分の一意な Worker に属する SupportAgent だけが削除されたことを確認します。その後、残ったステートレスなクリーンアップ Worker を削除します。

npx wrangler delete --config wrangler.cleanup.jsonc --force

確認を求められたら、正確な labex-c11-s01-... アプリケーションを確認します。Workers & Pages で、その Worker が存在しないことを確認します。このテスト用アカウントに無関係なアプリケーションがない場合、成功した実行では一覧全体が空になります。ほかのプロジェクトがあるアカウントでは、それらの無関係な行は残してください。

使い捨て Worker の削除後、プロジェクトが表示されていない Workers and Pages

Durable Objects を開き、削除した Worker が所有していた namespace も存在しないことを確認します。テスト用アカウントに無関係な namespace がない場合、Durable Objects の一覧には何も表示されません。この例に合わせるためだけに、別のプロジェクトに属する namespace を削除してはいけません。

SupportAgent クラスの削除後、namespace が表示されていない Durable Objects

過去のログは一時的に残ることがありますが、アクティブなリソースではありません。

ログアウトする前に、認証済みの存在確認を実行します。

python3 .labex/verify.py deleted

選択したアカウントに所有リソースがどちらも残っていないことを証明するのは、PASS: deleted だけです。認証を失ったことによる 404 やネットワークエラーは、削除の証拠として認められません。

この VM の認証を取り消す

このステップでは、使い捨て VM に保存された OAuth 認証を削除します。クラウドリソースのクリーンアップとローカル認証情報のクリーンアップは別の問題です。Worker と namespace はすでに削除されています。

ログアウトします。

npx wrangler logout

Wrangler に構造化された状態を問い合わせます。

npx wrangler whoami --json

結果に "loggedIn": false が明示的に含まれている必要があります。この構造化された値は、通常のメッセージより確実です。テストした Wrangler のバージョンでは、認証状態によって通常の出力が複数存在する可能性があるためです。ネットワーク障害は判断材料にならないため、ログアウトしたと解釈せず、再試行してください。

この実験で作成した 2 種類の状態を削除しました。リモートのサポート Agent namespace と Worker、そして VM のローカル認証です。

まとめ

AI という用語の裏側にある基盤を隠さずに、このコースで最初の Cloudflare Agent を構築しました。1 つの Agent クラスが動作を定義し、そのバインディングが SQLite Durable Object namespace を公開し、安定した URL 名が 1 つの論理インスタンスを選択することを学びました。また、Agents SDK が this.statesetState() を通じて initialState の更新を永続化することも確認しました。

ローカル環境で、同じ名前の永続性、異なる名前の分離、プロセス再起動後の永続性を検証しました。Cloudflare の学習用アカウントでも同じ契約を確認し、Dashboard でランタイムの証拠をバインディング、namespace、プライバシーに配慮したログに結び付けました。最後に、クラウドリソースと VM の認証を両方削除しました。次の実験では、ブラウザークライアントをこの状態に接続し、制御されたリアルタイム同期を導入します。