はじめに
durable Agent はサポートキューを記憶できます。ただし、実用的なダッシュボードでは、接続中のすべての画面を常に最新の状態に保つ必要があります。ポーリングでは、サーバーに新しいコピーを何度も要求します。一方、Cloudflare Agents SDK は WebSocket を開きます。WebSocket は、状態が変化すると同じ名前の Agent に接続しているすべてのクライアントへ更新をすぐに送信できる、長時間維持される双方向接続です。
この実験では、意図的に小さくした LLM を使わないダッシュボードを構築します。2 つの独立したバニラ JavaScript クライアント、Dispatcher と Observer が SupportDashboard:planning に接続します。Dispatcher は @callable() が付いたサーバーメソッドを呼び出します。このメソッドはチケットを検証して Agent の状態を 1 回更新し、SDK はその結果の状態を両方のクライアントへブロードキャストします。タイトルが無効な場合はサーバーで拒否され、共有リビジョンは進みません。
この実験では、アプリケーションに必要な次の 4 つの要素だけを扱います。
AgentClientはブラウザの WebSocket 接続を維持します。onStateUpdateは、サーバーが状態をブロードキャストした後に画面を再描画します。@callable()は、接続中のクライアントから呼び出せる特定のサーバーメソッドを公開します。setState()は、正規の次の状態を 1 つ保存し、同期を開始します。
この例では合成したサポートテキストと、公開された使い捨て用 Worker を使用します。目的はプロトコルに集中することです。入力検証はユーザー認証ではありません。実際のサポートツールで顧客データや変更操作を公開する前に、ID と認可のレイヤーを追加する必要があります。
このコースを直接開始する前に、LabEx を Cloudflare アカウントに接続するを完了してください。 新しい LabEx VM ごとに、独自の Wrangler 認証が必要です。S01 を推奨します。この実験は名前付き Agent の ID、durable state、明示的なクリーンアップを基にしていますが、React や AI モデルの知識は必要ありません。
VM を認証してダッシュボードを設定する
このステップでは、新しい VM を認証し、対象の Cloudflare アカウントを確認して、ダッシュボードで使用する 1 つの Agent namespace を宣言します。
準備済みのプロジェクトへ移動し、固定されたランタイムを確認します。セットアップでは依存関係をインストールし、画面の外枠だけを用意しています。Cloudflare の認証や Agent の実装はまだ行っていません。
cd /home/labex/project/support-dashboard-agent
node --version
npx wrangler --version
npm list agents vite @cloudflare/vite-plugin --depth=0
Node.js は v22.22.0、Wrangler は 4.134.0、Agents SDK は 0.23.0、Vite は 8.3.0、Cloudflare Vite plugin は 1.55.0 と表示されるはずです。
この VM を認証し、構造化された ID 情報を確認します。
npx wrangler login --device --browser=false
npx wrangler whoami --json
表示されたリンクをブラウザで開き、短いコードを入力します。専用の学習用アカウントであることを確認し、認証する前に権限を確認してください。ターミナルに戻ったら loggedIn: true を確認し、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"
RUN="labex-c11-s02-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"
学習用アカウントの名前が別の場合は、対象アカウントを確認したうえで、LabEx Learning の部分だけを置き換えてください。設定を作成します。
cat > wrangler.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/server.ts",
"compatibility_date": "2026-09-18",
"compatibility_flags": ["nodejs_compat"],
"workers_dev": true,
"preview_urls": false,
"observability": { "enabled": true },
"durable_objects": {
"bindings": [
{ "name": "SupportDashboard", "class_name": "SupportDashboard" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["SupportDashboard"] }
]
}
JSON
この binding によって Agent クラスの namespace が選択されます。インスタンス名は各ブラウザクライアントから渡されます。設定だけでは、クラウド上にリソースは作成されません。
検証済み callable メソッドを実装する
このステップでは、共有キューの状態と、ブラウザから呼び出せる唯一の変更処理を実装します。
変更ルールはサーバーが管理します。ブラウザは更新を要求できますが、タイトルや優先度が有効かどうかを判断してはいけません。src/server.ts を作成します。
cat > src/server.ts <<'TS'
import { Agent, callable, routeAgentRequest } from "agents";
type Priority = "normal" | "urgent";
type Ticket = {
id: number;
title: string;
priority: Priority;
};
export type DashboardState = {
tickets: Ticket[];
revision: number;
lastUpdatedBy: string;
};
interface Env {
SupportDashboard: DurableObjectNamespace<SupportDashboard>;
}
export class SupportDashboard extends Agent<Env, DashboardState> {
initialState: DashboardState = {
tickets: [],
revision: 0,
lastUpdatedBy: "system"
};
@callable()
addTicket(titleInput: string, priorityInput: string): DashboardState {
const title = typeof titleInput === "string" ? titleInput.trim() : "";
if (title.length < 3 || title.length > 80) {
throw new Error("title must contain 3-80 characters");
}
if (priorityInput !== "normal" && priorityInput !== "urgent") {
throw new Error("priority must be normal or urgent");
}
const priority: Priority = priorityInput;
const next: DashboardState = {
tickets: [
...this.state.tickets,
{ id: this.state.revision + 1, title, priority }
].slice(-6),
revision: this.state.revision + 1,
lastUpdatedBy: "dispatcher"
};
this.setState(next);
console.log(JSON.stringify({
event: "support_queue_updated",
instance: this.name,
revision: next.revision,
ticketCount: next.tickets.length
}));
return next;
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return (await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 });
}
};
TS
@callable() は明示的な RPC 境界です。デコレートされたメソッドだけが Agent クライアントプロトコル経由で呼び出せます。検証は setState() の前に実行されるため、拒否された呼び出しによってリビジョンが進むことはありません。合成チケットを最新の 6 件だけに制限することで、デモの状態サイズを抑えています。構造化ログにはインスタンス、リビジョン、件数が含まれますが、チケット本文は含まれません。
2 つのバニラブラウザクライアントを接続する
このステップでは、現在のデコレータビルドパスを設定し、2 つの独立したバニラクライアントを 1 つの名前付き Agent に接続します。
現在の SDK デコレータは、JavaScript 標準デコレータ変換を使用します。そのため、手動で作成したプロジェクトには Agents TypeScript preset と Agents Vite plugin の両方が必要です。TypeScript の従来の experimentalDecorators モードは有効にしないでください。
cat > tsconfig.json <<'JSON'
{
"extends": "agents/tsconfig",
"compilerOptions": {
"noEmit": true
},
"include": [
"src/**/*.ts",
"vite.config.ts",
"worker-configuration.d.ts"
]
}
JSON
cat > vite.config.ts <<'TS'
import { cloudflare } from "@cloudflare/vite-plugin";
import agents from "agents/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [agents(), cloudflare()]
});
TS
src/client.ts を作成します。
cat > src/client.ts <<'TS'
import { AgentClient } from "agents/client";
import type { DashboardState } from "./server";
function required<T>(selector: string): T {
const element = document.querySelector(selector);
if (!element) throw new Error(`Missing page element: ${selector}`);
return element as unknown as T;
}
const dispatcherView = required<HTMLDivElement>("#dispatcher");
const observerView = required<HTMLDivElement>("#observer");
const statusView = required<HTMLParagraphElement>("#status");
const errorView = required<HTMLParagraphElement>("#error");
const titleInput = required<HTMLInputElement>("#title");
const priorityInput = required<HTMLSelectElement>("#priority");
const form = required<HTMLFormElement>("#ticket-form");
function render(target: HTMLDivElement, state: DashboardState | undefined) {
if (!state) {
target.innerHTML = '<p class="empty">Waiting for initial state…</p>';
return;
}
const tickets = state.tickets.map((ticket) =>
`<div class="ticket ${ticket.priority}"><strong>#${ticket.id}</strong> ${ticket.title}<br><small>${ticket.priority}</small></div>`
).join("");
target.innerHTML = `<span class="revision">Revision ${state.revision}</span>${tickets || '<p class="empty">No tickets yet</p>'}`;
}
const shared = {
agent: "SupportDashboard",
name: "planning",
host: window.location.host
};
const dispatcher = new AgentClient<DashboardState>({
...shared,
onStateUpdate: (state) => render(dispatcherView, state)
});
const observer = new AgentClient<DashboardState>({
...shared,
onStateUpdate: (state) => render(observerView, state)
});
Promise.all([dispatcher.ready, observer.ready]).then(() => {
render(dispatcherView, dispatcher.state);
render(observerView, observer.state);
statusView.textContent = "Both clients are connected to SupportDashboard:planning";
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
errorView.textContent = "";
try {
await dispatcher.call("addTicket", [titleInput.value, priorityInput.value]);
} catch (cause) {
errorView.textContent = cause instanceof Error ? cause.message : String(cause);
}
});
TS
画面上では 1 ページに表示されますが、これらは実際には 2 つの WebSocket クライアントです。両方とも同じクラスと名前にルーティングされるため、同じ状態ブロードキャストを受信します。呼び出しを行うのは Dispatcher だけです。Observer によって、同期がコピーした DOM の更新ではなく、サーバー主導で行われることを確認できます。
型を生成して両方をビルドする
このステップでは、共有状態の契約を型チェックし、ランタイムを起動する前に Worker とブラウザアプリケーションをビルドします。
正確な binding 設定から環境型を生成します。
npx wrangler types
grep -n "SupportDashboard" worker-configuration.d.ts | head
Worker、ブラウザクライアント、Vite 設定をまとめて TypeScript で実行します。
npm run check
コンパイラ診断が表示されなければ、状態の形、callable サーバー、DOM クライアントが一致しています。本番用の 2 つのターゲットをビルドします。
npm run build
find dist -maxdepth 3 -type f | sort | sed -n '1,16p'
Vite は Worker 環境とクライアント環境を報告します。Cloudflare plugin は Worker バンドルを作成し、ビルド済みの静的ページを関連付けます。Agents plugin は現在のデコレータ変換を適用します。ビルドの成功で確認できるのはパッケージングであり、WebSocket の動作、アカウント所有権、リモートデプロイの成否ではありません。
ローカルで同期と拒否を確認する
このステップでは、有効な更新後に 2 つのローカルクライアントが同じ状態へ収束する様子と、無効な更新後も状態が変わらないことを確認します。
ローカルの Vite と Workers ランタイムをバックグラウンドジョブとして起動します。
CI=true npm run dev > .labex/dev.log 2>&1 < /dev/null &
echo $! > .labex/dev.pid
for attempt in $(seq 1 40); do
if curl --silent --fail http://127.0.0.1:5173/ > /dev/null; then
break
fi
sleep 1
done
curl --silent --head http://127.0.0.1:5173/ | head
LabEx デスクトップ内のブラウザで http://localhost:5173 を開きます。緑色のステータスに両方のクライアントが接続済みと表示されるまで待ちます。最初は両方のカードがリビジョン 0 で、チケットはありません。
用意されているタイトルをそのまま使い、Add with Dispatcher をクリックします。両方のカードがリビジョン 1 に進み、同じチケットを表示するはずです。Dispatcher はまず WebSocket 経由で RPC フレームを送信します。addTicket() が Agent 内で引数を検証し、setState(next) がリビジョン 1 を保存してブロードキャストします。2 つの onStateUpdate ハンドラーがそれぞれのカードを個別に再描画します。
次に、タイトルを x に置き換えてもう一度送信します。ページに title must contain 3-80 characters と表示され、両方のカードはリビジョン 1 のままになります。これは、状態の書き込み前に検証が行われたことを示す有用な証拠です。
独立したローカルチェックを実行します。
python3 .labex/verify.py local
検証ツールは、画面に表示される例をそのまま信頼せず、実行ごとに新しい一意の名前を使用します。2 つのクライアントを開いて収束を確認し、別の名前ではリビジョンが 0 のままであることを確認します。その後、無効な更新を送信し、共有リビジョンが変化しないことを確認します。
デプロイしてクラウド上のダッシュボードを確認する
このステップでは、本番バンドルをデプロイし、Cloudflare 上で同じ 2 クライアントの契約を確認して、その動作を Dashboard の証拠と結び付けます。
ローカルプロセスを正確に停止してから、本番ビルドをデプロイします。
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npm run deploy
Wrangler は migration v1 を適用し、Worker と静的クライアントをアップロードして、workers.dev URL を表示します。その URL を正確に保存します。
WORKER_URL="https://YOUR_WORKER_URL"
for attempt in $(seq 1 30); do
if curl --silent --fail "$WORKER_URL/" > /dev/null; then
break
fi
sleep 2
done
組み込みブラウザで URL を開きます。Cloud dashboard ticket を Urgent として追加します。両方のカードに同じリビジョンと赤い urgent マーカーが表示されるはずです。次に x を送信します。拒否メッセージが表示されますが、両方のリビジョンは変わりません。これらはクラウドが管理する新しい Agent インスタンスであり、ローカルの Vite 状態とは意図的に分離されています。

