Introduction
Un assistant d'assistance paraît réactif lorsque les mots s'affichent pendant que le modèle les génère. Il paraît également fiable lorsqu'un actualissement de la page n'efface pas la conversation. Ces deux besoins d'ingénierie sont distincts : la diffusion fournit progressivement les fragments de la réponse, tandis que la persistance enregistre les messages terminés afin de pouvoir restaurer plus tard la même conversation nommée.
Dans ce lab, vous allez mettre en place ces deux comportements avec l'intégration de chat prise en charge par Cloudflare :
AIChatAgentstocke les messages du chat et les données de flux pouvant être repris dans le Durable Object de l'Agent, sauvegardé par SQLite.streamText()produit une réponse Workers AI limitée au lieu d'attendre la réponse complète.useAgentChat()transforme ces fragments en liste de messages React et restaure l'historique enregistré.- Un jeton signé de courte durée limite chaque requête WebSocket et chaque requête d'historique à une conversation nommée.
Le client du navigateur est fourni sous la forme d'une petite fixture : React n'est donc pas un prérequis caché. Vous ne modifierez que les appels aux hooks et l'affichage des messages nécessaires pour comprendre ce concept de l'Agents SDK. Le scénario utilise un texte d'assistance synthétique, une courte réponse du modèle et des ressources temporaires. Les allocations gratuites sont partagées avec les autres activités du compte ; si aucune allocation Workers AI ne reste disponible sur le compte, arrêtez-vous plutôt que d'activer une offre payante.
Avant d'accéder directement à ce cours, terminez Connect LabEx to Your Cloudflare Account. Chaque nouvelle VM LabEx doit avoir sa propre autorisation Wrangler. Les labs S01 et S02 sont recommandés, car ce lab s'appuie sur l'identité nommée de l'Agent, l'état SQLite et les clients WebSocket, mais leurs VM et leurs ressources ne sont pas réutilisés ici.
Autoriser la VM et configurer le Worker de chat
Dans cette étape, vous allez autoriser la nouvelle VM et déclarer les trois bindings Cloudflare dont le chat a besoin.
Chaque chat nommé repose sur une instance de Durable Object SQLite. Le Worker a également besoin d'un binding Workers AI pour l'inférence et d'un binding secret pour délimiter la session.
Ouvrez un terminal et accédez au projet préparé :
cd /home/labex/project/persistent-support-chat
Autorisez cette nouvelle VM :
npx wrangler login
Ouvrez le lien affiché, approuvez les autorisations Wrangler indiquées pour votre compte d'apprentissage dédié, puis revenez au terminal. Vérifiez le résultat structuré :
npx wrangler whoami --json
Recherchez "loggedIn": true, vérifiez le nom du compte et copiez l'ID réel de ce compte. Enregistrez-le explicitement avec un nom de Worker temporaire et unique :
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
Le binding AI donne au Worker accès à Workers AI sans intégrer de clé API. Workers AI utilise toujours un modèle hébergé par Cloudflare, y compris pendant le développement local ; remote: true rend ce comportement explicite. Le binding Durable Object associe un nom de classe ; le navigateur fournira ensuite le nom d'instance distinct planning. Rien n'a encore été déployé.
Implémenter un AIChatAgent limité
Dans cette étape, vous allez implémenter la classe de chat côté serveur, l'inférence limitée et la protection du routage par signature.
AIChatAgent spécialise l'Agent de base avec un historique de chat durable et un stockage des flux pouvant être repris. Vous fournissez l'appel au modèle ; l'intégration gère le protocole du chat et la persistance.
Créez 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
Trois limites sont importantes ici. maxPersistedMessages limite la croissance de l'historique enregistré, maxOutputTokens limite chaque réponse du modèle et le prompt système demande une seule phrase. GLM 4.7 Flash peut utiliser son budget de jetons pour le raisonnement interne avant de produire du texte visible. Ce court flux d'assistance désactive donc explicitement le raisonnement ; l'apprenant voit une réponse concise au lieu d'une bulle d'assistant vide. La transmission de abortSignal permet au SDK d'annuler l'inférence en amont lorsqu'un tour est explicitement interrompu.
Les deux hooks de routage utilisent le vérificateur HMAC fourni. onBeforeConnect protège la négociation WebSocket ; onBeforeRequest protège également les helpers HTTP tels que /get-messages. Le navigateur reçoit une assertion signée, jamais le secret de signature. Le journal enregistre un ID de requête et un compteur, mais exclut volontairement le texte d'assistance.
Connecter les hooks React de chat pris en charge
Dans cette étape, vous allez connecter la structure de page fournie aux hooks React actuellement pris en charge.
Le HTML et les styles préparés ne constituent qu'une structure de base. Vous allez maintenant relier cette structure à l'Agent nommé. Créez la configuration TypeScript et 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
Créez 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() gère la connexion WebSocket signée à SupportChatAgent:<session>. useAgentChat() ajoute le protocole de chat IA à cette connexion : messages, état de diffusion, envoi et restauration initiale de l'historique. Le jeton est transmis dans l'URL de connexion, car les négociations WebSocket du navigateur ne peuvent pas ajouter d'en-tête d'autorisation personnalisé ; il expire après dix minutes et est limité à une conversation synthétique.
Générer les types et construire les deux parties
Dans cette étape, vous allez générer les types exacts de l'environnement et compiler les deux parties avant de démarrer un runtime.
Wrangler peut générer les types exacts des bindings à partir de votre configuration. Exécutez cette commande avant les builds TypeScript et Vite habituels :
npx wrangler types
npm run check
npm run build
La vérification des types relie this.env.AI, le namespace Durable Object et le binding secret à l'interface Env déclarée. Le build Vite produit un bundle Worker et un bundle navigateur ; la sortie réussie doit contenir dist/client/index.html.
Tester la protection signée en local
Dans cette étape, vous allez démarrer le runtime local et tester le contrôle d'accès sans consommer d'appel au modèle.
Workers AI est un binding distant. Le runtime local de Vite a donc besoin de l'accès OAuth déjà enregistré par Wrangler. Lisez-le directement dans une variable shell de courte durée, transmettez-le uniquement au processus enfant, puis effacez immédiatement la copie du shell :
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
N'affichez pas cette valeur et ne l'enregistrez pas dans .dev.vars. Il s'agit de l'accès OAuth temporaire existant de Wrangler, et non d'un nouveau jeton API. CI=true et la redirection de l'entrée standard permettent au processus Vite de rester détaché après le retour du terminal.
Attendez que l'URL apparaisse :
until curl -fsS http://127.0.0.1:5173/ >/dev/null; do sleep 1; done
tail -n 12 .labex/dev.log
Exécutez la vérification locale indépendante :
python3 .labex/verify.py local
Cette vérification ne consomme volontairement aucun appel au modèle. Elle prouve qu'une nouvelle session correctement signée peut lire son historique vide, tandis qu'une requête non signée et un jeton valide limité à un autre nom reçoivent tous deux HTTP 401. Miniflare local utilise les mêmes hooks de routage et le même secret provenant de .dev.vars.
Déployer et observer une diffusion persistante
Dans cette étape, vous allez déployer l'application, observer une réponse réellement diffusée, la restaurer après actualisation et prouver l'isolation des sessions.
Déployez le build de production, puis envoyez la clé de signature générée en tant que secret Worker :
npm run deploy
npx wrangler secret bulk .dev.vars
La commande de secret envoie la valeur à Cloudflare sans la placer dans wrangler.jsonc ni dans le bundle. N'affichez pas .dev.vars.
Enregistrez exactement l'origine workers.dev affichée par le déploiement réussi, puis créez un jeton de dix minutes pour la conversation planning :
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 contient uniquement l'origine, sans barre oblique finale ni chemin. Conservez le jeton dans cette session de terminal et ne le copiez pas dans des notes ou des captures d'écran.
Ouvrez l'URL complète. L'état initial doit se stabiliser sur ready et la page doit indiquer qu'aucun message enregistré n'existe. Envoyez la question synthétique préparée. Observez submitted passer à streaming, puis revenir à ready tandis que le texte s'affiche.

