Introduction
Une équipe de support promet souvent de vérifier à nouveau un ticket plus tard : après qu’un client a essayé une correction, après la fin d’une fenêtre de maintenance ou avant une échéance d’escalade. Un minuteur de navigateur ne peut pas gérer cette promesse de manière fiable, car la fermeture de l’onglet l’efface. Une planification d’Agent enregistre l’action future auprès de l’Agent nommé. La plateforme peut ainsi réveiller cette instance durable lorsque l’heure arrive.
Dans cet atelier, vous allez créer un petit tableau de suivi sans modèle de langage :
schedule()enregistre un rappel différé et renvoie son identifiant de planification durable.listSchedules()permet à l’application d’inspecter le travail en attente avec l’API asynchrone actuelle.cancelSchedule()supprime un élément encore en attente après que le serveur a vérifié qu’il lui appartient.- Le rappel enregistre une exécution terminée limitée dans l’état de l’Agent et émet un journal ne contenant qu’un minimum d’informations.
Vous allez planifier une tâche courte et suivre son exécution, puis créer une tâche plus longue et l’annuler avant son exécution. Les appels utilisent uniquement des références de tickets synthétiques. Les demandes d’enregistrement identiques activent l’idempotence du SDK, ce qui empêche un double-clic accidentel de créer du travail en double.
Le SDK Agents implémente ce cycle de vie au-dessus d’une alarme de Durable Object reposant sur SQLite. Vous utilisez l’API de planification de plus haut niveau au lieu de gérer vous-même les horodatages d’alarme et les enregistrements de stockage. Le travail appartient toutefois toujours à une instance d’Agent nommée et survit aux redémarrages ordinaires du Worker.
Avant d’accéder directement à ce cours, terminez Connect LabEx à votre compte Cloudflare. Chaque nouvelle VM LabEx a besoin de sa propre autorisation Wrangler. Les ateliers précédents du cours sont recommandés, mais cet atelier crée et supprime ses propres ressources isolées.
Autoriser la VM et configurer l’Agent
Dans cette étape, vous allez autoriser cette nouvelle VM et définir l’unique Worker temporaire ainsi que la classe Durable Object utilisés par l’atelier.
cd /home/labex/project/follow-up-agent
npx wrangler login
npx wrangler whoami --json
Ouvrez le lien vers l’appareil affiché dans le navigateur LabEx, vérifiez le code affiché et approuvez le compte d’apprentissage. Ne communiquez à personne votre mot de passe, votre jeton ou votre code d’autorisation. Dans le résultat JSON, vérifiez que "loggedIn": true, lisez le nom du compte et copiez son ID.
Générez un nom de ressource unique et créez 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
Le nom de binding est celui utilisé par le routeur et le client ; le nom de classe désigne l’implémentation. La migration v1 demande à Cloudflare de créer un stockage reposant sur SQLite pour cette classe. Elle ne crée pas encore une instance nommée précise : une instance telle que planning apparaît lorsque du trafic s’adresse à elle pour la première fois.
Implémenter la planification durable des suivis
Dans cette étape, vous allez implémenter l’enregistrement, l’inspection, l’annulation et le rappel final dans un Agent nommé.
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 provient des déclarations de binding générées par Wrangler que vous créerez avant la compilation. Le code source ne conserve donc pas une seconde copie écrite manuellement de l’environnement. schedule() reçoit un délai relatif, le nom du rappel et une petite charge utile sérialisable. { idempotent: true } signifie que la répétition du même rappel avec la même charge utile renvoie la planification existante au lieu d’en ajouter une autre. La politique de nouvelle tentative autorise au maximum deux tentatives du rappel avec un bref délai d’attente limité ; une erreur permanente ne peut donc pas provoquer une boucle infinie. Le rappel ne conserve que cinq exécutions synthétiques et son journal structuré exclut la référence du ticket.
Les méthodes de liste et de recherche sont volontairement attendues avec await. Des exemples plus anciens peuvent présenter des appels synchrones getSchedule() ou getSchedules() ; le code actuel du SDK Agents doit utiliser getScheduleById() et listSchedules().
Connecter le tableau de suivi
Dans cette étape, vous allez configurer la transformation actuelle des décorateurs et connecter la page fournie à un Agent nommé.
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 page interroge l’Agent toutes les deux secondes uniquement pour que cet exemple TypeScript reste facile à lire. La planification elle-même n’est pas un minuteur du navigateur : fermer la page ne l’annule pas. Le serveur reste l’autorité pour la validation, la propriété et l’exécution.
Générer les types et compiler l’application
Dans cette étape, vous allez générer les types de binding et compiler les deux parties de l’application avant de démarrer un processus d’exécution.
Générez les types d’environnement à partir du binding exact, vérifiez les deux parties TypeScript et compilez le Worker ainsi que la page statique :
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
Une compilation réussie prouve que le binding, la transformation des décorateurs, les types partagés et les bundles sont cohérents. Elle ne prouve pas encore qu’une alarme se déclenche ni que le compte cloud possède la ressource déployée ; ces vérifications d’exécution sont effectuées aux étapes suivantes.
Vérifier le cycle de vie en local
Dans cette étape, vous allez vérifier que l’exécution durable et l’annulation fonctionnent dans l’environnement d’exécution local de Cloudflare.
Démarrez l’environnement d’exécution local comme tâche persistante en arrière-plan :
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
Ouvrez http://localhost:5173 dans le navigateur du bureau LabEx. Planifiez T-DEMO-101 dans 12 secondes. Il apparaît d’abord dans Pending ; fermer ou actualiser la page ne prend pas en charge cette tâche. Une fois l’heure planifiée atteinte, le rappel la retire du stockage des planifications et l’enregistre dans Completed.
Planifiez ensuite T-DEMO-CANCEL dans 90 secondes, puis cliquez sur Cancel. Il disparaît de Pending et n’apparaît jamais dans Completed. Exécutez la vérification indépendante, qui utilise son propre nom d’Agent aléatoire et vérifie l’enregistrement idempotent, l’exécution et l’annulation :
python3 .labex/verify.py local
Déployer et inspecter le travail planifié
Dans cette étape, vous allez reproduire le cycle de vie sur Cloudflare et mettre en relation le comportement observable avec les éléments du Dashboard.
Arrêtez le processus local exact, déployez la version de production et attendez son 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
Ouvrez l’URL exacte dans le navigateur intégré. Planifiez T-CLOUD-101 dans 20 secondes et observez d’abord sa ligne durable dans la liste des éléments en attente.