この実際のテスト実行では、Dispatcher が書き込みを行い、Observer が同じブロードキャストを受信しました。チケット本文とリビジョンは、使い捨てのコースリソースで使用した例です。実際の値は異なる場合があります。

エラーは入力欄の横に表示されますが、どちらのカードも進みません。両方のカードに表示された変化しないリビジョンに注目してください。これは、サーバーが setState() を呼び出す前に引数を拒否したことを示します。
Cloudflare Dashboard で Workers & Pages を開き、正確な labex-c11-s02-... Worker を選択します。Bindings タブで、SupportDashboard が SupportDashboard Durable Object クラスを指していることを確認します。Durable Objects で、その namespace が SQL ストレージを使用していることを確認します。最後に Observability → Logs を開き、support_queue_updated でフィルターしてイベントを 1 つ展開します。instance が planning であること、リビジョン、チケット件数を照合してください。チケットタイトルは意図的に含まれていません。

概要画面では、これまで個別に扱ってきた複数の要素をまとめて確認できます。workers.dev ドメインが Worker に到達し、binding が durable state に接続し、エラー数 0 のカウンターが簡単なヘルスシグナルになります。このスクリーンショットの Worker 名は、ある合格テスト実行で生成されたものです。

binding グラフには、正確な Worker と Durable Object SupportDashboard の接続が表示されるはずです。これは設定の証拠であり、2 クライアントの動作確認の代わりにはなりません。

