Introducción
Un equipo de soporte suele prometer revisar un ticket más adelante: después de que el cliente pruebe una solución, cuando termine una ventana de servicio o antes de que venza el plazo de escalamiento. Un temporizador del navegador no puede conservar esa promesa de forma segura, porque al cerrar la pestaña se pierde. Una programación de Agent almacena la acción futura junto con el Agent identificado, de modo que la plataforma pueda activar esa instancia duradera cuando llegue el momento.
En este laboratorio, creará un pequeño panel de seguimientos sin usar un modelo de lenguaje:
schedule()registra una devolución de llamada retrasada y devuelve su identificador de programación duradero.listSchedules()permite que la aplicación inspeccione el trabajo pendiente mediante la API asíncrona actual.cancelSchedule()elimina un elemento que aún está pendiente después de que el servidor verifica que le pertenece.- La devolución de llamada registra una finalización acotada en el estado del Agent y emite un registro con información limitada por privacidad.
Programará una tarea breve y observará cómo se completa. Después, creará una tarea más larga y la cancelará antes de que se ejecute. Las llamadas utilizan únicamente referencias de tickets sintéticas. Las solicitudes de registro idénticas activan la idempotencia del SDK, lo que evita que un doble clic accidental cree trabajo duplicado.
Agents SDK implementa este ciclo de vida sobre una alarma de Durable Object respaldada por SQLite. Usted utilizará la API de programación de alto nivel en lugar de administrar directamente las marcas de tiempo de las alarmas y los registros de almacenamiento. Aun así, el trabajo seguirá perteneciendo a una única instancia de Agent identificada y sobrevivirá a los reinicios normales de Workers.
Antes de entrar directamente en este curso, complete Connect LabEx to Your Cloudflare Account. Cada nueva VM de LabEx necesita su propia autorización de Wrangler. Se recomiendan los laboratorios anteriores del curso, pero este laboratorio crea y elimina sus propios recursos aislados.
Autorizar la VM y configurar el Agent
En este paso, autorizará esta VM nueva y definirá el único Worker desechable y la clase Durable Object que utiliza el laboratorio.
cd /home/labex/project/follow-up-agent
npx wrangler login
npx wrangler whoami --json
Abra el enlace del dispositivo que se muestra en el navegador de LabEx, confirme el código mostrado y apruebe la cuenta de aprendizaje. No envíe a nadie ninguna contraseña, token ni código de autorización. En el resultado JSON, compruebe que "loggedIn": true, lea el nombre de la cuenta y copie su ID.
Genere un nombre de recurso único y cree wrangler.jsonc:
RUN="labex-c11-s04-$(openssl rand -hex 6)"
ACCOUNT_ID="YOUR_ACCOUNT_ID"
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": "FollowUpAgent", "class_name": "FollowUpAgent" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["FollowUpAgent"] }
]
}
JSON
python3 .labex/verify.py auth
El nombre del binding es el que utilizan el enrutador y el cliente; el nombre de la clase identifica la implementación. La migración v1 solicita a Cloudflare que cree almacenamiento respaldado por SQLite para esa clase. Todavía no crea una instancia con un nombre concreto: una instancia como planning aparece cuando el tráfico se dirige a ella por primera vez.
Implementar la programación de seguimientos duraderos
En este paso, implementará el registro, la inspección, la cancelación y la devolución de llamada final en un único Agent identificado.
cat > src/server.ts <<'TS'
import { Agent, callable, routeAgentRequest, type Schedule } from "agents";
type CompletedFollowUp = { ticketId: string; completedAt: string };
export type FollowUpState = { completed: CompletedFollowUp[]; revision: number };
export type PendingFollowUp = { id: string; ticketId: string; runAt: string };
export class FollowUpAgent extends Agent<Cloudflare.Env, FollowUpState> {
initialState: FollowUpState = { completed: [], revision: 0 };
private ticket(value: unknown): string {
const ticketId = typeof value === "string" ? value.trim().toUpperCase() : "";
if (!/^T-[A-Z0-9-]{3,24}$/.test(ticketId)) {
throw new Error("ticket must look like T-DEMO-101");
}
return ticketId;
}
@callable()
async scheduleFollowUp(ticketInput: string, delaySeconds: number): Promise<PendingFollowUp> {
const ticketId = this.ticket(ticketInput);
if (!Number.isInteger(delaySeconds) || delaySeconds < 3 || delaySeconds > 300) {
throw new Error("delay must be an integer from 3 to 300 seconds");
}
const scheduled = await this.schedule(
delaySeconds,
"completeFollowUp",
{ ticketId },
{
idempotent: true,
retry: { maxAttempts: 2, baseDelayMs: 100, maxDelayMs: 500 }
}
);
return this.pending(scheduled);
}
@callable()
async listFollowUps(): Promise<PendingFollowUp[]> {
const schedules = await this.listSchedules({ type: "delayed" });
return schedules
.filter((item) => item.callback === "completeFollowUp")
.map((item) => this.pending(item))
.sort((left, right) => left.runAt.localeCompare(right.runAt));
}
@callable()
async cancelFollowUp(scheduleId: string): Promise<boolean> {
if (!/^[a-zA-Z0-9_-]{8,80}$/.test(scheduleId)) throw new Error("invalid schedule ID");
const owned = await this.getScheduleById(scheduleId);
if (!owned || owned.callback !== "completeFollowUp") return false;
return this.cancelSchedule(scheduleId);
}
@callable()
getBoard(): FollowUpState {
return this.state;
}
async completeFollowUp(payload: unknown, _schedule: Schedule<unknown>): Promise<void> {
const ticketId = this.ticket((payload as { ticketId?: unknown })?.ticketId);
const next: FollowUpState = {
completed: [...this.state.completed, { ticketId, completedAt: new Date().toISOString() }].slice(-5),
revision: this.state.revision + 1
};
this.setState(next);
console.log(JSON.stringify({
event: "follow_up_completed",
instance: this.name,
revision: next.revision,
completedCount: next.completed.length
}));
}
private pending(schedule: Schedule<unknown>): PendingFollowUp {
const payload = schedule.payload as { ticketId?: unknown };
return {
id: schedule.id,
ticketId: this.ticket(payload.ticketId),
runAt: new Date(schedule.time * 1000).toISOString()
};
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return (await routeAgentRequest(request, env)) ?? new Response("Not found", { status: 404 });
}
};
TS
python3 .labex/verify.py server
Cloudflare.Env procede de las declaraciones de bindings generadas por Wrangler que creará antes de compilar, por lo que el código fuente no mantiene una segunda copia escrita manualmente del entorno. schedule() recibe un retraso relativo, el nombre de una devolución de llamada y una carga útil serializable pequeña. { idempotent: true } significa que repetir la misma devolución de llamada con la misma carga útil devuelve la programación pendiente existente en lugar de añadir otra. La política de reintentos permite como máximo dos intentos de la devolución de llamada, con un retroceso corto y acotado; por tanto, un error permanente no puede repetirse indefinidamente. La devolución de llamada conserva solo cinco finalizaciones sintéticas y su registro estructurado omite la referencia del ticket.
Los métodos de listado y búsqueda se esperan deliberadamente con await. Algunos ejemplos antiguos pueden mostrar llamadas síncronas como getSchedule() o getSchedules(); el código actual de Agents SDK debe utilizar getScheduleById() y listSchedules().
Conectar el panel de seguimientos
En este paso, configurará la transformación actual de decoradores y conectará la página proporcionada a un único Agent identificado.
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
cat > src/client.ts <<'TS'
import { AgentClient } from "agents/client";
import type { FollowUpState, PendingFollowUp } from "./server";
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
<main><p class="eyebrow">Durable scheduling</p><h1>Support Follow-Up Board</h1>
<p id="status" class="status">Connecting to FollowUpAgent:planning…</p>
<form id="form"><input id="ticket" value="T-DEMO-101" aria-label="Ticket reference">
<input id="delay" type="number" min="3" max="300" value="12" aria-label="Delay in seconds">
<button>Schedule follow-up</button></form><p id="error" class="error"></p>
<div class="columns"><section class="panel"><h2>Pending</h2><div id="pending"></div></section>
<section class="panel"><h2>Completed</h2><div id="completed"></div></section></div>
<p class="notice">This demonstration uses synthetic ticket references only.</p></main>`;
const client = new AgentClient<FollowUpState>({ agent: "FollowUpAgent", name: "planning", host: window.location.host });
const pendingView = document.querySelector<HTMLDivElement>("#pending")!;
const completedView = document.querySelector<HTMLDivElement>("#completed")!;
const statusView = document.querySelector<HTMLParagraphElement>("#status")!;
const errorView = document.querySelector<HTMLParagraphElement>("#error")!;
function renderCompleted(state: FollowUpState) {
completedView.innerHTML = state.completed.map((item) =>
`<div class="item"><strong>${item.ticketId}</strong><br><small>${new Date(item.completedAt).toLocaleTimeString()}</small></div>`
).join("") || '<p class="empty">No completed follow-ups yet</p>';
}
async function refresh() {
const pending = await client.call<PendingFollowUp[]>("listFollowUps", []);
pendingView.innerHTML = pending.map((item) =>
`<div class="item"><strong>${item.ticketId}</strong><br><small>${new Date(item.runAt).toLocaleTimeString()}</small><br>` +
`<button class="secondary" data-id="${item.id}">Cancel</button></div>`
).join("") || '<p class="empty">No pending follow-ups</p>';
const state = await client.call<FollowUpState>("getBoard", []);
renderCompleted(state);
}
await client.ready;
statusView.textContent = "Connected to FollowUpAgent:planning";
await refresh();
setInterval(() => refresh().catch(() => undefined), 2000);
document.querySelector<HTMLFormElement>("#form")!.addEventListener("submit", async (event) => {
event.preventDefault(); errorView.textContent = "";
try {
const ticket = document.querySelector<HTMLInputElement>("#ticket")!.value;
const delay = Number(document.querySelector<HTMLInputElement>("#delay")!.value);
await client.call("scheduleFollowUp", [ticket, delay]); await refresh();
} catch (cause) { errorView.textContent = cause instanceof Error ? cause.message : String(cause); }
});
pendingView.addEventListener("click", async (event) => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-id]");
if (!button) return;
await client.call("cancelFollowUp", [button.dataset.id]); await refresh();
});
TS
python3 .labex/verify.py client
La página consulta el Agent cada dos segundos únicamente para que este ejemplo sencillo de TypeScript sea fácil de leer. La programación no es un temporizador del navegador: cerrar la página no la cancela. El servidor sigue siendo la autoridad para la validación, la propiedad y la ejecución.
Generar tipos y compilar la aplicación
En este paso, generará los tipos de los bindings y compilará las dos partes de la aplicación antes de iniciar cualquier entorno de ejecución.
Genere los tipos del entorno a partir del binding exacto, compruebe ambas partes de TypeScript y compile el Worker junto con la página estática:
npx wrangler types
grep -n "FollowUpAgent" worker-configuration.d.ts | head
npm run check
npm run build
find dist -maxdepth 3 -type f | sort | sed -n '1,16p'
python3 .labex/verify.py build
Una compilación limpia demuestra que el binding, la transformación de decoradores, los tipos compartidos y los paquetes son compatibles. Todavía no demuestra que se active una alarma ni que la cuenta de Cloudflare sea propietaria del recurso desplegado; esas comprobaciones de ejecución se realizarán en los pasos siguientes.
Demostrar el ciclo de vida localmente
En este paso, demostrará que la ejecución duradera y la cancelación funcionan en el entorno de ejecución local de Cloudflare.
Inicie el entorno local como un proceso persistente 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
curl --silent --fail http://127.0.0.1:5173/ > /dev/null && break
sleep 1
done
curl --silent --head http://127.0.0.1:5173/ | head
Abra http://localhost:5173 en el navegador de escritorio de LabEx. Programe T-DEMO-101 para dentro de 12 segundos. Primero aparecerá en Pending; cerrar o actualizar la página no controla ese trabajo. Cuando llegue la hora programada, la devolución de llamada lo quitará del almacén de programaciones y lo registrará en Completed.
Después, programe T-DEMO-CANCEL para dentro de 90 segundos y haga clic en Cancel. Desaparecerá de Pending y nunca llegará a Completed. Ejecute la comprobación independiente, que utiliza su propio nombre aleatorio de Agent y demuestra el registro idempotente, la ejecución y la cancelación:
python3 .labex/verify.py local
Desplegar e inspeccionar el trabajo programado
En este paso, repetirá el ciclo de vida en Cloudflare y relacionará el comportamiento observable con las pruebas del Dashboard.
Detenga el proceso local exacto, despliegue la compilación de producción y espere a que esté disponible su URL:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npm run deploy
WORKER_URL="https://YOUR_WORKER_URL"
for attempt in $(seq 1 30); do
curl --silent --fail "$WORKER_URL/" > /dev/null && break
sleep 2
done
Abra la URL exacta en el navegador integrado. Programe T-CLOUD-101 para dentro de 20 segundos y observe primero la fila pendiente duradera.

La hora de ejecución y el identificador de programación pertenecen a la ejecución desechable aceptada; sus valores serán diferentes. La evidencia importante es que el elemento aparece en la lista del Agent, no en una cuenta atrás almacenada en la página.
Espere a que se ejecute la devolución de llamada y confirme que el mismo ticket sintético aparece en Completed.

Cree T-CLOUD-CANCEL para dentro de 90 segundos, capture su estado pendiente y cancélelo. El panel de elementos pendientes debería volver a quedar vacío, mientras que la entrada completada permanece sin cambios.


Abra Workers & Pages, seleccione el Worker exacto labex-c11-s04-... e inspeccione Bindings. Confirme que FollowUpAgent apunta al mismo nombre de clase.

Abra Durable Objects e inspeccione el espacio de nombres FollowUpAgent. Utiliza almacenamiento SQL porque el estado y las programaciones de Agent requieren registros duraderos.

Por último, abra Observability → Logs, filtre por follow_up_completed y expanda un evento. El evento acotado contiene la instancia del Agent, la revisión y el número de finalizaciones, pero no contiene la referencia del ticket.

Las vistas del Dashboard pueden tardar en actualizarse, por lo que la comprobación remota independiente es la autoridad:
python3 .labex/verify.py deployed
python3 .labex/verify.py observed
Eliminar el espacio de nombres de programación y el Worker
En este paso, eliminará únicamente el espacio de nombres de la clase y el Worker creados por este laboratorio.
Las programaciones y el estado de finalización viven en el espacio de nombres de la clase Durable Object. Elimine explícitamente esa clase antes de quitar el Worker sin estado restante:
cat > src/cleanup.ts <<'TS'
export default { fetch() { return Response.json({ status: "cleanup" }, { status: 410 }); } };
TS
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": ["FollowUpAgent"] },
{ "tag": "v2", "deleted_classes": ["FollowUpAgent"] }
]
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force
python3 .labex/verify.py deleted
No elimine recursos ajenos de la cuenta. Confirme que solo se hayan eliminado el Worker generado exacto y su espacio de nombres FollowUpAgent.


Revocar la autorización de esta VM
En este paso, eliminará la concesión de OAuth almacenada en esta VM desechable y verificará el estado estructurado de cierre de sesión.
Después de que la limpieza en la nube se complete correctamente, elimine la autorización de OAuth almacenada en esta VM desechable:
npx wrangler logout
npx wrangler whoami --json
python3 .labex/verify.py logout
Compruebe explícitamente que "loggedIn": false. Un error de red no es concluyente y debe volver a intentarse. El Worker desechable, su espacio de nombres de programación y la autorización local de esta VM ya se han eliminado.
Resumen
Proporcionó a un Agent de Cloudflare identificado trabajo futuro duradero sin depender de un navegador abierto ni de un modelo de lenguaje. Registró una devolución de llamada retrasada y acotada, hizo idempotentes los registros repetidos, inspeccionó las programaciones pendientes mediante la API asíncrona actual, verificó la propiedad antes de cancelar y conservó únicamente un historial pequeño de finalizaciones.
También relacionó la abstracción del SDK con el ciclo de vida de las alarmas de Durable Objects, comprobó la finalización y la cancelación de forma local y remota, inspeccionó pruebas con información limitada por privacidad y eliminó explícitamente el espacio de nombres de la clase, el Worker y la autorización de la VM desechable.



