Introducción
Un asistente de soporte resulta ágil cuando las palabras aparecen mientras el modelo todavía las está generando. También transmite confianza cuando actualizar la página no borra la conversación. Son dos necesidades de ingeniería diferentes: la transmisión entrega fragmentos incrementales de la respuesta, mientras que la persistencia guarda los mensajes completados para poder restaurar más adelante la misma conversación identificada por su nombre.
En este laboratorio, añadirá ambos comportamientos mediante la integración de chat compatible con Cloudflare:
AIChatAgentalmacena los mensajes del chat y los datos de transmisión reanudables en el Durable Object del Agent, respaldado por SQLite.streamText()genera una respuesta acotada de Workers AI en lugar de esperar a que termine la respuesta completa.useAgentChat()convierte esos fragmentos en una lista de mensajes de React y restaura el historial guardado.- Un token firmado de corta duración limita cada solicitud de WebSocket y de historial a una conversación identificada por su nombre.
El cliente del navegador se proporciona como un pequeño recurso preparado, por lo que React no es un requisito oculto. Solo editará las llamadas actuales a los hooks y la representación de mensajes necesarias para este concepto del Agents SDK. El escenario utiliza texto de soporte sintético, una respuesta breve del modelo y recursos desechables. Las asignaciones gratuitas se comparten con la actividad de la cuenta; si la cuenta ya no tiene asignación disponible de Workers AI, deténgase en lugar de activar un plan de pago.
Antes de acceder directamente a este curso, complete Conectar LabEx con su cuenta de Cloudflare. Cada nueva VM de LabEx necesita su propia autorización de Wrangler. Se recomiendan S01 y S02 porque este laboratorio se basa en una identidad de Agent con nombre, estado en SQLite y clientes WebSocket, pero sus VM y recursos no se reutilizan aquí.
Autorizar la VM y configurar el Worker del chat
En este paso, autorizará la VM nueva y describirá los tres bindings de Cloudflare que necesita el chat.
Cada chat identificado por un nombre se respalda mediante una instancia de Durable Object con SQLite. El Worker también necesita un binding de Workers AI para la inferencia y un binding secreto para delimitar la sesión.
Abra un terminal y entre en el proyecto preparado:
cd /home/labex/project/persistent-support-chat
Autorice esta VM nueva:
npx wrangler login
Abra el enlace mostrado, apruebe los permisos de Wrangler documentados para su cuenta de aprendizaje asignada y vuelva al terminal. Confirme el resultado estructurado:
npx wrangler whoami --json
Busque "loggedIn": true, confirme el nombre de la cuenta y copie el ID real de esa cuenta. Guárdelo explícitamente junto con un nombre único para el Worker desechable:
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
El binding AI permite que el Worker acceda a Workers AI sin incluir una clave de API. Workers AI siempre utiliza un modelo alojado por Cloudflare, también durante el desarrollo local; remote: true hace explícito este comportamiento. El binding de Durable Object asigna un nombre de clase; más adelante, el navegador proporcionará por separado el nombre de instancia planning. Todavía no se ha implementado nada.
Implementar un AIChatAgent con límites
En este paso, implementará la clase de chat del servidor, la inferencia acotada y el límite de enrutamiento firmado.
AIChatAgent especializa el Agent base con un historial de chat persistente y almacenamiento de transmisiones reanudables. Usted proporciona la llamada al modelo; la integración se encarga del protocolo de chat y de la persistencia.
Cree 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
Aquí son importantes tres límites. maxPersistedMessages limita el crecimiento del historial almacenado, maxOutputTokens limita cada respuesta del modelo y el prompt del sistema solicita una sola frase. GLM 4.7 Flash puede consumir su presupuesto de tokens en el razonamiento interno antes de producir texto visible, por lo que este flujo breve de soporte desactiva explícitamente el pensamiento; el alumno verá una respuesta concisa en lugar de una burbuja de asistente vacía. Reenviar abortSignal permite que el SDK cancele la inferencia ascendente cuando se detiene explícitamente un turno.
Ambos hooks de enrutamiento utilizan el verificador HMAC proporcionado. onBeforeConnect protege el handshake de WebSocket; onBeforeRequest también protege los helpers HTTP, como /get-messages. El navegador recibe una afirmación firmada, nunca el secreto de firma. El registro incluye un ID de solicitud y un recuento, pero excluye deliberadamente el texto de soporte.
Conectar los hooks de chat compatibles de React
En este paso, conectará la estructura de página proporcionada con los hooks de React compatibles actualmente.
El HTML y los estilos preparados son solo una estructura básica. Ahora conecte esa estructura con el Agent identificado por su nombre. Cree la configuración de TypeScript y 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
Cree 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() administra la conexión WebSocket firmada con SupportChatAgent:<session>. useAgentChat() añade el protocolo de chat de IA a esa conexión: mensajes, estado de transmisión, envío y restauración inicial del historial. El token viaja en la URL de conexión porque los handshakes de WebSocket del navegador no pueden añadir un encabezado de autorización personalizado; caduca después de diez minutos y está limitado a una conversación sintética.
Generar los tipos y compilar ambos lados
En este paso, generará los tipos exactos del entorno y compilará ambas partes antes de iniciar el entorno de ejecución.
Wrangler puede generar tipos exactos para los bindings a partir de su configuración. Ejecútelo antes de las compilaciones normales de TypeScript y Vite:
npx wrangler types
npm run check
npm run build
La comprobación de tipos conecta this.env.AI, el namespace de Durable Object y el binding secreto con la interfaz Env declarada. La compilación de Vite produce un bundle para el Worker y otro para el navegador; el resultado correcto debe incluir dist/client/index.html.
Probar localmente el límite firmado
En este paso, iniciará el entorno local y probará el control de acceso sin consumir una llamada al modelo.
Workers AI es un binding remoto, por lo que el entorno local de Vite necesita el acceso de OAuth que Wrangler ya almacenó. Léalo directamente en una variable de shell de corta duración, páselo únicamente al proceso hijo y elimine de inmediato la copia del 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
No muestre este valor ni lo guarde en .dev.vars. Es el acceso OAuth temporal de Wrangler que ya existe, no un API Token nuevo. CI=true y la redirección de la entrada estándar mantienen el proceso de Vite separado después de que el terminal vuelva a estar disponible.
Espere hasta que aparezca la URL:
until curl -fsS http://127.0.0.1:5173/ >/dev/null; do sleep 1; done
tail -n 12 .labex/dev.log
Ejecute la comprobación local independiente:
python3 .labex/verify.py local
Esta comprobación no consume intencionadamente ninguna llamada al modelo. Demuestra que una sesión nueva correctamente firmada puede leer su historial vacío, mientras que una solicitud sin firma y un token válido limitado a otro nombre reciben HTTP 401. Miniflare local utiliza los mismos hooks de enrutamiento y el mismo secreto de .dev.vars.
Implementar y observar la transmisión persistente
En este paso, implementará la aplicación, observará una respuesta real transmitida por fragmentos, la restaurará después de actualizar la página y demostrará el aislamiento entre sesiones.
Implemente la compilación de producción y, después, cargue la clave de firma generada como secreto del Worker:
npm run deploy
npx wrangler secret bulk .dev.vars
El comando de secretos envía el valor a Cloudflare sin incluirlo en wrangler.jsonc ni en el bundle. No muestre .dev.vars.
Guarde el origen exacto de workers.dev que aparece en la implementación correcta y cree un token de diez minutos para la conversación 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 debe contener únicamente el origen, sin una barra final ni una ruta. Mantenga el token en esta sesión del terminal y no lo copie en notas ni capturas de pantalla.
Abra la URL completa. El estado inicial debe quedar en ready y la página debe indicar que no hay mensajes guardados. Envíe la pregunta sintética preparada. Observe cómo submitted cambia a streaming y después vuelve a ready mientras llega el texto.