La ressource et la réponse affichées sont des exemples issus de l'exécution temporaire testée. Votre formulation exacte peut différer, car la sortie du modèle est non déterministe.
Actualisez la même URL. Les messages utilisateur et assistant terminés doivent réapparaître depuis SQLite au lieu de recommencer :

Prouvez maintenant l'isolation par nom. Générez et ouvrez une URL signée distincte :
PRIVATE_TOKEN="$(node scripts/create-session-token.mjs private)"
printf '%s/?session=private&token=%s\n' "${WORKER_URL%/}" "$PRIVATE_TOKEN"
La page private est autorisée, mais elle appartient à une autre instance d'Agent nommée ; son historique est donc vide :

Enfin, exécutez une sonde distante indépendante et propre à cette exécution. Elle effectue un appel supplémentaire et limité au modèle, confirme la présence de plusieurs fragments de flux, récupère les messages utilisateur et assistant enregistrés après reconnexion, vérifie qu'une seconde session autorisée est vide et rejette l'accès intersession :
python3 .labex/verify.py deployed
Inspecter et supprimer les ressources du chat
Dans cette étape, vous allez relier le comportement du runtime aux éléments visibles dans le Dashboard, puis supprimer uniquement les ressources de ce lab.
Dans le Cloudflare Dashboard, ouvrez Workers & Pages, sélectionnez votre Worker labex-c11-s03-... exact et inspectez ses bindings. Vous devez voir le binding AI et le binding Durable Object SupportChatAgent :