namespace ページには、Agent クラスの背後にある durable storage が表示され、Storage: SQL と報告されます。プライバシー保護のため、教材画像では不透明な namespace ID を隠しています。学習者がこの ID をコピーする必要はありません。

展開したイベントには、合成した instance 名、リビジョン、チケット件数が含まれますが、チケットタイトルは含まれません。これは意図的なデータ最小化です。ログは、機密性のある可能性があるユーザーコンテンツをコピーせずに、動作の診断に役立つ必要があります。
Dashboard のデータは遅れて到着することがあるため、最近のログが空でも判断はできません。認証済みの設定、所有する namespace、独立したライブ AgentClient チェックを正式な根拠とします。
python3 .labex/verify.py deployed
python3 .labex/verify.py observed
最初のチェックでは、新しいリモート名を作成し、画面に表示される planning の例を信頼せずに、同期、分離、拒否を確認します。2 つ目のチェックでは、所有する正確なリソースを残したまま、読み取り専用で Dashboard を確認できます。
Dashboard namespace と Worker を削除する
このステップでは、まず Agent クラスの namespace を明示的に削除し、VM がまだ認証されている間に残りの Worker を削除します。
キューは Durable Object クラスの namespace に保存されます。そのため、残りのステートレス Worker を削除する前に、このクラスを明示的に削除します。クリーンアップ用のエントリーポイントを作成します。
cat > src/cleanup.ts <<'TS'
export default {
fetch() {
return Response.json({ status: "cleanup" }, { status: 410 });
}
};
TS
元の migration は残したまま、削除用に v2 を追加します。
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": ["SupportDashboard"] },
{ "tag": "v2", "deleted_classes": ["SupportDashboard"] }
]
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force
python3 .labex/verify.py deleted
migration の履歴は追加専用です。v1 を書き換えても、Cloudflare ですでに適用された遷移を表せません。Dashboard で、正確な Worker とその SupportDashboard namespace が存在しないことを確認します。アカウントに他のリソースがある場合は、それらを残してください。

