Introdução
Uma equipe de suporte costuma prometer verificar um ticket novamente mais tarde: depois que o cliente tentar uma correção, depois que uma janela de manutenção terminar ou antes do prazo para uma escalação. Um timer do navegador não pode assumir essa responsabilidade com segurança, porque fechar a aba o apaga. Um agendamento de Agent armazena a ação futura junto ao Agent nomeado, permitindo que a plataforma desperte essa instância durável quando chegar o momento.
Neste laboratório, você criará um pequeno painel de acompanhamentos sem usar um modelo de linguagem:
schedule()registra um callback atrasado e retorna o ID durável do agendamento.listSchedules()permite que a aplicação inspecione o trabalho pendente usando a API assíncrona atual.cancelSchedule()remove um item ainda pendente depois que o servidor verifica o que pertence a ele.- O callback registra uma conclusão limitada no estado do Agent e emite um log com dados mínimos.
Você agendará uma tarefa curta e observará sua conclusão. Depois, criará uma tarefa mais longa e a cancelará antes da execução. As chamadas usam apenas referências de tickets fictícias. Solicitações de registro idênticas usam a idempotência do SDK, impedindo que um clique duplo acidental crie trabalho duplicado.
O Agents SDK implementa esse ciclo de vida sobre um alarme de Durable Object com armazenamento baseado em SQLite. Você usa a API de agendamento de nível mais alto em vez de gerenciar timestamps de alarmes e registros de armazenamento por conta própria, mas o trabalho ainda pertence a uma única instância de Agent nomeada e sobrevive às reinicializações normais do Worker.
Antes de entrar diretamente neste curso, conclua Conectar o LabEx à sua conta da Cloudflare. Cada nova VM do LabEx precisa de sua própria autorização do Wrangler. Os laboratórios anteriores do curso são recomendados, mas este laboratório cria e remove seus próprios recursos isolados.
Autorizar a VM e configurar o Agent
Nesta etapa, você autorizará esta VM nova e definirá o único Worker descartável e a única classe de Durable Object usados pelo laboratório.
cd /home/labex/project/follow-up-agent
npx wrangler login
npx wrangler whoami --json
Abra o link do dispositivo exibido no navegador do LabEx, confirme o código mostrado e aprove a conta de aprendizagem. Não envie senha, token ou código de autorização a ninguém. No resultado JSON, confirme que "loggedIn": true, leia o nome da conta e copie o ID dela.
Gere um nome de recurso exclusivo e crie 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
O nome do binding é usado pelo roteador e pelo cliente; o nome da classe identifica a implementação. A migração v1 pede à Cloudflare que crie armazenamento baseado em SQLite para essa classe. Ela ainda não cria uma instância nomeada específica: uma instância como planning aparece quando o tráfego a acessa pela primeira vez.
Implementar o agendamento durável de acompanhamentos
Nesta etapa, você implementará o registro, a inspeção, o cancelamento e o callback final em um único Agent nomeado.
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 vem das declarações de binding geradas pelo Wrangler que você criará antes da compilação. Assim, o código-fonte não mantém uma segunda cópia escrita manualmente do ambiente. schedule() recebe um atraso relativo, o nome do callback e um payload pequeno e serializável. { idempotent: true } significa que repetir o mesmo callback com o mesmo payload retorna o agendamento pendente existente, em vez de adicionar outro. A política de novas tentativas permite no máximo duas execuções do callback, com um recuo curto e limitado; portanto, um erro permanente não entra em um loop infinito. O callback mantém apenas cinco conclusões fictícias, e seu log estruturado não inclui a referência do ticket.
Os métodos de listagem e consulta são aguardados explicitamente. Exemplos mais antigos podem mostrar chamadas síncronas como getSchedule() ou getSchedules(); no código atual do Agents SDK, use getScheduleById() e listSchedules().
Conectar o painel de acompanhamentos
Nesta etapa, você configurará a transformação atual dos decorators e conectará a página fornecida a um Agent nomeado.
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
A página consulta o Agent a cada dois segundos apenas para manter este exemplo simples em TypeScript. O agendamento em si não é um timer do navegador: fechar a página não o cancela. O servidor continua sendo a autoridade para validação, propriedade e execução.
Gerar os tipos e compilar a aplicação
Nesta etapa, você gerará os tipos dos bindings e compilará as duas partes da aplicação antes de iniciar qualquer runtime.
Gere os tipos do ambiente a partir do binding exato, verifique as duas partes em TypeScript e compile o Worker e a 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
Uma compilação sem erros comprova que o binding, a transformação dos decorators, os tipos compartilhados e os bundles são compatíveis. Isso ainda não comprova que um alarme será disparado nem que a conta na nuvem possui o recurso implantado; essas verificações de runtime serão feitas nas próximas etapas.
Comprovar o ciclo de vida localmente
Nesta etapa, você comprovará que a execução durável e o cancelamento funcionam no runtime local da Cloudflare.
Inicie o runtime local como um processo persistente em 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 no navegador da área de trabalho do LabEx. Agende T-DEMO-101 para 12 segundos. Primeiro, ele aparecerá em Pending; fechar ou atualizar a página não controla essa tarefa. Depois do horário agendado, o callback a removerá do armazenamento de agendamentos e a registrará em Completed.
Em seguida, agende T-DEMO-CANCEL para 90 segundos e clique em Cancel. O item desaparecerá de Pending e nunca chegará a Completed. Execute a verificação independente, que usa seu próprio nome aleatório de Agent e comprova o registro idempotente, a execução e o cancelamento:
python3 .labex/verify.py local
Implantar e inspecionar o trabalho agendado
Nesta etapa, você repetirá o ciclo de vida na Cloudflare e relacionará o comportamento observável às evidências do Dashboard.
Pare o processo local exato, implante a compilação de produção e aguarde a 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 a URL exata no navegador integrado. Agende T-CLOUD-101 para 20 segundos e observe primeiro a linha durável pendente.

