El Agents SDK: cada agente es un Durable Object
El Agents SDK no inventa un runtime nuevo: toma la primitiva más peculiar de la plataforma y la viste de agente. Cada instancia es un Durable Object con nombre propio, un solo hilo, SQLite pegado al cómputo y WebSockets para hablar con el cliente sin que nadie pregunte. Sobre esa base, la clase Agent añade estado sincronizado automáticamente con el navegador, tareas programadas que sobreviven a la hibernación y un enrutado por nombre que convierte una URL en una identidad. Vemos las tres capas del apilamiento, cómo se declara un agente, cómo fluye el estado en los dos sentidos y por qué el nombre de la instancia es la decisión de diseño más importante.
Cuando estudiaste los Durable Objects seguramente los archivaste como la pieza rara del catálogo: la que se usa para salas de chat, contadores y cerrojos. Ahora vuelven convertidos en la infraestructura central de la IA con estado, y no por moda. Un agente necesita un nombre estable al que dirigirse, un hilo único que ordene los turnos, memoria que sobreviva al reinicio y un canal abierto para contar lo que va haciendo. Esa lista no describe un producto de IA: describe exactamente un Durable Object. El Agents SDK es, en el fondo, el reconocimiento de esa coincidencia —una capa fina que envuelve la primitiva que ya tenías y le pone encima el vocabulario del agente.
- Ver el apilamiento de tres capas que hay bajo la clase
Agenty qué aporta cada una. - Declarar un agente, su binding y su enrutado por nombre con
routeAgentRequest. - Usar el estado sincronizado con
setStatey entender su viaje de ida y vuelta al cliente. - Justificar por qué el nombre de la instancia es la decisión de diseño que gobierna todo lo demás.
Tres capas bajo un nombre
La clase que vas a extender es la punta de un apilamiento muy deliberado. Abajo está el DurableObject del runtime, con sus propiedades duras: una instancia por identificador en todo el planeta, ejecución de un solo hilo y almacenamiento transaccional en SQLite dentro del propio objeto. En medio está Server, heredado de partyserver, que no añade persistencia: sustituye las primitivas de bajo nivel por callbacks cómodos y aporta el direccionamiento por nombre. Arriba, la clase Agent añade lo específico del oficio: estado sincronizado, tareas programadas, clientes MCP y los ganchos del ciclo de vida.
| Capa | Qué aporta |
|---|---|
DurableObject |
Unicidad global, un hilo, SQLite local, alarmas, hibernación |
Server |
Direccionamiento por nombre, onStart, onRequest, onConnect |
Agent |
Estado sincronizado, this.sql, planificación, herramientas y chat |
Esta arqueología importa porque explica qué puedes esperar y qué no. Nada de lo que ofrece el SDK es magia inaccesible: cuando necesites bajar, ahí abajo sigue estando this.ctx.storage con su transaccionalidad, y las mismas garantías de consistencia que estudiaste. Y explica también la restricción operativa: un agente exige el binding y la migración de un Durable Object, porque literalmente lo es.
import { Agent, routeAgentRequest } from "agents";
type Estado = { turnos: number; euros: number };
export class AgenteSoporte extends Agent<Env, Estado> {
initialState: Estado = { turnos: 0, euros: 0 };
async onStart() {
this.sql`CREATE TABLE IF NOT EXISTS notas (id TEXT PRIMARY KEY, texto TEXT)`;
}
async onMessage(conexion: Connection, mensaje: string) {
const respuesta = await this.pensar(mensaje);
this.setState({ ...this.state, turnos: this.state.turnos + 1 });
conexion.send(respuesta);
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return (
(await routeAgentRequest(request, env)) ??
new Response("No encontrado", { status: 404 })
);
},
};
El Worker de entrada ha quedado reducido a un despachador. routeAgentRequest lee de la URL el nombre de la clase y el nombre de la instancia, resuelve el objeto correspondiente y le entrega la petición; si la ruta no le corresponde, devuelve un valor nulo y sigues tú. Todo el trabajo interesante ocurre ya dentro del objeto, donde this.state, this.sql y las conexiones abiertas están simplemente ahí, sin cliente que instanciar ni credencial que pasar.
El estado que viaja solo
El estado del agente tiene dos formas y conviene no confundirlas. Por un lado está this.state, un objeto serializable, pequeño, pensado para lo que el cliente debe ver: el turno actual, el gasto acumulado, si hay una herramienta esperando aprobación. Por otro está this.sql, la base SQLite embebida, para lo que crece: el historial completo, los documentos, las trazas de cada paso.
Lo notable es el comportamiento de la primera. Cuando llamas a setState, no solo persistes: el SDK difunde el estado nuevo a todos los clientes conectados por WebSocket. Y funciona en los dos sentidos, porque el cliente también puede empujar. Ese doble camino convierte el par agente y navegador en algo parecido a un estado compartido replicado, sin que escribas una sola línea de sincronización.
import { useAgent } from "agents/react";
function Panel({ conversacionId }: { conversacionId: string }) {
const agente = useAgent<AgenteSoporte, Estado>({
agent: "agente-soporte",
name: `conv-${conversacionId}`,
onStateUpdate: (estado, origen) => console.log(origen, estado.turnos),
});
return <p>Turnos: {agente.state?.turnos ?? 0}</p>;
}
El hook abre el WebSocket, lo reconecta si se cae y expone state como una propiedad reactiva. Fíjate en el argumento name: es el que decide a qué instancia te conectas. Dos pestañas con el mismo nombre aterrizan en el mismo agente y ven el mismo estado en el mismo instante; dos nombres distintos son dos agentes que no comparten absolutamente nada.
flowchart LR C1[pestana A] --> W[worker con routeAgentRequest] C2[pestana B] --> W C3[otro usuario] --> W W -->|conv-42| A1[agente conv-42] W -->|conv-99| A2[agente conv-99] A1 --> S1[estado y sqlite propios] A2 --> S2[estado y sqlite propios] style A1 fill:#89b4fa,color:#11111b style A2 fill:#89b4fa,color:#11111b
Tareas que despiertan solas
Hay una capacidad que distingue a un agente de un chat y que se olvida con facilidad: puede actuar cuando nadie le está hablando. El SDK lo expone con this.schedule, que acepta un retardo en segundos, una fecha concreta o una expresión de cron, y el nombre del método que debe ejecutarse cuando llegue el momento.
export class AgenteSoporte extends Agent<Env, Estado> {
async onMessage(conexion: Connection, mensaje: string) {
await this.schedule(60 * 60 * 24 * 3, "revisarPedido", { pedido: "A-1001" });
}
async revisarPedido(datos: { pedido: string }) {
const estado = await this.env.PEDIDOS.consultar(datos.pedido);
if (estado === "retenido") {
this.broadcast(JSON.stringify({ aviso: "tu pedido sigue retenido", ...datos }));
}
}
}
Por debajo son las alarmas de los Durable Objects, con toda su durabilidad: el agente hiberna, deja de consumir recursos y el runtime lo despierta tres días después con el contexto intacto. Nada de esto exige un cron externo, ni una cola de tareas, ni un proceso vigilando una tabla, y por eso encaja tan bien con el seguimiento —comprobar dentro de una semana si el caso quedó resuelto— o con la memoria que se destila por la noche.
Lo interesante es que la planificación también puede ser una herramienta que expones al modelo. Si le das la capacidad de programarse a sí mismo, el agente puede responder a un recuérdamelo el viernes creando su propia cita futura. Es un patrón potente y, como todo lo potente en este nivel, conviene acotarlo: sin un tope de tareas pendientes por agente, tienes un despertador que se multiplica.
El nombre es la arquitectura
Todo lo anterior descansa en una elección que se toma en una línea y condiciona el sistema entero: qué cadena usas como nombre de la instancia. Ese nombre define la unidad de aislamiento, la unidad de concurrencia y la unidad de memoria, las tres a la vez.
Por conversación
Cada hilo de chat es un agente. Aislamiento perfecto y turnos ordenados, pero nada se aprende de una conversación a la siguiente.
Por usuario
Un agente por persona, que recuerda entre sesiones. A cambio, todas sus conversaciones se serializan en el mismo hilo.
Por organización
Memoria compartida por el equipo y un presupuesto común, con el riesgo de convertir el objeto en un cuello de botella.
Compuesto
Un agente por conversación que consulta por RPC a otro agente de usuario. Aísla los turnos sin renunciar a la memoria larga.
Como los turnos de una misma instancia se ejecutan en un solo hilo, el nombre es también el ámbito de la exclusión mutua: dentro de un agente jamás hay dos turnos pisándose. Ese regalo tiene precio, y es el que ya conoces del nivel de los Durable Objects: un agente muy compartido es una cola con un único servidor, y una operación lenta bloquea a los demás. Elegir el nombre es, por tanto, elegir dónde pones la frontera entre aislamiento y memoria compartida.
Vale la pena detenerse en la profundidad de lo que ha ocurrido aquí, porque es un cambio de paradigma disfrazado de detalle de implementación. El backend moderno se construyó sobre servicios sin estado detrás de un balanceador: instancias intercambiables, anónimas, donde la identidad vive en la base de datos y el cómputo es una granja de trabajadores idénticos que se turnan. Ese diseño resolvió magníficamente el escalado horizontal y a cambio impuso un impuesto que aprendimos a pagar sin discutir: cada operación empieza yendo a buscar el estado a otro sitio, y cada escritura concurrente exige coordinación —transacciones, bloqueos optimistas, colas— porque cualquier instancia puede tocar cualquier dato. Un agente sobre Durable Objects invierte esa figura y recupera, con cincuenta años de retraso, el modelo de actores de Hewitt y Erlang: una entidad con dirección, con estado propio que nadie más toca, que procesa mensajes de uno en uno y cuya identidad es una cadena, no una fila. Las consecuencias son enormes y casi todas invisibles hasta que las buscas. Desaparecen las condiciones de carrera dentro del agente, no porque las gestiones bien sino porque no existen. Desaparece la caché, porque el estado ya está en la memoria del proceso que lo usa. Desaparece la pregunta de a qué réplica dirigirse, porque solo hay una y el nombre te lleva a ella. Y aparece a cambio una responsabilidad nueva que el mundo sin estado no te obligaba a asumir: el particionado deja de ser una optimización tardía y pasa a ser el primer acto de diseño, porque el nombre que elijas fija de un plumazo la granularidad de la memoria, el ámbito de la concurrencia y el radio de un fallo. En el mundo de los servicios, el reparto de datos era una decisión que podías posponer y corregir; en el mundo de los actores es la arquitectura misma. Quien entiende esto deja de escribir código de IA y empieza a diseñar sistemas distribuidos, que es exactamente lo que ha estado construyendo todo el tiempo.
- Declara un agente que extienda
Agentcon un estado propio y su binding de Durable Object con la migración correspondiente. Comprueba querouteAgentRequestlo alcanza. - Conecta dos pestañas al mismo nombre y una tercera a un nombre distinto. Provoca un
setStatey describe con precisión qué ve cada una. - Guarda el historial completo en
this.sqly deja enthis.statesolo lo que el cliente debe pintar. Explica el criterio que has usado para repartir. - Elige entre las cuatro estrategias de nombrado para tu caso real y defiende la elección midiendo dos cosas: qué se comparte y qué se serializa.
- Diseña el caso compuesto: un agente por conversación que consulta a un agente de usuario. Indica qué información vive en cada uno y por qué no puede vivir en el otro.