はじめに
モデルが回答を生成している間に単語が少しずつ表示されると、サポートアシスタントは応答性が高く感じられます。また、ページを更新しても会話が消えなければ、より信頼できる印象になります。これらは別々のエンジニアリング要件です。ストリーミングは回答の断片を順次配信し、永続化は完了したメッセージを保存して、同じ名前の会話を後から復元できるようにします。
この実験では、Cloudflare がサポートするチャット統合を使って、両方の動作を追加します。
AIChatAgentは、Agent の SQLite ベースの Durable Object にチャットメッセージと再開可能なストリームデータを保存します。streamText()は、完全な回答を待つのではなく、制限付きの Workers AI レスポンスを生成します。useAgentChat()は、これらの断片を React のメッセージリストに変換し、保存済みの履歴を復元します。- 有効期間の短い署名付きトークンによって、すべての WebSocket リクエストと履歴リクエストを、名前付きの 1 つの会話に限定します。
ブラウザクライアントは小さな fixture として用意されているため、React の知識は隠れた前提条件ではありません。Agents SDK のこの概念に必要な、現在のフック呼び出しとメッセージ表示部分だけを編集します。シナリオでは、合成したサポートテキスト、短いモデルレスポンス、破棄可能なリソースを使用します。無料割り当ては他のアカウント利用と共有されます。アカウントに Workers AI の残りの割り当てがない場合は、有料プランを有効にせずに中止してください。
このコースに直接入る前に、LabEx を Cloudflare アカウントに接続するを完了してください。新しい LabEx VM ごとに、独自の Wrangler 認証が必要です。S01 と S02 を先に行うことを推奨します。この実験は、名前付き Agent の識別、SQLite の状態、WebSocket クライアントを前提にしています。ただし、これらの実験の VM とリソースはここでは再利用されません。
VM を認証して Chat Worker を設定する
このステップでは、新しい VM を認証し、チャットに必要な 3 つの Cloudflare バインディングを定義します。
名前付きチャットは、それぞれ 1 つの SQLite Durable Object インスタンスによって管理されます。Worker には、推論用の Workers AI バインディングと、セッション境界用のシークレットバインディングも必要です。
ターミナルを開き、用意されたプロジェクトに移動します。
cd /home/labex/project/persistent-support-chat
新しい VM を認証します。
npx wrangler login
表示されたリンクを開き、専用の学習用アカウントに対して、Wrangler に必要な権限を承認します。その後、ターミナルに戻ります。構造化された結果を確認します。
npx wrangler whoami --json
"loggedIn": true を確認し、アカウント名を確認したうえで、そのアカウントの実際の ID をコピーします。一時的に使う一意の Worker 名とともに、明示的に保存します。
ACCOUNT_ID="paste-your-confirmed-account-id"
RUN="labex-c11-s03-$(openssl rand -hex 6)"
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 },
"ai": { "binding": "AI", "remote": true },
"durable_objects": {
"bindings": [
{ "name": "SupportChatAgent", "class_name": "SupportChatAgent" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["SupportChatAgent"] }
]
}
JSON
AI バインディングにより、API キーを埋め込まずに Worker から Workers AI にアクセスできます。Workers AI は、ローカル開発中も含め、常に Cloudflare がホストするモデルを使用します。remote: true によって、この動作を明示しています。Durable Object のバインディングはクラス名を対応付けます。ブラウザは後で、別個のインスタンス名 planning を指定します。まだ何もデプロイされていません。
制限付き AIChatAgent を実装する
このステップでは、サーバー側のチャットクラス、制限付き推論、署名付きルーティング境界を実装します。
AIChatAgent は、永続的なチャット履歴と再開可能なストリーム保存機能をベースの Agent に追加します。モデル呼び出しは自分で記述します。チャットプロトコルと永続化は統合機能が処理します。
src/server.ts を作成します。
cat > src/server.ts <<'TS'
import { AIChatAgent, type OnChatMessageOptions } from "@cloudflare/ai-chat";
import { convertToModelMessages, streamText } from "ai";
import { routeAgentRequest } from "agents";
import { createWorkersAI } from "workers-ai-provider";
import { verifySessionRequest } from "./session-auth";
interface Env {
AI: Ai;
SupportChatAgent: DurableObjectNamespace<SupportChatAgent>;
SESSION_SIGNING_KEY: string;
}
export class SupportChatAgent extends AIChatAgent<Env> {
maxPersistedMessages = 12;
async onChatMessage(_onFinish: unknown, options?: OnChatMessageOptions) {
console.log(JSON.stringify({
event: "support_chat_turn_started",
requestId: options?.requestId ?? "unknown",
messageCount: this.messages.length,
continuation: Boolean(options?.continuation)
}));
const workersai = createWorkersAI({ binding: this.env.AI });
const result = streamText({
model: workersai("@cf/zai-org/glm-4.7-flash", {
reasoning_effort: null,
chat_template_kwargs: { enable_thinking: false }
}),
system: "You are a concise support assistant. Answer synthetic questions in one sentence and never request credentials.",
messages: await convertToModelMessages(this.messages),
maxOutputTokens: 64,
temperature: 0,
abortSignal: options?.abortSignal
});
return result.toUIMessageStreamResponse();
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const authorize = (candidate: Request, route: { name: string }) =>
verifySessionRequest(candidate, route.name, env.SESSION_SIGNING_KEY);
return (await routeAgentRequest(request, env, {
onBeforeConnect: authorize,
onBeforeRequest: authorize
})) ?? new Response("Not found", { status: 404 });
}
};
TS
ここでは 3 つの制限が重要です。maxPersistedMessages は保存する履歴の増加を制限し、maxOutputTokens は各モデルレスポンスの長さを制限します。また、system プロンプトは 1 文で回答するよう指示します。GLM 4.7 Flash は、表示されるテキストを生成する前に、トークン予算を内部推論に使うことがあります。そのため、この短いサポートワークフローでは thinking を明示的に無効にしています。これにより、学習者には空のアシスタントバブルではなく、簡潔な回答が表示されます。abortSignal を転送すると、ターンを明示的に停止したときに SDK が上流の推論をキャンセルできます。
2 つのルーティングフックは、どちらも指定された HMAC 検証機能を使用します。onBeforeConnect は WebSocket ハンドシェイクを保護し、onBeforeRequest は /get-messages などの HTTP ヘルパーも保護します。ブラウザが受け取るのは署名付きクレームであり、署名シークレットではありません。ログにはリクエスト ID と件数だけを記録し、サポートテキストは意図的に含めません。
サポートされている React チャットフックを接続する
このステップでは、用意されたページの土台を、現在サポートされている React フックに接続します。
用意されている HTML とスタイルは、表示用の土台にすぎません。ここで、その土台を名前付き Agent に接続します。TypeScript と Vite の設定を作成します。
cat > tsconfig.json <<'JSON'
{
"extends": "agents/tsconfig",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["@cloudflare/workers-types", "vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "worker-configuration.d.ts"]
}
JSON
cat > vite.config.ts <<'TS'
import { cloudflare } from "@cloudflare/vite-plugin";
import react from "@vitejs/plugin-react";
import agents from "agents/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), agents(), cloudflare()]
});
TS
src/client.tsx を作成します。
cat > src/client.tsx <<'TSX'
import { useAgentChat } from "@cloudflare/ai-chat/react";
import { useAgent } from "agents/react";
import { Suspense } from "react";
import { createRoot } from "react-dom/client";
function SupportChat() {
const parameters = new URLSearchParams(window.location.search);
const session = parameters.get("session") ?? "";
const token = parameters.get("token") ?? "";
if (!session || !token) {
return <main><h1>Signed session required</h1><p className="help">Open the complete URL printed by the token command.</p></main>;
}
const agent = useAgent({
agent: "SupportChatAgent",
name: session,
host: window.location.host,
query: { token }
});
const { messages, sendMessage, status, error } = useAgentChat({ agent });
return (
<main>
<p className="eyebrow">Cloudflare Agents SDK</p>
<h1>Persistent Support Chat</h1>
<p className="session">Conversation: <strong>{session}</strong></p>
<p className="status">Status: <strong>{status}</strong></p>
<section className="messages" aria-live="polite">
{messages.length === 0 && <p className="empty">No saved messages in this conversation.</p>}
{messages.map((message) => (
<article className={`message ${message.role}`} key={message.id}>
<span className="role">{message.role}</span>
{message.parts.map((part, index) =>
part.type === "text" ? <span key={index}>{part.text}</span> : null
)}
</article>
))}
</section>
<form => {
event.preventDefault();
const input = event.currentTarget.elements.namedItem("message") as HTMLInputElement;
const text = input.value.trim();
if (!text) return;
sendMessage({ text });
input.value = "";
}}>
<input name="message" defaultValue="What does pending invoice status mean?" maxLength={160} aria-label="Support question" />
<button type="submit" disabled={status === "streaming" || status === "submitted"}>Send</button>
</form>
{error && <p className="error" role="alert">{error.message}</p>}
</main>
);
}
createRoot(document.getElementById("root")!).render(
<Suspense fallback={<main><p>Restoring the signed conversation…</p></main>}>
<SupportChat />
</Suspense>
);
TSX
useAgent() は、SupportChatAgent:<session> への署名付き WebSocket 接続を管理します。useAgentChat() はその接続に AI チャットプロトコルを重ね、メッセージ、ストリーミング状態、送信処理、初回の履歴復元を担当します。ブラウザの WebSocket ハンドシェイクではカスタム認証ヘッダーを追加できないため、トークンは接続 URL に含めます。トークンは 10 分後に有効期限が切れ、1 つの合成会話だけに限定されます。
型を生成し、両方の側をビルドする
このステップでは、正確な環境型を生成し、ランタイムを起動する前に Worker 側とブラウザ側の両方をコンパイルします。
Wrangler は設定から正確なバインディング型を生成できます。通常の TypeScript と Vite のビルドの前に実行してください。
npx wrangler types
npm run check
npm run build
型チェックによって、this.env.AI、Durable Object の namespace、シークレットバインディングが、宣言した Env に対応付けられます。Vite のビルドでは、Worker 用バンドルとブラウザ用バンドルがそれぞれ 1 つずつ生成されます。成功すると、出力に dist/client/index.html が含まれます。
署名付き境界をローカルでテストする
このステップでは、ローカルランタイムを起動し、モデル呼び出しを消費せずにアクセス制御をテストします。
Workers AI はリモートバインディングなので、Vite のローカルランタイムには Wrangler がすでに保存している OAuth アクセスが必要です。短時間だけ使うシェル変数に直接読み込み、子プロセスにだけ渡した後、シェル内のコピーをすぐに削除します。
DEV_PROXY_TOKEN="$(npx wrangler auth token --json | node -e 'let data="";process.stdin.on("data",chunk=>data+=chunk).on("end",()=>process.stdout.write(JSON.parse(data).token))')"
CLOUDFLARE_API_TOKEN="$DEV_PROXY_TOKEN" CI=true npm run dev > .labex/dev.log 2>&1 < /dev/null &
echo $! > .labex/dev.pid
unset DEV_PROXY_TOKEN
この値を表示したり、.dev.vars に保存したりしないでください。これは既存の一時的な Wrangler OAuth アクセスであり、新しく作成した API Token ではありません。CI=true と標準入力のリダイレクトにより、ターミナルが戻った後も Vite プロセスを切り離して実行できます。
URL が表示されるまで待ちます。
until curl -fsS http://127.0.0.1:5173/ >/dev/null; do sleep 1; done
tail -n 12 .labex/dev.log
独立したローカルチェックを実行します。
python3 .labex/verify.py local
このチェックでは、意図的にモデル呼び出しを消費しません。正しく署名された新しいセッションが空の履歴を読み取れること、署名なしのリクエストと別の名前を対象にした有効なトークンが、どちらも HTTP 401 を受け取ることを確認します。ローカルの Miniflare は、.dev.vars にある同じルーティングフックとシークレットを使用します。
デプロイして永続的なストリーミングを確認する
このステップでは、デプロイを行い、実際のストリーミング回答を 1 つ確認し、更新後に復元できることとセッション分離を証明します。
本番ビルドをデプロイした後、生成した署名キーを Worker シークレットとしてアップロードします。
npm run deploy
npx wrangler secret bulk .dev.vars
シークレットコマンドは、値を wrangler.jsonc やバンドルに含めずに Cloudflare へ送信します。.dev.vars を表示しないでください。
デプロイ成功時に表示された正確な workers.dev オリジンを保存し、planning 会話用の 10 分間有効なトークンを作成します。
WORKER_URL="https://paste-the-workers-dev-origin-printed-by-deploy"
TOKEN="$(node scripts/create-session-token.mjs planning)"
printf '%s/?session=planning&token=%s\n' "${WORKER_URL%/}" "$TOKEN"
WORKER_URL にはオリジンだけを指定し、末尾のスラッシュやパスは含めません。トークンはこのターミナルセッション内に保持し、メモやスクリーンショットに貼り付けないでください。
完全な URL を開きます。初期状態は ready に落ち着き、ページには保存済みメッセージがないと表示されます。用意されている合成質問を送信してください。テキストが到着するにつれて、submitted が streaming に変わり、その後 ready に戻ることを確認します。