El recurso y la respuesta mostrados son ejemplos de la ejecución desechable probada. La redacción exacta puede variar porque la salida del modelo no es determinista.
Actualice la misma URL. Los mensajes completados del usuario y del asistente deben volver desde SQLite en lugar de empezar de nuevo:

Ahora demuestre el aislamiento por nombre. Genere y abra una URL firmada por separado:
PRIVATE_TOKEN="$(node scripts/create-session-token.mjs private)"
printf '%s/?session=private&token=%s\n' "${WORKER_URL%/}" "$PRIVATE_TOKEN"
La página private está autorizada, pero pertenece a una instancia de Agent identificada con otro nombre, por lo que su historial está vacío:

Por último, ejecute una comprobación remota independiente y exclusiva de esta ejecución. Realiza una llamada adicional acotada al modelo, confirma varios fragmentos de transmisión, obtiene los mensajes almacenados del usuario y del asistente después de volver a conectarse, comprueba que una segunda sesión autorizada está vacía y rechaza el acceso entre sesiones:
python3 .labex/verify.py deployed
Inspeccionar y eliminar los recursos del chat
En este paso, relacionará el comportamiento del entorno de ejecución con las pruebas del Dashboard y después eliminará únicamente los recursos de este laboratorio.
En el Cloudflare Dashboard, abra Workers & Pages, seleccione el Worker exacto labex-c11-s03-... e inspeccione sus bindings. Debe ver tanto el binding AI como el binding de Durable Object SupportChatAgent:

