Einführung
Ein Supportteam verspricht häufig, ein Ticket später erneut zu prüfen: nachdem ein Kunde eine Fehlerbehebung ausprobiert hat, nachdem ein Wartungsfenster endet oder bevor eine Eskalationsfrist abläuft. Ein Browser-Timer kann diese Zusage nicht zuverlässig verwalten, weil das Schließen des Tabs sie löscht. Ein Agent-Zeitplan speichert die zukünftige Aktion beim benannten Agent. So kann die Plattform die dauerhafte Instanz zum vorgesehenen Zeitpunkt wieder aktivieren.
In diesem Lab erstellen Sie ohne Sprachmodell eine kleine Follow-up-Übersicht:
schedule()registriert einen verzögerten Callback und gibt dessen dauerhafte Zeitplan-ID zurück.listSchedules()ermöglicht der Anwendung, ausstehende Aufgaben mit der aktuellen asynchronen API zu prüfen.cancelSchedule()entfernt einen noch ausstehenden Eintrag, nachdem der Server geprüft hat, ob er zu ihm gehört.- Der Callback speichert einen begrenzten Abschlussverlauf im Agent-Zustand und schreibt ein datenschutzbeschränktes Protokoll.
Sie planen zunächst eine kurze Aufgabe und beobachten, wie sie abgeschlossen wird. Danach erstellen Sie eine längere Aufgabe und brechen sie vor der Ausführung ab. Die Aufrufe verwenden ausschließlich künstliche Ticketreferenzen. Identische Registrierungsanfragen aktivieren die Idempotenz des SDK. Dadurch erzeugt ein versehentlicher Doppelklick keine doppelte Aufgabe.
Das Agents SDK implementiert diesen Lebenszyklus auf Grundlage eines SQLite-gestützten Durable-Object-Alarms. Sie verwenden die übergeordnete Schedule-API, anstatt Alarmzeitpunkte und Speichereinträge selbst zu verwalten. Die Aufgabe gehört dennoch zu genau einer benannten Agent-Instanz und bleibt bei gewöhnlichen Neustarts eines Workers erhalten.
Bevor Sie diesen Kurs direkt beginnen, absolvieren Sie LabEx mit Ihrem Cloudflare-Konto verbinden. Jede neue LabEx-VM benötigt eine eigene Wrangler-Autorisierung. Frühere Labs des Kurses werden empfohlen. Dieses Lab erstellt und entfernt jedoch eigene isolierte Ressourcen.
Die VM autorisieren und den Agent konfigurieren
In diesem Schritt autorisieren Sie die neue VM und definieren den einzigen temporären Worker sowie die einzige Durable-Object-Klasse, die dieses Lab verwendet.
cd /home/labex/project/follow-up-agent
npx wrangler login
npx wrangler whoami --json
Öffnen Sie den angezeigten Geräte-Link im LabEx-Browser, bestätigen Sie den angezeigten Code und genehmigen Sie das Lernkonto. Senden Sie niemandem ein Passwort, Token oder einen Autorisierungscode. Prüfen Sie im JSON-Ergebnis, ob "loggedIn": true gesetzt ist, lesen Sie den Kontonamen ab und kopieren Sie die Konto-ID.
Erzeugen Sie einen eindeutigen Ressourcennamen und erstellen Sie 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
Der Binding-Name wird vom Router und vom Client verwendet. Der Klassenname bezeichnet die Implementierung. Die Migration v1 weist Cloudflare an, SQLite-gestützten Speicher für diese Klasse zu erstellen. Sie erstellt noch keine bestimmte benannte Instanz. Eine Instanz wie planning entsteht erst, wenn eine Anfrage sie erstmals adressiert.
Dauerhafte Follow-up-Zeitpläne implementieren
In diesem Schritt implementieren Sie Registrierung, Prüfung, Abbruch und den späteren Callback in einem benannten Agent.
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 stammt aus den von Wrangler erzeugten Binding-Deklarationen, die Sie vor dem Kompilieren erstellen. Der Quellcode benötigt daher keine zweite, manuell gepflegte Kopie der Umgebung. schedule() erhält eine relative Verzögerung, den Namen des Callbacks und eine kleine serialisierbare Nutzlast. { idempotent: true } bedeutet: Wenn derselbe Callback mit derselben Nutzlast erneut aufgerufen wird, gibt die Methode den vorhandenen ausstehenden Zeitplan zurück, anstatt einen weiteren anzulegen. Die Wiederholungsrichtlinie erlaubt höchstens zwei Callback-Versuche mit einer kurzen, begrenzten Wartezeit. Ein dauerhafter Fehler kann daher nicht endlos wiederholt werden. Der Callback behält nur fünf künstliche Abschlüsse. Sein strukturiertes Protokoll enthält keine Ticketreferenz.
Die Listen- und Suchmethoden werden absichtlich mit await aufgerufen. Ältere Beispiele zeigen möglicherweise synchrone Aufrufe wie getSchedule() oder getSchedules(). Aktueller Code für das Agents SDK sollte jedoch getScheduleById() und listSchedules() verwenden.
Die Follow-up-Übersicht verbinden
In diesem Schritt konfigurieren Sie die aktuelle Decorator-Transformation und verbinden die bereitgestellte Seite mit einem benannten Agent.
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
Die Seite fragt den Agent alle zwei Sekunden ab, damit dieses reine TypeScript-Beispiel leicht verständlich bleibt. Der Zeitplan selbst ist kein Browser-Timer. Das Schließen der Seite bricht ihn nicht ab. Der Server bleibt für Validierung, Besitzprüfung und Ausführung maßgeblich.
Typen generieren und die Anwendung erstellen
In diesem Schritt generieren Sie die Binding-Typen und erstellen beide Teile der Anwendung, bevor Sie eine Laufzeit starten.
Generieren Sie die Umgebungstypen aus dem exakten Binding, prüfen Sie beide TypeScript-Seiten und erstellen Sie den Worker sowie die statische Seite:
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
Ein fehlerfreier Build zeigt, dass Binding, Decorator-Transformation, gemeinsam verwendete Typen und Bundles zusammenpassen. Er beweist noch nicht, dass ein Alarm ausgelöst wird oder dass das Cloud-Konto die bereitgestellte Ressource besitzt. Diese Laufzeitprüfungen führen Sie in den nächsten Schritten durch.
Den Lebenszyklus lokal nachweisen
In diesem Schritt weisen Sie nach, dass dauerhafte Ausführung und Abbruch in der lokalen Cloudflare-Laufzeit funktionieren.
Starten Sie die lokale Laufzeit als dauerhaft laufenden Hintergrundprozess:
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
Öffnen Sie http://localhost:5173 im LabEx-Desktop-Browser. Planen Sie T-DEMO-101 für 12 Sekunden. Der Eintrag erscheint zunächst unter Pending. Das Schließen oder Aktualisieren der Seite verwaltet diese Aufgabe nicht. Nach dem geplanten Zeitpunkt entfernt der Callback den Eintrag aus dem Zeitplanspeicher und trägt ihn unter Completed ein.
Planen Sie anschließend T-DEMO-CANCEL für 90 Sekunden und klicken Sie auf Cancel. Der Eintrag verschwindet aus Pending und erscheint nie unter Completed. Führen Sie die unabhängige Prüfung aus. Sie verwendet einen eigenen zufälligen Agent-Namen und weist idempotente Registrierung, Ausführung und Abbruch nach:
python3 .labex/verify.py local
Geplante Aufgaben bereitstellen und prüfen
In diesem Schritt wiederholen Sie den Lebenszyklus in Cloudflare und verknüpfen das beobachtbare Verhalten mit den Nachweisen im Dashboard.
Beenden Sie den exakt gestarteten lokalen Prozess, stellen Sie den Produktions-Build bereit und warten Sie auf seine 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
Öffnen Sie die exakte URL im integrierten Browser. Planen Sie T-CLOUD-101 für 20 Sekunden und beobachten Sie zunächst den dauerhaft gespeicherten ausstehenden Eintrag.