表示されるリソースと回答は、テスト済みの破棄可能な実行例です。モデル出力は非決定的であるため、実際の文言は異なる場合があります。
同じ URL を更新します。完了したユーザーメッセージとアシスタントメッセージが、最初からやり直すのではなく SQLite から復元されます。

次に、名前による分離を証明します。別の署名付き URL を生成して開きます。
PRIVATE_TOKEN="$(node scripts/create-session-token.mjs private)"
printf '%s/?session=private&token=%s\n' "${WORKER_URL%/}" "$PRIVATE_TOKEN"
private ページは認証されていますが、別の名前の Agent インスタンスに属しています。そのため、履歴は空です。

最後に、実行ごとに固有の独立したリモートプローブを 1 回実行します。このプローブは、制限付きモデル呼び出しを追加で 1 回行い、複数のストリーム断片を確認します。また、再接続後に保存済みのユーザーメッセージとアシスタントメッセージを取得し、認証済みの別セッションが空であることを確認し、セッションをまたいだアクセスを拒否します。
python3 .labex/verify.py deployed
チャットリソースを確認して削除する
このステップでは、ランタイムの動作を Dashboard の証拠に結び付けた後、この実験のリソースだけを削除します。
Cloudflare Dashboard で Workers & Pages を開き、正確な名前が labex-c11-s03-... の Worker を選択して、バインディングを確認します。AI バインディングと SupportChatAgent Durable Object バインディングの両方が表示されます。

