Construimos Cuatro Agentes y Cuatro Copias del Contexto de Nuestro Cliente
Cada agente nuevo en un SaaS vertical reconstruye el mismo pipeline. Keel es una capa de contexto open source: eventos de dominio entran, un grafo bitemporal por cliente sale, cinco herramientas MCP para todos los agentes. Con un ejemplo de Toche, mi app de finanzas del hogar.
El cuarto agente fue el que me rompió. No porque fuera difícil de construir. Porque fue fácil, y ya lo había construido antes. Tres veces.
Esta es la escena. Toche, la app de finanzas del hogar que construí: cuentas, un presupuesto mensual por rubro, deudas con foto mensual, un calendario de vencimientos, movimientos bancarios. Un hogar. Una tarjeta de crédito, una Visa. La foto de deudas de septiembre dice que el hogar debe 4.200.000 pesos al 29,22 % efectivo anual, con una cuota de 380.000. El 3 de octubre pagan la cuota de octubre desde ahorros. Hechos simples. Ahora hazle a los cuatro agentes que teníamos corriendo una sola pregunta: ¿cuánto debemos de la Visa?
El agente de vencimientos dice que octubre está pagado y que la siguiente cuota es el 5 de noviembre. No tiene idea del saldo; solo conoce el calendario. El agente de deudas dice 4.200.000, porque lee la foto mensual que cacheó en septiembre, y nadie le avisó que la de octubre ya había llegado. El agente de presupuesto dice 380.000 gastados en el rubro "Tarjeta Visa", justo en el plan, y tampoco sabe qué es un saldo. Y el asistente, el que de verdad habla con el hogar, hace la cuenta: 4.200.000 menos 380.000 son 3.820.000. Que está mal, porque los intereses existen, y la foto de octubre decía 3.870.000.
Cuatro agentes, cuatro respuestas, una deuda. Ninguna de ellas 3.870.000.
Eso no es un bug en ninguno de los cuatro. Cada uno hace exactamente lo que se construyó para hacer. El bug es que construimos cuatro, y debajo de cada uno construimos el mismo pipeline: extraer los datos del hogar de nuestra propia base de datos, normalizarlos a algo que un modelo pueda leer, decidir qué significa "cara", darle memoria al agente, exponerle herramientas al modelo. Cuatro copias del contexto del hogar. Cuatro definiciones de cara. Cuatro memorias que nunca se hablan.
Me pasé un fin de semana escribiendo qué haría falta para no hacerlo una quinta vez. El resultado es Keel, que publico hoy como open source, pre-alpha, con la tesis en la primera línea del README: deja de reconstruir el pipeline para cada agente.
El pipeline que nadie presupuesta
Cuando planeas un agente, presupuestas el agente: el prompt, las herramientas, los evals, el framework. En lo que de verdad se te va el tiempo es en todo lo que hay debajo.
Todo agente dentro de un producto como este necesita las mismas cinco cosas antes de ser útil. Necesita extraer la porción de datos del cliente que importa para su trabajo. Necesita normalizar esa porción a una forma sobre la que un modelo pueda razonar, porque una tabla debts con una fila de foto por mes y tres foreign keys no es esa forma. Necesita semántica: qué cuenta como cara, qué cuenta como vencido, qué cuenta como pasado de presupuesto. Necesita una memoria, porque un agente que concluye algo el lunes y lo olvida el martes es una función sin estado carísima. Y necesita herramientas, la superficie que el modelo invoca.
Nada de eso es el agente. Todo eso es el pipeline. Y al pipeline se fue el presupuesto del primer agente, y el del segundo, y el del tercero.
El costo no es el código. Copiar un pipeline es rápido; por eso mismo lo hicimos cuatro veces. El costo es la deriva. "Cara" estaba definido en cuatro prompts, y dos de ellos ya no coincidían cuando alguien se dio cuenta. El contexto del asistente se reconstruía cada noche y el del agente de deudas cada mes, así que la mayor parte del mes vivían en mundos distintos. Cuando el asistente aprendía algo útil en una conversación, ese conocimiento iba a su propia memoria y moría ahí. El agente de vencimientos lo habría usado. Nunca supo que existía.
Y había una pregunta que ninguno de los cuatro podía responder, que es la primera que hizo el hogar: "¿Cuánto debíamos el 15 de septiembre?" No "¿cuánto debemos?" sino "¿qué era cierto en una fecha, y qué creía su sistema en ese momento?". Nuestra base de datos guarda el estado actual del mundo. No guarda qué era cierto en una fecha, ni qué creíamos nosotros en esa fecha. Tampoco lo guardaba ninguno de los agentes.
Lo que probé antes de escribir una línea
No quería construir infraestructura. Probé tres cosas que ya existían.
Vistas compartidas en Postgres. La jugada obvia: un conjunto de vistas que todos los agentes leen, para que al menos lean lo mismo. Ayudó con la deriva durante un mes, más o menos. Pero una vista es una copia del estado actual. No tiene dimensión temporal, no registra cuándo se supo un hecho y no tiene dónde guardar lo que un agente concluye. Las memorias siguieron siendo por agente. Y los agentes seguían teniendo que normalizar la vista a algo que el modelo pudiera usar, así que el código de normalización seguía viviendo en cuatro sitios.
Una capa de memoria. Hay mucho trabajo bueno aquí: Zep, Mem0, AgentCore Memory, los productos de grafos de conocimiento sobre Neo4j. Leí sus docs con esperanza de verdad. Hasta donde entiendo, parten de texto. Conversaciones, documentos, entidades extraídas. Modelan la memoria por usuario, o por asistente. Es el diseño correcto para el problema que resuelven, y no es nuestro problema. Nuestros hechos no son texto; son filas con timestamps que el producto ya emite: se tomó una foto de deudas, se pagó un vencimiento, se importó un movimiento. Y la unidad de aislamiento no es el usuario. Es el cliente: el hogar en Toche, la empresa cliente en un SaaS B2B vertical, con sus propios datos, sus propias reglas y su propio derecho a que su grafo no se mezcle con el del siguiente. No encontré una capa que partiera de eventos de dominio y particionara por esa unidad.
RAG sobre nuestros propios datos. Probamos indexar las tablas y la documentación operativa y dejar que el retrieval hiciera el trabajo. Da respuestas probabilísticas a preguntas que tienen respuesta exacta. El saldo de una deuda el 1 de octubre no es algo que se recupere con un puntaje de similitud. Es un hecho, con una fuente, y ya lo tenemos.
Tres intentos, el mismo hueco: nada se sentaba entre "el producto emite eventos" y "un agente necesita contexto", y ese hueco es exactamente donde seguíamos reconstruyendo.
La forma que se quedó
Keel son tres decisiones, y la mayor parte del código es solo esas decisiones escritas.
Los eventos son la verdad. El producto ya produce eventos de dominio, o produce escrituras que son eventos si entrecierras los ojos: se tomó una foto de deudas, se programó un vencimiento, se pagó, se importó un movimiento. Keel los ingiere como sobres con un tenant, un tipo, un sujeto y dos timestamps: cuándo pasó en el mundo y cuándo se enteró Keel. La ingesta es determinista. Sin LLM en el camino, sin llamadas de red, sin azar. El grafo se deriva del log, lo que significa que el subgrafo completo de un cliente se puede borrar y reconstruir reproduciendo sus eventos. Si un estado no se puede reconstruir desde los eventos, ese estado es un bug.
El grafo es bitemporal y por cliente. Cada hecho tiene un valid_from y un valid_to en tiempo del mundo, más un recorded_at en tiempo de Keel. Cuando llega la foto de octubre de la misma deuda, el saldo de septiembre se cierra, no se sobrescribe. Así que "cuánto debíamos el 15 de septiembre" y "cuánto debemos ahora" son consultas distintas y las dos funcionan. Cada hecho lleva su procedencia: la fuente, el evento que lo justifica y quién lo corrigió si lo hizo un humano o un agente. Y el grafo guarda punteros, no copias. Una entidad lleva una referencia a la fila en el sistema de registro y los dos o tres atributos que una herramienta necesita para etiquetarla. Si alguien manda un PR que copia una tabla entera al grafo, el PR está mal.
Cinco herramientas, y solo cinco. Todo agente, en todo dominio, habla con el grafo a través de las mismas cinco: context, history, search, annotate, related. Primero son funciones de Python normales, y keel serve expone las mismas cinco por MCP. Cualquier capacidad nueva entra en la semántica de esas cinco antes de que alguien añada una sexta, porque los nombres y las firmas son la parte que se supone que es un estándar.
La flecha que importa es la que sale del cartridge. El núcleo no sabe nada de deudas.
Cartridges: el dominio vive en un archivo
Lo que hace que esto sea reutilizable en lugar de una herramienta para Toche es que el dominio se declara una vez, en un cartridge: un archivo YAML y un módulo de handlers. El YAML nombra los tipos de entidad, las relaciones entre ellos, el catálogo de eventos, las reglas semánticas y la política de memoria. Este es el de finanzas, recortado:
name: finance
entities:
household:
description: The home whose finances are tracked. The tenant of the tenant.
debt:
description: A debt with a creditor, tracked by monthly snapshot.
ref: debts.id
due:
description: A scheduled payment with a date and an amount.
ref: dues.id
relations:
owed_by: { from: debt, to: household }
for_debt: { from: due, to: debt }
events:
debt.snapshot:
subject: debt
owner: debts
payload: { creditor: string, product: string, balance_cop: number, rate_ea_pct: number, installment_cop: number, month: string }
due.paid:
subject: due
owner: calendar
payload: { paid_on: date, amount_cop: number, account_id: string, movement_id: string }
rules:
debt_expensive:
applies_to: debt
definition: "A debt is expensive when its effective annual rate is above 25%."
rate_ea_pct: 25
memory:
ttl:
tenant: never
task: 30d
agent: 1d
requires_evidence: [global, tenant]El handler del evento de foto es toda la traducción de payload a grafo:
@registry.on("debt.snapshot")
def debt_snapshot(event, store):
p = event.payload
store.upsert_entity(event.tenant, "debt", event.subject_id, ref=f"{event.source}#{event.subject_id}", creditor=p.get("creditor"), product=p.get("product"))
if not store.relations(event.tenant, event.subject, rel="owed_by", at=event.occurred_at):
store.link(event.tenant, event.subject, "owed_by", f"household:{event.tenant}", event.occurred_at, source=event.source)
for predicate in ("balance_cop", "rate_ea_pct", "installment_cop"):
store.open_fact(event.tenant, event.subject, predicate, p[predicate], valid_from=event.occurred_at, source=event.source, evidence=event.id)Tres primitivas. Ese es todo el camino de escritura: upsert_entity, link, open_fact, más unlink y close_fact para la dirección contraria. Un handler son unas diez líneas. Si crece más allá de eso, la lógica de negocio se está filtrando al sitio equivocado, porque la definición de "cara" no vive en el handler y no vive en el agente. Vive en rules, en el grafo, donde las herramientas la leen y todos los agentes reciben la misma respuesta.
La regla que más me obligo a cumplir es que el núcleo se mantenga agnóstico del dominio. Ningún tipo de entidad, ningún nombre de relación, ningún predicado, ninguna regla, ningún tipo de evento aparece en ningún sitio bajo keel/. La prueba de aceptación de cualquier cambio en el núcleo es que cartridges sin relación entre sí sigan pasando sin ediciones. El repositorio trae tres: uno de gestión de flotas que escribí primero, un CRM pequeño para una peluquería, y este de finanzas. Finanzas fue el tercero, y el diff bajo keel/ cuando lo añadí quedó vacío. Eso no es una demo. Es la prueba de que la tesis se sostiene.
Cómo se ve desde el lado del agente
Este es un agente sin LLM, porque el contrato importa más que el modelo. Pide las deudas del hogar, lee la regla desde el grafo en lugar de desde un prompt, y escribe lo que concluyó como memoria de alcance task con el hecho que lo justifica como evidencia:
tools = Tools(cartridge, store, tenant="hogar-demo")
for rel in tools.related("household:hogar-demo", "owed_by"):
debt = tools.context(rel["subject"])
threshold = debt["rules"]["debt_expensive"]["rate_ea_pct"]
rate = next(f for f in debt["facts"] if f["predicate"] == "rate_ea_pct")
if rate["value"] > threshold:
tools.annotate(rel["subject"], "assessment", f"expensive: {rate['value']}% > {threshold}%", scope="task", evidence=rate["evidence"], by="agent:debts")Y esto es lo que devuelve history para la deuda después. El saldo de septiembre está cerrado, no desaparecido:
balance_cop=4200000 valid 2026-09-01 -> 2026-10-01
balance_cop=3870000 valid 2026-10-01 -> now
assessment=expensive: 29.22% > 25% valid 2026-10-14 -> nowLa llamada a annotate es donde vive el diseño de la memoria. Los alcances van global, tenant, task, agent, del más amplio al más estrecho. El agente de arriba escribió en alcance task, así que su conclusión vence en treinta días, lo que dura un cierre de mes, y es visible para cualquier agente que trabaje la misma tarea. Si quisiera promover ese hecho a alcance tenant, donde nunca vence y lo ve todo agente de ese hogar, tendría que aportar evidencia, y la política se aplica dentro de la herramienta. Un agente que llama a annotate en alcance tenant sin evidencia recibe un PermissionError. No se confía en que el agente se vigile a sí mismo. Esa sola regla es lo que impide que "algo que un agente adivinó una vez" se convierta en "algo que todo el sistema cree".
Para un agente de verdad, ejecutas keel serve y apuntas cualquier cliente MCP. Con Claude Code es una línea:
claude mcp add keel -- uv run --directory $PWD keel serve cartridges/finance --tenant hogar-demo --events examples/events/finance.jsonlProbé esto en modo headless antes de publicar. Le pregunté a Claude, solo con esas cinco herramientas y sin ningún otro contexto, cuánto debe el hogar de la Visa ahora y cuánto debía el 15 de septiembre, si la deuda es cara según la regla del cartridge, y qué vencimientos siguen pendientes. Llamó a search con "Visa", a context y history sobre la deuda, y a context sobre cada uno de los tres vencimientos. Seis llamadas. Respondió 3.870.000 ahora y 4.200.000 el 15 de septiembre, que leyó del hecho cerrado. Dijo que la deuda es cara y citó el umbral del 25 %, que leyó de la regla en el grafo y no de nada que yo le dijera. Listó noviembre como el único vencimiento pendiente. Nada de esa respuesta salió de un pipeline, porque no hay pipeline.
Lo que Keel no resuelve
Prefiero que leas esta lista antes de clonarlo.
El store está en memoria. El grafo muere con el proceso. El backend durable es el siguiente punto del roadmap, y la restricción para elegirlo ya está decidida: si una base de datos de grafos obliga a meter un LLM en la ingesta para manejar eventos estructurados, usaré solo su almacenamiento y mantendré la ingesta determinista de Keel. SQLite es un paso intermedio aceptable.
No hay consolidación. La memoria de task y de agent vence. Resumir hechos episódicos viejos en hechos durables no está diseñado todavía, y no quiero diseñarlo antes de ver agentes reales produciendo memoria real.
Un tenant, un cartridge. Componer cartridges es una decisión posterior.
Todavía no ha corrido contra un stream de producción. El stream de finanzas del repo es una muestra con la forma de los eventos de Toche, no los datos vivos de Toche. Los adaptadores para una tabla outbox en Postgres y para un webhook existen y están probados, y conectar el stream real es el criterio de salida de esta fase.
No es un framework de agentes, y no es RAG. Tus agentes viven en LangGraph, el SDK de Claude, ADK, donde sea. Keel es lo que consultan. Y si tu contexto son documentos, ya hay buenas herramientas para eso; Keel puede apuntar a su salida pero no va a ingerir texto.
El cursor del outbox tiene un borde conocido. El adaptador de Postgres sigue una columna serial, y con escritores concurrentes una fila que hace commit tarde se puede saltar. Un escritor por tabla, o un cursor con retraso, es la respuesta; la advertencia está en el código con la ruta de mejora al lado.
Cuándo usarlo y cuándo no
Úsalo si:
- Tienes un producto vertical y tienes, o estás a punto de tener, más de un agente sobre los mismos datos del cliente.
- Tus hechos son eventos estructurados con tiempo, no conversaciones.
- Tus clientes preguntan "qué era cierto en esa fecha" y necesitas que la respuesta sea auditable.
- Quieres que los agentes compartan lo que aprenden, con una política sobre qué se promueve y qué vence.
No lo uses si:
- Estás construyendo un solo asistente sobre documentos. Una capa de memoria o RAG es la herramienta correcta y Keel te va a parecer sobrecarga.
- Tu memoria es conversacional y por usuario, preferencias y tono más que hechos. Para eso están las capas de memoria.
- Necesitas un store durable hoy. Espera a la 0.1 o ven a ayudar a construirlo.
Lo que pido
Diez minutos te dan la demo:
git clone https://github.com/angelkurten/keel && cd keel
uv sync --extra dev
uv run keel validate cartridges/finance
uv run keel context cartridges/finance examples/events/finance.jsonl debt:visaVeinte minutos más te dan un cartridge para tu propio vertical. Si escribirlo te obliga a cambiar algo bajo keel/, ese es el issue más valioso que puedes abrir, porque significa que el núcleo no es tan genérico como creo. Hay una plantilla de issue exactamente para eso.
Estoy construyendo esto en abierto, de diez a veinte horas a la semana, y el roadmap tiene una condición de parada escrita: a los treinta días, un issue, un pull request o una pregunta de alguien a quien no conozco. A los noventa, tracción suficiente para seguir, o paro y escribo por qué. Prefiero decirlo en voz alta que fingir que el proyecto no tiene reloj.
El código está en github.com/angelkurten/keel, la documentación en angelkurten.github.io/keel, Apache 2.0. El sobre del evento, el formato de cartridge y las cinco herramientas están pensados como estándar, y siempre serán libres.
Si tu cuarto agente viene en camino y estás a punto de copiar el pipeline otra vez, prueba esto primero. Y si la forma está mal, abre un issue y dime dónde. El punto nunca fue Keel. El punto es que nadie debería construir el mismo pipeline cuatro veces para obtener cuatro respuestas distintas sobre una deuda.
Publicaciones Relacionadas
Context Engineering > Prompt Engineering
Context engineering reemplaza a prompt engineering. Aprende a diseñar el contexto completo que reciben los LLMs, con patrones de produccion y metricas reales.
4 Agentes, 1 Spec: Orquestacion Multi-Agente Que Funciona
Los sistemas multi-agente fallan cuando los agentes hacen tareas distintas. Funcionan cuando miran lo mismo desde angulos diferentes. Patrones desde produccion.