Ausführungszeit und Zeitplan-ID gehören zum temporären akzeptierten Lauf. Ihre Werte unterscheiden sich. Entscheidend ist, dass der Agent den Eintrag auflistet und nicht ein Countdown, der in der Seite gespeichert ist.
Warten Sie auf den Callback und prüfen Sie, ob dasselbe künstliche Ticket unter Completed erscheint.

Erstellen Sie T-CLOUD-CANCEL mit einer Verzögerung von 90 Sekunden, prüfen Sie seinen ausstehenden Zustand und brechen Sie es ab. Das Pending-Panel sollte wieder leer sein, während der abgeschlossene Eintrag unverändert bleibt.


Öffnen Sie Workers & Pages, wählen Sie den exakten Worker labex-c11-s04-... aus und prüfen Sie Bindings. Bestätigen Sie, dass FollowUpAgent auf denselben Klassennamen verweist.

Öffnen Sie Durable Objects und prüfen Sie den Namespace FollowUpAgent. Er verwendet SQL-Speicher, weil der Agent-Zustand und die Zeitpläne dauerhafte Datensätze benötigen.

Öffnen Sie schließlich Observability → Logs, filtern Sie nach follow_up_completed und blenden Sie ein Ereignis aus. Das begrenzte Ereignis enthält die Agent-Instanz, die Revision und die Anzahl der Abschlüsse, jedoch keine Ticketreferenz.