Ouvrez Durable Objects et sélectionnez le namespace basé sur SQL appartenant à ce Worker. Le namespace est la vue au niveau des ressources de Cloudflare ; planning, private et les noms utilisés par le vérificateur sont des instances isolées à l'intérieur de ce namespace :

Ouvrez les journaux du Worker ou la vue d'observabilité et recherchez support_chat_turn_started. L'événement affiche des métadonnées limitées, comme le nombre de messages, mais pas la question de l'apprenant ni la réponse du modèle :

Après l'inspection, créez une migration de suppression qui retire uniquement le namespace de classe de ce lab :
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
Vérifiez que le Worker n'apparaît plus dans Workers & Pages :

Vérifiez ensuite que le namespace SupportChatAgent détenu par ce Worker n'apparaît plus dans Durable Objects :

Exécutez la vérification authentifiée de l'absence tant que cette VM est encore autorisée :
python3 .labex/verify.py deleted
Supprimer uniquement le Worker ne suffit pas : la migration explicite deleted_classes rend le cycle de vie du namespace avec état vérifiable et empêche l'historique synthétique enregistré par ce lab de rester présent.
Révoquer l'autorisation de cette VM
Dans cette étape, vous allez révoquer l'autorisation de cette VM temporaire après avoir vérifié le nettoyage dans le cloud.
Les ressources cloud ont déjà été supprimées. Révoquez maintenant l'autorisation OAuth enregistrée dans cette VM temporaire :
npx wrangler logout
npx wrangler whoami --json || true
Le résultat structuré doit indiquer "loggedIn": false (ou Wrangler peut renvoyer un résultat non authentifié avec un code différent de zéro). Cette étape vient volontairement en dernier : la vérification du nettoyage nécessite une autorisation valide, tandis que la déconnexion protège ensuite la VM mise au rebut.
Résumé
Vous avez créé une conversation d'assistance persistante et diffusée avec l'intégration de chat actuelle de Cloudflare. Vous avez :
- étendu
AIChatAgentet utilisé un appelstreamText()Workers AI limité ; - connecté une structure React fournie avec
useAgent()etuseAgentChat(); - protégé les routes WebSocket et HTTP de l'historique avec une signature limitée à une session et à durée d'expiration ;
- observé l'état évoluer progressivement, actualisé la page pour restaurer l'historique sauvegardé dans SQLite et prouvé qu'une autre conversation nommée restait isolée ;
- inspecté des éléments Cloudflare dont les données étaient limitées pour protéger la confidentialité ;
- supprimé le namespace exact de la classe Agent et le Worker avant de révoquer l'autorisation de la VM.
Le prochain lab réutilise la même identité d'Agent durable pour les suivis d'assistance planifiés. La planification répond à un autre besoin du cycle de vie : elle permet d'exécuter le travail plus tard, même lorsqu'aucun navigateur n'est encore connecté.



