Introducción
Un Agent duradero puede recordar una cola de soporte, pero un panel útil también debe mantener actualizada cada pantalla conectada. El sondeo solicita al servidor una copia nueva una y otra vez. En cambio, el SDK de Cloudflare Agents abre un WebSocket: una conexión bidireccional de larga duración que puede enviar una actualización a todos los clientes del mismo Agent con nombre en cuanto cambia el estado.
Construirá un panel deliberadamente pequeño y sin LLM. Dos clientes independientes de JavaScript vanilla —Dispatcher y Observer— se conectan a SupportDashboard:planning. Dispatcher invoca un método del servidor marcado con @callable(). El método valida el ticket, actualiza una vez el estado del Agent y el SDK difunde el estado resultante a ambos clientes. El servidor rechaza un título no válido y no incrementa la revisión compartida.
Este laboratorio presenta solo cuatro elementos, cuando la aplicación los necesita:
AgentClientmantiene la conexión WebSocket del navegador.onStateUpdatevuelve a dibujar la vista después de que el servidor difunde el estado.@callable()expone un método específico del servidor a los clientes conectados.setState()persiste un único estado siguiente autoritativo y activa la sincronización.
El ejemplo utiliza texto sintético de soporte y un Worker público desechable para que pueda concentrarse en el protocolo. La validación de entradas no es autenticación de usuarios. Una herramienta de soporte para producción debe incorporar una capa de identidad y autorización antes de exponer datos de clientes o permitir mutaciones.
Antes de entrar directamente en este curso, complete Conectar LabEx a su cuenta de Cloudflare. Cada VM nueva de LabEx necesita su propia autorización de Wrangler. Se recomienda S01 porque este laboratorio se basa en una identidad de Agent con nombre, un estado duradero y una limpieza explícita, pero no presupone conocimientos de React ni de modelos de IA.
Autorizar la VM y configurar el panel
En este paso, autorizará la VM recién creada, confirmará la cuenta de Cloudflare prevista y declarará el único espacio de nombres de Agent que utiliza el panel.
Entre en el proyecto preparado y confirme las versiones fijadas del entorno de ejecución. La configuración instaló las dependencias y proporcionó únicamente la estructura visual de la página; todavía no autorizó Cloudflare ni implementó el Agent.
cd /home/labex/project/support-dashboard-agent
node --version
npx wrangler --version
npm list agents vite @cloudflare/vite-plugin --depth=0
Debería obtener Node.js v22.22.0, Wrangler 4.134.0, Agents SDK 0.23.0, Vite 8.3.0 y el plugin de Vite de Cloudflare 1.55.0.
Autorice esta VM e inspeccione la identidad estructurada:
npx wrangler login --device --browser=false
npx wrangler whoami --json
Abra en un navegador el enlace mostrado, introduzca el código corto, confirme que se trata de su cuenta de aprendizaje asignada e inspeccione los permisos antes de autorizar. De vuelta en la terminal, confirme loggedIn: true. Después, seleccione la cuenta mediante su nombre visible confirmado sin mostrar su 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"
Si su cuenta de aprendizaje utiliza otro nombre, reemplace únicamente LabEx Learning después de confirmar que es la cuenta correcta. Cree la configuración:
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
El binding selecciona el espacio de nombres de la clase Agent; cada cliente del navegador proporcionará el nombre de la instancia. La configuración por sí sola no crea ningún recurso en la nube.
Implementar un método invocable validado
En este paso, implementará el estado compartido de la cola y la única mutación que pueden invocar los navegadores.
El servidor es responsable de la regla de mutación. Un navegador puede solicitar una actualización, pero no debe decidir si un título o una prioridad son válidos. Cree 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() es un límite RPC explícito: solo los métodos decorados pueden invocarse mediante el protocolo del cliente Agent. La validación ocurre antes de setState(), por lo que las llamadas rechazadas no pueden incrementar la revisión. Conservar únicamente los seis últimos tickets sintéticos limita el tamaño del estado de demostración. El registro estructurado contiene la instancia, la revisión y la cantidad, pero no el texto del ticket.
Conectar dos clientes de navegador vanilla
En este paso, configurará el flujo de compilación actual de decoradores y conectará dos clientes vanilla independientes a un único Agent con nombre.
El decorador actual del SDK utiliza la transformación estándar de decoradores de JavaScript. Por tanto, un proyecto manual necesita tanto el ajuste preestablecido de Agents para TypeScript como el plugin de Vite de Agents. No active el modo heredado experimentalDecorators de TypeScript.
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
Cree 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
Estos son dos clientes WebSocket reales, aunque aparezcan en una sola página. Ambos se enrutan a la misma clase y al mismo nombre, por lo que reciben la misma difusión de estado. Solo Dispatcher realiza la llamada; Observer demuestra que la sincronización la controla el servidor y no una actualización copiada del DOM.
Generar los tipos y compilar ambos lados
En este paso, comprobará los tipos del contrato de estado compartido y compilará el Worker y la aplicación del navegador antes de iniciar un entorno de ejecución.
Genere los tipos del entorno a partir de la configuración exacta del binding:
npx wrangler types
grep -n "SupportDashboard" worker-configuration.d.ts | head
Ejecute TypeScript sobre el Worker, el cliente del navegador y la configuración de Vite:
npm run check
La ausencia de diagnósticos del compilador indica que la forma del estado, el servidor invocable y el cliente DOM son compatibles. Compile los dos destinos de producción:
npm run build
find dist -maxdepth 3 -type f | sort | sed -n '1,16p'
Vite informa de un entorno de Worker y otro de cliente. El plugin de Cloudflare genera el paquete del Worker y adjunta la página estática compilada; el plugin de Agents aplica la transformación actual de decoradores. Una compilación correcta demuestra que el empaquetado funciona, pero no demuestra el comportamiento de WebSocket, la propiedad de la cuenta ni el despliegue remoto.
Observar la sincronización local y el rechazo
En este paso, observará cómo dos clientes locales convergen después de una actualización válida y permanecen sin cambios después de una actualización no válida.
Inicie el entorno local de Vite y Workers como tarea en segundo plano:
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
Abra http://localhost:5173 en el navegador del escritorio de LabEx. Espere hasta que el estado verde indique que ambos clientes están conectados. Ambas tarjetas comienzan en la revisión 0 y sin tickets.
Conserve el título preparado y haga clic en Add with Dispatcher. Ambas tarjetas deben avanzar a la revisión 1 y mostrar el mismo ticket. Dispatcher envía primero una trama RPC por su WebSocket. addTicket() valida los argumentos en el Agent; después, setState(next) persiste la revisión 1 y la difunde. Ambos controladores onStateUpdate vuelven a dibujar sus tarjetas de forma independiente.
Ahora sustituya el título por x y vuelva a enviar el formulario. La página muestra title must contain 3-80 characters; ambas tarjetas permanecen en la revisión 1. Esto demuestra que la validación se produjo antes de escribir el estado.
Ejecute la comprobación local independiente:
python3 .labex/verify.py local
El verificador utiliza nombres nuevos y exclusivos de cada ejecución, en lugar de confiar en el ejemplo visible. Abre dos clientes, demuestra la convergencia, comprueba que otro nombre permanece en la revisión cero, envía una actualización no válida y confirma que la revisión compartida no cambia.
Desplegar e inspeccionar el panel en la nube
En este paso, desplegará el paquete de producción, demostrará el mismo contrato de dos clientes en Cloudflare y relacionará ese comportamiento con las evidencias del panel.
Detenga el proceso local exacto y despliegue la compilación de producción:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npm run deploy
Wrangler aplica la migración v1, carga el Worker junto con el cliente estático e imprime una URL de workers.dev. Guarde esa URL exacta:
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
Abra la URL en el navegador integrado. Añada Cloud dashboard ticket con prioridad Urgent. Ambas tarjetas deben mostrar la misma revisión y el marcador rojo de urgencia. Después, envíe x; el rechazo aparecerá mientras ambas revisiones permanecen sin cambios. Estas son instancias nuevas del Agent propiedad de la nube; el estado local de Vite está separado deliberadamente.