Durable Objects を開き、この Worker が所有する SQL ベースの namespace を選択します。namespace は Cloudflare におけるリソースレベルの表示です。planning、private、検証用の名前は、その中にある分離されたインスタンスです。

Worker のログまたは observability ビューを開き、support_chat_turn_started を探します。このイベントにはメッセージ件数などの制限されたメタデータが表示されますが、学習者のプロンプトやモデルの回答は含まれません。

確認後、この実験のクラス namespace だけを削除するマイグレーションを作成します。
python3 - <<'PY'
import json
from pathlib import Path
path = Path('wrangler.jsonc')
data = json.loads(path.read_text())
data.pop('durable_objects', None)
data['migrations'].append({'tag': 'v2', 'deleted_classes': ['SupportChatAgent']})
Path('wrangler.cleanup.jsonc').write_text(json.dumps(data, indent=2) + '\n')
PY
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force
Workers & Pages で Worker が存在しないことを確認します。

次に、所有していた SupportChatAgent namespace が Durable Objects に存在しないことを確認します。

この VM がまだ認証済みの間に、認証付きの不存在チェックを実行します。
python3 .labex/verify.py deleted
Worker だけを削除しても十分ではありません。明示的な deleted_classes マイグレーションによって、状態を持つ namespace のライフサイクルを確認でき、この実験で保存した合成履歴が残されるのを防ぎます。
この VM の認証を取り消す
このステップでは、クラウド側のクリーンアップを確認した後、一時 VM の認証を取り消します。
クラウドリソースはすでに削除されています。次に、この一時 VM に保存されている OAuth 認証を取り消します。
npx wrangler logout
npx wrangler whoami --json || true
構造化された結果に "loggedIn": false と表示されることを確認します。Wrangler が未認証を示すゼロ以外の終了コードを返す場合もあります。これは意図的に最後に行います。クリーンアップの確認には有効な認証が必要ですが、ログアウトすると、その後に破棄する VM を保護できます。
まとめ
Cloudflare の現在のチャット統合を使って、永続化されるストリーミング対応のサポート会話を構築しました。以下を実施しました。
AIChatAgentを拡張し、制限付きの Workers AIstreamText()呼び出しを使用しました。- 用意された React の土台を
useAgent()とuseAgentChat()に接続しました。 - 有効期限があり、セッション単位に限定された署名で、WebSocket と HTTP の履歴ルートの両方を保護しました。
- 状態が段階的に変化する様子を確認し、更新後に SQLite ベースの履歴を復元し、別名の会話が分離されたままであることを証明しました。
- プライバシーに配慮して内容を制限した Cloudflare の証拠を確認しました。
- VM の認証を取り消す前に、対象となる Agent クラスの namespace と Worker を削除しました。
次の実験では、同じ永続的な Agent 識別情報を、スケジュールされたサポートフォローアップに使用します。スケジューリングは別のライフサイクル上の関心事です。ブラウザが接続されていない場合でも、後から処理を実行できるようにします。



