Introdução
Um Agent durável pode memorizar uma fila de suporte, mas um painel útil também precisa manter todas as telas conectadas atualizadas. O polling solicita ao servidor uma nova cópia repetidamente. Já o Cloudflare Agents SDK abre um WebSocket: uma conexão bidirecional de longa duração que pode enviar uma atualização a todos os clientes do mesmo Agent nomeado assim que o estado mudar.
Você criará um painel deliberadamente pequeno e sem LLM. Dois clientes independentes de JavaScript vanilla — Dispatcher e Observer — conectam-se a SupportDashboard:planning. O Dispatcher chama um método do servidor marcado com @callable(). Esse método valida o ticket, atualiza o estado do Agent uma vez, e o SDK transmite o estado resultante aos dois clientes. Um título inválido é rejeitado no servidor e não incrementa a revisão compartilhada.
Este laboratório apresenta apenas quatro componentes, conforme a aplicação precisa deles:
AgentClientmantém a conexão WebSocket do navegador.onStateUpdateredesenha a interface depois que o servidor transmite o estado.@callable()expõe um método específico do servidor aos clientes conectados.setState()persiste um próximo estado autoritativo e inicia a sincronização.
O exemplo usa texto sintético de suporte e um Worker público descartável, para que você possa se concentrar no protocolo. A validação de entrada não é autenticação de usuário. Uma ferramenta de suporte em produção precisa adicionar uma camada de identidade e autorização antes de expor dados de clientes ou operações de alteração.
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. O S01 é recomendado porque este laboratório se baseia em identidade de Agent nomeada, estado durável e limpeza explícita, mas não pressupõe conhecimento de React ou de modelos de IA.
Autorizar a VM e configurar o painel
Nesta etapa, você autorizará a VM recém-criada, confirmará a conta da Cloudflare pretendida e declarará o único namespace de Agent usado pelo painel.
Entre no projeto preparado e confirme o runtime fixado. A configuração instalou as dependências e forneceu apenas a estrutura visual da página; ela não autorizou a Cloudflare nem implementou o Agent.
cd /home/labex/project/support-dashboard-agent
node --version
npx wrangler --version
npm list agents vite @cloudflare/vite-plugin --depth=0
Você deve obter Node.js v22.22.0, Wrangler 4.134.0, Agents SDK 0.23.0, Vite 8.3.0 e o plugin Vite da Cloudflare 1.55.0.
Autorize esta VM e inspecione a identidade estruturada:
npx wrangler login --device --browser=false
npx wrangler whoami --json
Abra o link exibido em um navegador, informe o código curto, confirme sua conta de aprendizagem dedicada e verifique as permissões antes de autorizar. De volta ao terminal, confirme loggedIn: true. Em seguida, selecione a conta pelo nome de exibição confirmado sem imprimir o 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"
Se sua conta de aprendizagem usar outro nome, substitua apenas LabEx Learning depois de confirmar que essa é a conta pretendida. Crie a configuração:
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
O binding seleciona o namespace da classe do Agent; o nome da instância será fornecido por cada cliente do navegador. A configuração, por si só, não cria nenhum recurso na nuvem.
Implementar um método chamável validado
Nesta etapa, você implementará o estado compartilhado da fila e a única alteração que pode ser chamada pelo navegador.
O servidor é responsável pela regra de alteração. Um navegador pode solicitar uma atualização, mas não deve decidir se um título ou uma prioridade é válido. Crie 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() é um limite explícito de RPC: somente os métodos decorados podem ser chamados pelo protocolo do cliente do Agent. A validação acontece antes de setState(), portanto chamadas rejeitadas não podem incrementar a revisão. Manter apenas os seis tickets sintéticos mais recentes limita o tamanho do estado da demonstração. O log estruturado contém a instância, a revisão e a quantidade, mas não o texto do ticket.
Conectar dois clientes de navegador vanilla
Nesta etapa, você configurará o caminho atual de compilação dos decorators e conectará dois clientes vanilla independentes ao mesmo Agent nomeado.
O decorator atual do SDK usa a transformação padrão de decorators do JavaScript. Por isso, um projeto manual precisa tanto do preset TypeScript do Agents quanto do plugin Agents para Vite. Não habilite o modo legado experimentalDecorators do 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
Crie 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
Estes são dois clientes WebSocket reais, embora apareçam em uma única página. Ambos são encaminhados para a mesma classe e o mesmo nome, portanto recebem a mesma transmissão de estado. Apenas o Dispatcher faz a chamada; o Observer demonstra que a sincronização é controlada pelo servidor, e não uma atualização de DOM copiada.
Gerar os tipos e compilar os dois lados
Nesta etapa, você verificará o contrato de estado compartilhado e compilará o Worker e a aplicação do navegador antes de iniciar um runtime.
Gere os tipos do ambiente a partir da configuração exata do binding:
npx wrangler types
grep -n "SupportDashboard" worker-configuration.d.ts | head
Execute o TypeScript no Worker, no cliente do navegador e na configuração do Vite:
npm run check
A ausência de diagnósticos do compilador significa que o formato do estado, o servidor chamável e o cliente DOM estão de acordo. Compile os dois destinos de produção:
npm run build
find dist -maxdepth 3 -type f | sort | sed -n '1,16p'
O Vite informa um ambiente de Worker e um ambiente de cliente. O plugin da Cloudflare produz o bundle do Worker e anexa a página estática compilada; o plugin Agents aplica a transformação atual dos decorators. Uma compilação bem-sucedida comprova o empacotamento, mas não o comportamento do WebSocket, a propriedade da conta nem a implantação remota.
Observar a sincronização local e a rejeição
Nesta etapa, você observará dois clientes locais convergirem depois de uma atualização válida e permanecerem inalterados depois de uma atualização inválida.
Inicie o runtime local do Vite e do Workers como um processo 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
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 no navegador dentro do desktop do LabEx. Aguarde até que o status verde informe que os dois clientes estão conectados. Os dois cartões começam na revisão 0, sem tickets.
Mantenha o título preparado e clique em Add with Dispatcher. Os dois cartões devem avançar para a revisão 1 e exibir o mesmo ticket. Primeiro, o Dispatcher envia um frame de RPC pelo WebSocket. addTicket() valida os argumentos no Agent; em seguida, setState(next) persiste a revisão 1 e a transmite. Os dois handlers onStateUpdate redesenham seus cartões de forma independente.
Agora substitua o título por x e envie novamente. A página exibirá title must contain 3-80 characters; os dois cartões permanecerão na revisão 1. Isso comprova que a validação ocorreu antes da gravação do estado.
Execute a verificação local independente:
python3 .labex/verify.py local
O verificador usa nomes novos e exclusivos para cada execução, em vez de confiar no exemplo visível. Ele abre dois clientes, comprova a convergência, verifica que um nome diferente permanece na revisão zero, envia uma atualização inválida e confirma que a revisão compartilhada não muda.
Implantar e inspecionar o painel na nuvem
Nesta etapa, você implantará o bundle de produção, comprovará o mesmo contrato de dois clientes na Cloudflare e relacionará esse comportamento às evidências do Dashboard.
Pare o processo local exato e implante a compilação de produção:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npm run deploy
O Wrangler aplica a migração v1, envia o Worker junto com o cliente estático e exibe uma URL workers.dev. Salve essa URL exata:
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 a URL no navegador integrado. Adicione Cloud dashboard ticket como Urgent. Os dois cartões devem mostrar a mesma revisão e o marcador vermelho de urgência. Em seguida, envie x; a rejeição aparecerá, enquanto as duas revisões permanecerão inalteradas. Estas são novas instâncias de Agent pertencentes à nuvem; o estado local do Vite é intencionalmente separado.