テストしたアカウントでは、削除後に Workers & Pages の概要へ戻りました。学習用アカウントには関係のない Worker が含まれている場合があります。そのため、アカウントが空になることを期待せず、正確な labex-c11-s02-... という名前が消えていることを確認してください。

合格したテストアカウントでは、削除後に空の Durable Objects 概要へ戻りました。他の namespace があるアカウントでは、それらを残し、この実験が所有する namespace だけが削除されたことを確認してください。
この VM の認証を取り消す
このステップでは、この使い捨て VM にだけ保存されている OAuth 認証を削除し、構造化されたログアウト状態を確認します。
クラウドのクリーンアップは完了していますが、この使い捨て VM にはローカルの OAuth grant がまだ残っています。これを削除し、構造化された状態を確認します。
npx wrangler logout
npx wrangler whoami --json
python3 .labex/verify.py logout
JSON に "loggedIn": false が明示的に含まれている必要があります。ネットワークエラーはログアウトの証拠ではありません。接続が戻ったら、状態の読み取りを再試行してください。公開された合成ダッシュボード、その durable state、この VM の認証はすべて削除されました。
まとめ
React や言語モデルを導入せずに、1 つの durable な名前付き Agent をリアルタイムのブラウザアプリケーションへ変換しました。2 つの AgentClient 接続が SupportDashboard:planning を選択し、検証済みの @callable() メソッドが変更処理を管理し、setState() が正規のリビジョンを 1 つ保存し、SDK がその状態を両方の onStateUpdate ハンドラーへブロードキャストしました。
また、現在のデコレータパスで agents/tsconfig と agents/vite の両方が必要な理由、WebSocket RPC とクライアントによる直接的な状態変更の違い、拒否された入力が影響を与えないこと、Cloudflare 上での同期と名前の分離、プライバシーを考慮した証拠の確認方法を学びました。最後に、クラス namespace、Worker、VM の認証を明示的に削除しました。