Abra Durable Objects y seleccione el namespace respaldado por SQL que pertenece a este Worker. El namespace es la vista del recurso en Cloudflare; planning, private y los nombres del verificador son instancias aisladas dentro de él:

Abra los registros del Worker o la vista de observabilidad y busque support_chat_turn_started. El evento muestra metadatos acotados, como el recuento de mensajes, pero no el prompt del alumno ni la respuesta del modelo:

Después de inspeccionarlo, cree una migración de eliminación que quite únicamente el namespace de clase de este laboratorio:
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
Confirme que el Worker ya no aparece en Workers & Pages:

Después, confirme que el namespace SupportChatAgent perteneciente a este laboratorio ya no aparece en Durable Objects:

Ejecute la comprobación autenticada de ausencia mientras esta VM todavía tenga autorización:
python3 .labex/verify.py deleted
Eliminar solo el Worker no es suficiente: la migración explícita deleted_classes hace revisable el ciclo de vida del namespace con estado y evita que el historial sintético almacenado de este laboratorio quede atrás.
Revocar la autorización de esta VM
En este paso, revocará la autorización de esta VM temporal después de comprobar la limpieza en la nube.
Los recursos en la nube ya se han eliminado. Ahora revoque la autorización de OAuth almacenada en esta VM temporal:
npx wrangler logout
npx wrangler whoami --json || true
El resultado estructurado debe indicar "loggedIn": false (o Wrangler puede devolver un resultado no autenticado con código distinto de cero). Este paso se realiza deliberadamente al final: la verificación de la limpieza necesita una autorización válida, mientras que cerrar la sesión protege la VM desechada después.
Resumen
Ha creado una conversación de soporte persistente con transmisión mediante la integración de chat actual de Cloudflare. Usted:
- amplió
AIChatAgenty utilizó una llamada acotada de Workers AI constreamText(); - conectó una estructura de React proporcionada mediante
useAgent()yuseAgentChat(); - protegió las rutas de historial de WebSocket y HTTP con una firma que caduca y está limitada a la sesión;
- observó el estado incremental, restauró el historial respaldado por SQLite al actualizar la página y demostró que otra conversación identificada por un nombre diferente permanecía aislada;
- inspeccionó pruebas de Cloudflare con datos limitados para proteger la privacidad; y
- eliminó el namespace exacto de la clase del Agent y el Worker antes de revocar la autorización de la VM.
El siguiente laboratorio utiliza la misma identidad persistente del Agent para seguimientos de soporte programados. La programación responde a una preocupación diferente del ciclo de vida: permite ejecutar el trabajo más adelante aunque ya no haya ningún navegador conectado.