Nesta execução de teste real, o Dispatcher realizou a gravação enquanto o Observer recebeu a mesma transmissão. O texto do ticket e a revisão são exemplos do recurso descartável do curso; seus próprios valores podem ser diferentes.

O erro aparece ao lado do campo de entrada, mas nenhum dos cartões avança. Leia a revisão inalterada nos dois cartões como a pista principal: o servidor rejeitou o argumento antes de chamar setState().
Abra Workers & Pages no Cloudflare Dashboard e selecione seu Worker exato labex-c11-s02-.... Use a aba Bindings para confirmar que SupportDashboard aponta para a classe de Durable Object SupportDashboard. Em Durable Objects, confirme que o namespace usa armazenamento SQL. Por fim, abra Observability → Logs, filtre por support_queue_updated e expanda um evento. Compare a instância planning, a revisão e a quantidade de tickets; o título do ticket está ausente de propósito.

A visão geral reúne várias ideias que você usou separadamente: o domínio workers.dev alcança o Worker, o binding o conecta ao estado durável e o contador de zero erros é um indicador rápido de integridade. O nome do Worker mostrado nesta captura pertence a uma execução de teste aceita.

O grafo de bindings deve conectar seu Worker exato a um Durable Object chamado SupportDashboard. Isso é uma evidência de configuração; não substitui a verificação do comportamento com dois clientes.