En esta ejecución de prueba real, Dispatcher realizó la escritura y Observer recibió la misma difusión. El texto del ticket y la revisión son ejemplos del recurso desechable del curso; sus valores pueden ser diferentes.

El error aparece junto a la entrada, pero ninguna tarjeta avanza. Lea la revisión sin cambios en ambas tarjetas como la pista importante: el servidor rechazó el argumento antes de llamar a setState().
Abra Workers & Pages en el Dashboard de Cloudflare y seleccione el Worker exacto labex-c11-s02-.... Use la pestaña Bindings para confirmar que SupportDashboard apunta a la clase Durable Object SupportDashboard. En Durable Objects, confirme que su espacio de nombres utiliza almacenamiento SQL. Por último, abra Observability → Logs, filtre por support_queue_updated y expanda un evento. Compruebe que su instancia sea planning y coincidan la revisión y la cantidad de tickets; el título del ticket se omite deliberadamente.

La vista general reúne varias ideas que ha utilizado por separado: el dominio workers.dev llega al Worker, el binding lo conecta con el estado duradero y el contador de cero errores es una señal rápida de salud. El nombre del Worker mostrado en esta captura pertenece a una ejecución de prueba aceptada.

El gráfico de bindings debe conectar su Worker exacto con un Durable Object llamado SupportDashboard. Esta es una evidencia de configuración; no sustituye la comprobación del comportamiento con dos clientes.

