El plumbing del agente, escrito por Cloudflare. Tú escribes la lógica.
// conversation-agent.ts — sin Agents SDKimport { DurableObject } from "cloudflare:workers";interface Message { id: string; role: "user" | "assistant"; content: string; ts: number;}interface AgentState { status: "idle" | "thinking" | "responding"; messageCount: number; lastActivity: number;}export class ConversationDO extends DurableObject { private wsClients = new Set<WebSocket>(); private state: AgentState | null = null; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); ctx.blockConcurrencyWhile(async () => { ctx.storage.sql.exec(` CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, role TEXT NOT NULL, content TEXT NOT NULL, ts INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS state_kv ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); `); this.state = await this.loadState(); }); for (const ws of ctx.getWebSockets()) { this.wsClients.add(ws); } } async fetch(request: Request): Promise<Response> { const url = new URL(request.url); if (request.headers.get("Upgrade") === "websocket") { const pair = new WebSocketPair(); this.ctx.acceptWebSocket(pair[1]); this.wsClients.add(pair[1]); return new Response(null, { status: 101, webSocket: pair[0] }); } if (url.pathname === "/messages" && request.method === "GET") { return Response.json(this.getMessages()); } if (url.pathname === "/send" && request.method === "POST") { const { content } = await request.json<{ content: string }>(); return Response.json(await this.handleMessage(content)); } if (url.pathname === "/state") { return Response.json(this.state); } return new Response("Not found", { status: 404 }); } async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) { if (typeof msg !== "string") return; const parsed = JSON.parse(msg); if (parsed.type === "send") { await this.handleMessage(parsed.content); } } async webSocketClose(ws: WebSocket) { this.wsClients.delete(ws); } async alarm() { const next = await this.ctx.storage.get<string>("next_action"); if (next === "follow_up") await this.sendFollowUp(); if (next === "cleanup") await this.cleanup(); } async scheduleFollowUp(delayMs: number) { await this.ctx.storage.put("next_action", "follow_up"); await this.ctx.storage.setAlarm(Date.now() + delayMs); } private async loadState(): Promise<AgentState> { const row = this.ctx.storage.sql .exec<{ value: string }>( "SELECT value FROM state_kv WHERE key = ?", "state" ).one(); return row ? JSON.parse(row.value) : { status: "idle", messageCount: 0, lastActivity: Date.now() }; } private async setState(state: AgentState) { this.state = state; this.ctx.storage.sql.exec( "INSERT OR REPLACE INTO state_kv (key, value) VALUES (?, ?)", "state", JSON.stringify(state), ); this.broadcast({ type: "state", state }); } private broadcast(msg: unknown) { const json = JSON.stringify(msg); for (const ws of this.wsClients) { try { ws.send(json); } catch {} } } // ⬇ TU LÓGICA — 6 líneas en 127 private async handleMessage(content: string) { const id = crypto.randomUUID(); this.ctx.storage.sql.exec( "INSERT INTO messages (id, role, content, ts) VALUES (?, ?, ?, ?)", id, "user", content, Date.now(), ); await this.setState({ ...this.state!, messageCount: this.state!.messageCount + 1, lastActivity: Date.now() }); return { id, ok: true }; }
// conversation-agent.ts — sin Agents SDKimport { DurableObject } from "cloudflare:workers";interface Message { id: string; role: "user" | "assistant"; content: string; ts: number;}interface AgentState { status: "idle" | "thinking" | "responding"; messageCount: number; lastActivity: number;}export class ConversationDO extends DurableObject { private wsClients = new Set<WebSocket>(); private state: AgentState | null = null; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); ctx.blockConcurrencyWhile(async () => { ctx.storage.sql.exec(` CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, role TEXT NOT NULL, content TEXT NOT NULL, ts INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS state_kv ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); `); this.state = await this.loadState(); }); for (const ws of ctx.getWebSockets()) { this.wsClients.add(ws); } } async fetch(request: Request): Promise<Response> { const url = new URL(request.url); if (request.headers.get("Upgrade") === "websocket") { const pair = new WebSocketPair(); this.ctx.acceptWebSocket(pair[1]); this.wsClients.add(pair[1]); return new Response(null, { status: 101, webSocket: pair[0] }); } if (url.pathname === "/messages" && request.method === "GET") { return Response.json(this.getMessages()); } if (url.pathname === "/send" && request.method === "POST") { const { content } = await request.json<{ content: string }>(); return Response.json(await this.handleMessage(content)); } if (url.pathname === "/state") { return Response.json(this.state); } return new Response("Not found", { status: 404 }); } async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) { if (typeof msg !== "string") return; const parsed = JSON.parse(msg); if (parsed.type === "send") { await this.handleMessage(parsed.content); } } async webSocketClose(ws: WebSocket) { this.wsClients.delete(ws); } async alarm() { const next = await this.ctx.storage.get<string>("next_action"); if (next === "follow_up") await this.sendFollowUp(); if (next === "cleanup") await this.cleanup(); } async scheduleFollowUp(delayMs: number) { await this.ctx.storage.put("next_action", "follow_up"); await this.ctx.storage.setAlarm(Date.now() + delayMs); } private async loadState(): Promise<AgentState> { const row = this.ctx.storage.sql .exec<{ value: string }>( "SELECT value FROM state_kv WHERE key = ?", "state" ).one(); return row ? JSON.parse(row.value) : { status: "idle", messageCount: 0, lastActivity: Date.now() }; } private async setState(state: AgentState) { this.state = state; this.ctx.storage.sql.exec( "INSERT OR REPLACE INTO state_kv (key, value) VALUES (?, ?)", "state", JSON.stringify(state), ); this.broadcast({ type: "state", state }); } private broadcast(msg: unknown) { const json = JSON.stringify(msg); for (const ws of this.wsClients) { try { ws.send(json); } catch {} } } // tu lógica — los survivors private async handleMessage(content: string) { const id = crypto.randomUUID(); this.ctx.storage.sql.exec( "INSERT INTO messages (...) VALUES (...)", id, "user", content, Date.now(), ); await this.setState({ ...this.state!, messageCount: this.state!.messageCount + 1 }); return { id, ok: true }; }
// conversation-agent.ts — con Agents SDKimport { Agent } from "agents";interface AgentState { status: "idle" | "thinking" | "responding"; messageCount: number; lastActivity: number;}export class ConversationAgent extends Agent<Env, AgentState> { initialState: AgentState = { status: "idle", messageCount: 0, lastActivity: Date.now(), }; async onMessage(connection, message) { const { content } = JSON.parse(message); this.sql`INSERT INTO messages (id, role, content, ts) VALUES (${crypto.randomUUID()}, 'user', ${content}, ${Date.now()})`; this.setState({ ...this.state, messageCount: this.state.messageCount + 1, lastActivity: Date.now(), }); } async followUp() { /* tu lógica de follow-up */ }}
// dentro de tu Agent class:await this.schedule(5, 'incrementCounter');await this.schedule(10, 'logTick', { msg: 'check status'});// cron syntax para tareas recurrentes:await this.schedule('0 0 * * *', 'cleanup');// fecha específica:await this.schedule( new Date('2026-12-25T00:00:00Z'), 'sendGreeting', { msg: 'feliz navidad' },);// las schedules viven en el SQLite del DO:// SELECT * FROM cf_agents_schedules// state también es persistente y reactivo:this.setState({ ...this.state, counter: this.state.counter + 1,});
Las schedules viven en el SQLite del DO. Si el Worker reinicia, persisten. El alarm system de Cloudflare las dispara incluso si nadie está hablando con el agente. Reemplazan al cron + DB compartida del stack viejo.
El mismo objeto this.state que el agente muta en el server, lo lee tu componente React. Las dos partes siempre están sincronizadas porque son la misma cosa, no dos copias.
import { Agent } from "agents";export class ConversationAgent extends Agent<Env, State> { initialState = { counter: 0, msgs: [] }; async onMessage(connection, msg) { const { from, body } = JSON.parse(msg); this.setState({ ...this.state, counter: this.state.counter + 1, msgs: [...this.state.msgs, { from, body, ts: Date.now() }], }); // setState propaga a TODOS los clients via WebSocket }}
import { useAgent } from "agents/react";function ChatUI({ id }) { const { state, send } = useAgent({ agent: "conversation-agent", name: id, }); return ( <div> <h1>{state.counter}</h1> {state.msgs.map(m => <li>{m.body}</li>)} <button onClick={() => send(...)}/> </div> );}
Un agente llama a otros agentes. 4 servicios pegados con webhooks se vuelven un DO + bindings. La orquestación deja de vivir en glue code y vive en el lenguaje del programa.
export class Concierge extends Agent { async onRequest(topic) { const r = await this.callAgent( 'Research', { topic }); const draft = await this.callAgent( 'Writer', { sources: r }); const ok = await this.callAgent( 'Reviewer', { draft }); if (ok) await this.callAgent( 'Publisher', { draft }); }}
Eigen Atlas construye sobre este stack. Cada cliente vive en su propio DO, cada decisión que pinta en el negocio reside en código que tu equipo puede leer en una sentada.
eigenatlas builds these →