A página do namespace identifica o armazenamento durável por trás da classe do Agent e informa Storage: SQL. O ID opaco do namespace está oculto na imagem didática por privacidade; os alunos nunca precisam copiá-lo.

O evento expandido contém o nome sintético da instância, a revisão e a quantidade de tickets, mas não o título do ticket. Isso é minimização deliberada de dados: os logs devem ajudar a diagnosticar o comportamento sem copiar conteúdo potencialmente sensível dos usuários.
Os dados do Dashboard podem chegar com atraso, portanto uma visualização vazia de logs recentes não é conclusiva. As configurações autenticadas, o namespace pertencente à conta e as verificações independentes em tempo real com AgentClient são as fontes autoritativas.
python3 .labex/verify.py deployed
python3 .labex/verify.py observed
A primeira verificação cria novos nomes remotos e comprova sincronização, isolamento e rejeição sem confiar no exemplo visível planning. A segunda mantém os recursos exatos pertencentes à conta disponíveis para sua inspeção somente leitura no Dashboard.
Remover o namespace e o Worker do painel
Nesta etapa, você apagará explicitamente o namespace da classe do Agent e depois removerá o Worker restante enquanto a VM ainda estiver autorizada.
A fila está armazenada no namespace da classe do Durable Object; portanto, exclua explicitamente essa classe antes de excluir o Worker restante sem estado. Crie um entrypoint de limpeza:
cat > src/cleanup.ts <<'TS'
export default {
fetch() {
return Response.json({ status: "cleanup" }, { status: 410 });
}
};
TS
Mantenha a migração original e acrescente v2 para a exclusão:
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
O histórico de migrações é somente de acréscimo: reescrever v1 não descreveria a transição já aplicada na Cloudflare. No Dashboard, confirme que o Worker exato e seu namespace SupportDashboard estão ausentes. Preserve os recursos não relacionados, caso sua conta contenha algum.

A conta testada voltou à visão geral de Workers & Pages depois da exclusão. Sua conta de aprendizagem pode conter Workers não relacionados; verifique se o nome exato labex-c11-s02-... desapareceu, em vez de esperar uma conta vazia.

A conta de teste aceita também voltou a uma visão geral vazia de Durable Objects. Em uma conta com outros namespaces, preserve-os e confirme que apenas o namespace pertencente a este laboratório foi removido.
Revogar a autorização desta VM
Nesta etapa, você removerá a autorização OAuth armazenada somente nesta VM descartável e verificará o estado estruturado de sessão encerrada.
A limpeza na nuvem foi concluída, mas esta VM descartável ainda mantém sua concessão OAuth local. Remova-a e solicite o status estruturado:
npx wrangler logout
npx wrangler whoami --json
python3 .labex/verify.py logout
O JSON deve conter explicitamente "loggedIn": false. Um erro de rede não é evidência de logout; repita a leitura do status quando a conectividade retornar. O painel público sintético, seu estado durável e a autorização desta VM foram removidos.
Resumo
Você transformou um Agent nomeado e durável em uma aplicação de navegador em tempo real sem introduzir React ou um modelo de linguagem. Duas conexões AgentClient selecionaram SupportDashboard:planning, um método @callable() validado controlou a alteração, setState() persistiu uma revisão autoritativa e o SDK transmitiu esse estado aos dois handlers onStateUpdate.
Você também aprendeu por que o caminho atual de decorators precisa de agents/tsconfig e agents/vite, distinguiu um RPC por WebSocket de alterações diretas no estado do cliente, comprovou que uma entrada rejeitada não produz efeito, repetiu a sincronização e o isolamento por nome na Cloudflare, inspecionou evidências com dados limitados por privacidade e removeu explicitamente o namespace da classe, o Worker e a autorização da VM.