Dashboard-Ansichten können verspätet erscheinen. Daher ist die unabhängige Remote-Prüfung maßgeblich:
python3 .labex/verify.py deployed
python3 .labex/verify.py observed
Den Scheduling-Namespace und den Worker entfernen
In diesem Schritt löschen Sie nur den Klassen-Namespace und den Worker, die von diesem Lab erstellt wurden.
Zeitpläne und Abschlusszustand liegen im Namespace der Durable-Object-Klasse. Löschen Sie diese Klasse ausdrücklich, bevor Sie den verbleibenden zustandslosen Worker entfernen:
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
Löschen Sie keine nicht zugehörigen Ressourcen des Kontos. Bestätigen Sie, dass nur der exakt erzeugte Worker und sein Namespace FollowUpAgent entfernt wurden.


Die Autorisierung dieser VM widerrufen
In diesem Schritt entfernen Sie die in dieser temporären VM gespeicherte OAuth-Berechtigung und prüfen den strukturierten abgemeldeten Zustand.
Entfernen Sie nach erfolgreicher Cloud-Bereinigung die in dieser temporären VM gespeicherte OAuth-Autorisierung:
npx wrangler logout
npx wrangler whoami --json
python3 .labex/verify.py logout
Verlangen Sie ausdrücklich "loggedIn": false. Ein Netzwerkfehler ist nicht eindeutig und sollte erneut geprüft werden. Der temporäre Worker, sein Scheduling-Namespace und die lokale Autorisierung dieser VM sind nun entfernt.
Zusammenfassung
Sie haben einem benannten Cloudflare Agent dauerhafte zukünftige Aufgaben gegeben, ohne einen geöffneten Browser oder ein Sprachmodell vorauszusetzen. Sie haben einen begrenzten verzögerten Callback registriert, wiederholte Registrierungen idempotent gemacht, ausstehende Zeitpläne über die aktuelle asynchrone API geprüft, den Besitz vor dem Abbruch verifiziert und nur einen kleinen Abschlussverlauf gespeichert.
Außerdem haben Sie die Abstraktion des SDK mit dem Alarm-Lebenszyklus eines Durable Objects verbunden, Abschluss und Abbruch lokal sowie remote nachgewiesen, datenschutzbeschränkte Nachweise geprüft und den Klassen-Namespace, den Worker sowie die Autorisierung der temporären VM ausdrücklich entfernt.