L’heure d’exécution et l’identifiant de planification appartiennent à l’exécution temporaire acceptée ; vos valeurs seront différentes. L’élément important est que l’Agent affiche cet élément, et non un compte à rebours stocké dans la page.
Attendez le rappel et vérifiez que le même ticket synthétique apparaît dans Completed.

Créez T-CLOUD-CANCEL pour 90 secondes, capturez son état en attente et annulez-le. Le panneau des éléments en attente doit redevenir vide, tandis que l’entrée terminée reste inchangée.


Ouvrez Workers & Pages, sélectionnez le Worker exact labex-c11-s04-... et inspectez Bindings. Vérifiez que FollowUpAgent pointe vers le même nom de classe.

Ouvrez Durable Objects et inspectez l’espace de noms FollowUpAgent. Il utilise un stockage SQL, car l’état et les planifications de l’Agent nécessitent des enregistrements durables.

Enfin, ouvrez Observability → Logs, filtrez sur follow_up_completed et développez un événement. L’événement limité contient l’instance de l’Agent, la révision et le nombre d’exécutions terminées, mais aucune référence de ticket.

Les vues du Dashboard peuvent apparaître avec un délai. La vérification distante indépendante fait donc autorité :
python3 .labex/verify.py deployed
python3 .labex/verify.py observed
Supprimer l’espace de noms de planification et le Worker
Dans cette étape, vous allez supprimer uniquement l’espace de noms de la classe et le Worker créés par cet atelier.
Les planifications et l’état des exécutions terminées résident dans l’espace de noms de la classe Durable Object. Supprimez explicitement cette classe avant de retirer le Worker sans état restant :
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
Ne supprimez pas les autres ressources du compte. Vérifiez que seul le Worker généré exact et son espace de noms FollowUpAgent ont été supprimés.


Révoquer l’autorisation de cette VM
Dans cette étape, vous allez supprimer l’autorisation OAuth stockée dans cette VM temporaire et vérifier l’état structuré de déconnexion.
Une fois le nettoyage cloud terminé, supprimez l’autorisation OAuth stockée dans cette VM temporaire :
npx wrangler logout
npx wrangler whoami --json
python3 .labex/verify.py logout
Vérifiez explicitement que "loggedIn": false. Une erreur réseau ne permet pas de conclure et doit faire l’objet d’une nouvelle tentative. Le Worker temporaire, son espace de noms de planification et l’autorisation locale de cette VM sont maintenant supprimés.
Résumé
Vous avez confié à un Agent Cloudflare nommé un travail futur durable sans dépendre d’un navigateur ouvert ni d’un modèle de langage. Vous avez enregistré un rappel différé limité, rendu les enregistrements répétés idempotents, inspecté les planifications en attente via l’API asynchrone actuelle, vérifié la propriété avant l’annulation et conservé un historique réduit des exécutions terminées.
Vous avez également relié l’abstraction du SDK au cycle de vie des alarmes de Durable Object, vérifié l’exécution et l’annulation localement et à distance, inspecté des éléments ne contenant qu’un minimum d’informations, puis supprimé explicitement l’espace de noms de la classe, le Worker et l’autorisation de la VM temporaire.