La página del espacio de nombres identifica el almacenamiento duradero que respalda la clase Agent e indica Storage: SQL. El ID opaco del espacio de nombres está oculto en la imagen didáctica por motivos de privacidad; los alumnos nunca necesitan copiarlo.

El evento expandido contiene el nombre sintético de la instancia, la revisión y la cantidad de tickets, pero no el título del ticket. Es una minimización de datos deliberada: los registros deben ayudar a diagnosticar el comportamiento sin copiar contenido potencialmente sensible de los usuarios.
Los datos del Dashboard pueden llegar tarde, por lo que una vista de registros recientes vacía no es concluyente. La configuración autenticada, el espacio de nombres propio y las comprobaciones independientes en vivo de AgentClient son la evidencia autoritativa.
python3 .labex/verify.py deployed
python3 .labex/verify.py observed
La primera comprobación crea nuevos nombres remotos y demuestra la sincronización, el aislamiento y el rechazo sin confiar en el ejemplo visible planning. La segunda mantiene disponibles los recursos exactos de su propiedad para que pueda inspeccionarlos en modo de solo lectura en el Dashboard.
Eliminar el espacio de nombres del panel y el Worker
En este paso, eliminará explícitamente el espacio de nombres de la clase Agent y después quitará el Worker restante mientras la VM siga autorizada.
La cola se almacena en el espacio de nombres de la clase Durable Object, por lo que debe eliminar explícitamente esa clase antes de eliminar el Worker sin estado restante. Cree un punto de entrada de limpieza:
cat > src/cleanup.ts <<'TS'
export default {
fetch() {
return Response.json({ status: "cleanup" }, { status: 410 });
}
};
TS
Conserve la migración original y añada v2 para la eliminación:
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
El historial de migraciones es de solo adición: reescribir v1 no describiría la transición que ya se aplicó en Cloudflare. En el Dashboard, confirme que el Worker exacto y su espacio de nombres SupportDashboard ya no estén presentes. Conserve los recursos no relacionados que pueda contener su cuenta.

La cuenta probada volvió a la vista general de Workers & Pages después de la eliminación. Su cuenta de aprendizaje puede contener Workers no relacionados, así que verifique que haya desaparecido el nombre exacto labex-c11-s02-... en lugar de esperar que la cuenta quede vacía.

La cuenta de prueba aceptada también volvió a una vista general de Durable Objects vacía. En una cuenta con otros espacios de nombres, consérvelos y confirme que solo se haya eliminado el espacio de nombres propiedad de este laboratorio.
Revocar la autorización de esta VM
En este paso, eliminará la autorización OAuth almacenada únicamente en esta VM desechable y verificará el estado estructurado de sesión cerrada.
La limpieza de la nube ha terminado, pero esta VM desechable todavía conserva su concesión OAuth local. Elimínela y solicite el estado estructurado:
npx wrangler logout
npx wrangler whoami --json
python3 .labex/verify.py logout
El JSON debe contener explícitamente "loggedIn": false. Un error de red no demuestra que se haya cerrado la sesión; vuelva a solicitar el estado cuando se restablezca la conectividad. El panel sintético público, su estado duradero y la autorización de esta VM han sido eliminados.
Resumen
Convirtió un Agent duradero con nombre en una aplicación de navegador en tiempo real sin introducir React ni un modelo de lenguaje. Dos conexiones AgentClient seleccionaron SupportDashboard:planning, un método @callable() validado controló la mutación, setState() persistió una única revisión autoritativa y el SDK difundió ese estado a ambos controladores onStateUpdate.
También aprendió por qué el flujo actual de decoradores necesita tanto agents/tsconfig como agents/vite, distinguió una RPC mediante WebSocket de los cambios directos de estado del cliente, demostró que una entrada rechazada no tiene efecto, repitió la sincronización y el aislamiento por nombre en Cloudflare, inspeccionó evidencias con datos limitados por privacidad y eliminó explícitamente el espacio de nombres de la clase, el Worker y la autorización de la VM.