O horário de execução e o ID do agendamento pertencem à execução descartável aceita; seus valores serão diferentes. A evidência importante é que o item é listado pelo Agent, e não por uma contagem regressiva armazenada na página.
Aguarde o callback e confirme que o mesmo ticket fictício aparece em Completed.

Crie T-CLOUD-CANCEL para 90 segundos, registre seu estado pendente e cancele-o. O painel de pendências deve voltar a ficar vazio, enquanto a entrada concluída permanece inalterada.


Abra Workers & Pages, selecione o Worker exato labex-c11-s04-... e inspecione Bindings. Confirme que FollowUpAgent aponta para o mesmo nome de classe.

Abra Durable Objects e inspecione o namespace FollowUpAgent. Ele usa armazenamento SQL porque o estado e os agendamentos do Agent exigem registros duráveis.

Por fim, abra Observability → Logs, filtre por follow_up_completed e expanda um evento. O evento limitado contém a instância do Agent, a revisão e a contagem de conclusões, mas não contém a referência do ticket.

As visualizações do Dashboard podem aparecer com atraso; por isso, a verificação remota independente é a autoridade:
python3 .labex/verify.py deployed
python3 .labex/verify.py observed
Remover o namespace de agendamentos e o Worker
Nesta etapa, você apagará somente o namespace da classe e o Worker criados por este laboratório.
Os agendamentos e o estado das conclusões ficam no namespace da classe de Durable Object. Exclua explicitamente essa classe antes de remover o Worker stateless 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
Não exclua recursos não relacionados da conta. Confirme que somente o Worker gerado exato e seu namespace FollowUpAgent foram removidos.


Revogar a autorização desta VM
Nesta etapa, você removerá a concessão OAuth armazenada nesta VM descartável e verificará o estado estruturado de logout.
Depois que a limpeza na nuvem for concluída, remova a autorização OAuth armazenada nesta VM descartável:
npx wrangler logout
npx wrangler whoami --json
python3 .labex/verify.py logout
Confirme explicitamente "loggedIn": false. Um erro de rede não é conclusivo e deve ser verificado novamente. O Worker descartável, seu namespace de agendamentos e a autorização local desta VM agora foram removidos.
Resumo
Você atribuiu a um único Cloudflare Agent nomeado um trabalho futuro durável sem depender de um navegador aberto ou de um modelo de linguagem. Registrou um callback atrasado e limitado, tornou os registros repetidos idempotentes, inspecionou os agendamentos pendentes pela API assíncrona atual, verificou a propriedade antes do cancelamento e registrou apenas um pequeno histórico de conclusões.
Você também relacionou a abstração do SDK ao ciclo de vida do alarme do Durable Object, comprovou a conclusão e o cancelamento local e remotamente, inspecionou evidências com dados mínimos e removeu explicitamente o namespace da classe, o Worker e a autorização da VM descartável.



