By Angel Kurten16 min read

We Built Four Agents and Four Copies of Our Customer Context

Every new agent in a vertical SaaS rebuilds the same pipeline. Keel is an open-source context layer: domain events in, a bitemporal graph per customer out, five MCP tools for every agent. With a worked example from Toche, my household-finance app.

The fourth agent is the one that broke me. Not because it was hard to build. Because it was easy, and I had built it before. Three times.

Here is the scene. Toche, the household-finance app I built: accounts, a monthly budget by line, debts with a monthly snapshot, a calendar of dues, bank movements. One household. One credit card, a Visa. September's debt snapshot says the household owes 4,200,000 pesos at 29.22% effective annual rate, with a 380,000 installment. On October 3 they pay October's installment from savings. Simple facts. Now ask the four agents we had running one question: how much do we owe on the Visa?

The dues agent says October is paid and the next installment is on November 5. It has no idea what the balance is; it only knows the calendar. The debts agent says 4,200,000, because it reads the monthly snapshot it cached in September, and nobody told it October's snapshot had arrived. The budget agent says 380,000 spent on the "Tarjeta Visa" line, right on plan, and does not know what a balance is either. And the assistant, the one that actually talks to the household, does the arithmetic: 4,200,000 minus 380,000 is 3,820,000. Which is wrong, because interest exists, and the October snapshot said 3,870,000.

Four agents, four answers, one debt. None of them 3,870,000.

That is not a bug in any of the four. Each one does exactly what it was built to do. The bug is that we built four of them, and under each one we built the same pipeline: extract the household's data from our own database, normalize it into something a model can read, decide what "expensive" means, give the agent a memory, expose tools to the model. Four copies of the household's context. Four definitions of expensive. Four memories that never talk to each other.

I spent a weekend writing down what it would take to not do that a fifth time. The result is Keel, which I am publishing today as open source, pre-alpha, with the thesis in the first line of the README: stop rebuilding the pipeline for every agent.

The pipeline nobody budgets for

When you plan an agent, you budget for the agent: the prompt, the tools, the evals, the framework. What you actually spend most of the time on is everything under it.

Every agent inside a product like this needs the same five things before it can be useful. It needs to extract the slice of the customer's data that matters for its job. It needs to normalize that slice into a shape a model can reason about, because a debts table with a snapshot row per month and three foreign keys is not that shape. It needs semantics: what counts as expensive, what counts as overdue, what counts as over budget. It needs a memory, because an agent that concludes something on Monday and forgets it on Tuesday is a very expensive stateless function. And it needs tools, the surface the model calls.

None of that is the agent. All of it is the pipeline. And the pipeline is where the first agent's budget went, and the second's, and the third's.

The cost is not the code. Copying a pipeline is fast; that is exactly why we did it four times. The cost is drift. "Expensive" was defined in four prompts, and two of them disagreed by the time anyone noticed. The assistant's context was rebuilt nightly and the debts agent's monthly, so for most of the month they lived in different worlds. When the assistant learned something useful from a conversation, that knowledge went into its own memory and died there. The dues agent could have used it. It never knew it existed.

And there was a question none of the four could answer, which is the one the household asked first: "How much did we owe on September 15?" Not "how much do we owe" but "what was true on a date, and what did your system believe at the time?" Our database holds the current state of the world. It does not hold what was true on a date, nor what we believed on that date. Neither did any of the agents.

What I tried before writing a line

I did not want to build infrastructure. I tried three things that already existed.

Shared views in Postgres. The obvious move: one set of views that every agent reads, so at least they read the same thing. It helped with drift for about a month. But a view is a copy of the current state. It has no time dimension, no record of when a fact was learned, and no place for anything an agent concludes. The memories stayed per agent. And the agents still had to normalize the view into something a model could use, so the normalization code still lived in four places.

A memory layer. There is a lot of good work here: Zep, Mem0, AgentCore Memory, the knowledge-graph products on top of Neo4j. I read their docs with real hope. As far as I can tell, they start from text. Conversations, documents, extracted entities. They model memory per user, or per assistant. That is the right design for the problem they solve, and it is not our problem. Our facts are not text; they are rows with timestamps that the product already emits: a snapshot was taken, a due was paid, a movement was imported. And the unit of isolation is not the user. It is the customer: the household in Toche, the end-customer's company in a B2B vertical SaaS, with its own data, its own rules, and its own right to not have its graph mixed with the next one's. I could not find a layer that started from domain events and partitioned by that unit.

RAG over our own data. We tried indexing the tables and the operational docs and letting retrieval do the work. It gives probabilistic answers to questions that have exact answers. A debt's balance on October 1 is not something to retrieve with a similarity score. It is a fact, with a source, and we already have it.

Three attempts, same gap: nothing sat between "the product emits events" and "an agent needs context", and that gap is exactly where we kept rebuilding.

The shape that stuck

Keel is three decisions, and most of the code is just those decisions written down.

Events are the truth. The product already produces domain events, or it produces writes that are events if you squint: a debt snapshot was taken, a due was scheduled, a due was paid, a movement was imported. Keel ingests those as envelopes with a tenant, a type, a subject, and two timestamps: when it happened in the world and when Keel learned about it. The ingest is deterministic. No LLM in the path, no network calls, no randomness. The graph is derived from the log, which means one customer's whole subgraph can be wiped and rebuilt by replaying its events. If a state cannot be rebuilt from events, that state is a bug.

The graph is bitemporal and per customer. Every fact has a valid_from and a valid_to in world time, plus a recorded_at in Keel's time. When October's snapshot arrives for the same debt, September's balance is closed, not overwritten. So "what did we owe on September 15" and "what do we owe now" are different queries and both work. Every fact carries provenance: the source, the event that justifies it, and who corrected it if a human or an agent did. And the graph holds pointers, not copies. An entity carries a reference to the row in the system of record and the two or three attributes a tool needs to label it. If someone sends a PR that copies a whole table into the graph, the PR is wrong.

Five tools, and only five. Every agent, in every domain, talks to the graph through the same five: context, history, search, annotate, related. They are plain Python functions first, and keel serve exposes the same five over MCP. New capability goes into the semantics of those five before anyone adds a sixth, because the names and signatures are the part that is supposed to be a standard.

The arrow that matters is the one from the cartridge. The core knows nothing about debts.

Cartridges: the domain lives in one file

The thing that makes this reusable instead of a Toche-specific tool is that the domain is declared once, in a cartridge: a YAML file and a handlers module. The YAML names the entity kinds, the relations between them, the event catalog, the semantic rules, and the memory policy. This is the finance one, trimmed:

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]

The handler for the snapshot event is the entire translation from payload to graph:

@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)

Three primitives. That is the whole write path: upsert_entity, link, open_fact, plus unlink and close_fact for the other direction. A handler is about ten lines. If it is growing past that, business logic is leaking into the wrong place, because the definition of "expensive" does not live in the handler and it does not live in the agent. It lives in rules, in the graph, where the tools read it and every agent gets the same answer.

The rule I am enforcing hardest on myself is that the core stays domain-agnostic. No entity kind, no relation name, no predicate, no rule, no event type appears anywhere under keel/. The acceptance test for any change to the core is that unrelated cartridges still pass without edits. The repository ships three: a fleet-management one that I wrote first, a small CRM for a hair salon, and this finance one. Finance was the third, and the diff under keel/ when I added it was empty. That is not a demo. That is the test that the thesis holds.

What it looks like from the agent's side

Here is an agent with no LLM in it, because the contract matters more than the model. It asks for the household's debts, reads the rule from the graph instead of from a prompt, and writes back what it concluded as task-scoped memory with the fact that justifies it as evidence:

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")

And this is what history returns for the debt afterwards. September's balance is closed, not gone:

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 -> now

The annotate call is where the memory design lives. Scopes go global, tenant, task, agent, from widest to narrowest. The agent above wrote at task scope, so its conclusion expires in thirty days, the length of a month close, and is visible to any agent working the same task. If it wanted to promote that fact to tenant scope, where it never expires and every agent for that household sees it, it would have to provide evidence, and the policy is enforced inside the tool. An agent that calls annotate at tenant scope with no evidence gets a PermissionError. The calling agent is not trusted to police itself. That one rule is what stops "a thing one agent guessed once" from becoming "a thing the whole system believes."

For a real agent, you run keel serve and point any MCP client at it. With Claude Code it is one line:

claude mcp add keel -- uv run --directory $PWD keel serve cartridges/finance --tenant hogar-demo --events examples/events/finance.jsonl

I tested this headless before publishing. I asked Claude, with only those five tools and no other context, how much the household owes on the Visa now and how much it owed on September 15, whether the debt is expensive by the cartridge's rule, and which dues are still pending. It called search for "Visa", context and history on the debt, and context on each of the three dues. Six calls. It answered 3,870,000 now and 4,200,000 on September 15, which it read from the closed fact. It called the debt expensive and cited the 25% threshold, which it read from the rule in the graph and not from anything I told it. It listed November as the one pending due. Nothing in that answer came from a pipeline, because there is no pipeline.

What Keel does not solve

I would rather you read this list before you clone it.

The store is in memory. The graph dies with the process. The durable backend is the next item on the roadmap, and the constraint on choosing it is already decided: if a graph database forces an LLM into the ingest path to handle structured events, I will use only its storage and keep Keel's deterministic ingest. SQLite is an acceptable intermediate step.

There is no consolidation. Task and agent memory expires. Summarizing old episodic facts into durable ones is not designed yet, and I do not want to design it before seeing real agents produce real memory.

One tenant, one cartridge. Composing cartridges is a later decision.

It has not run against a production stream yet. The finance stream in the repo is a sample shaped like Toche's events, not Toche's live data. The adapters for a Postgres outbox table and a webhook exist and are tested, and plugging the real stream in is the exit criterion for this phase.

It is not an agent framework, and it is not RAG. Your agents live in LangGraph, the Claude SDK, ADK, wherever. Keel is what they consult. And if your context is documents, there are good tools for that already; Keel can point at their output but it will not ingest text.

The outbox cursor has a known edge. The Postgres adapter follows a serial column, and under concurrent writers a row that commits late can be skipped. One writer per table, or a cursor with a lag, is the answer; the caveat is in the code with the upgrade path next to it.

When to use it and when not

Use it if:

  • You run a vertical product and you have, or are about to have, more than one agent on the same customer data.
  • Your facts are structured events with time, not conversations.
  • Your customers ask "what was true on that date" and you need the answer to be auditable.
  • You want agents to share what they learn, with a policy on what gets promoted and what expires.

Do not use it if:

  • You are building a single assistant over documents. A memory layer or RAG is the right tool and Keel will feel like overhead.
  • Your memory is conversational and per user, preferences and tone rather than facts. That is what the memory layers are for.
  • You need a durable store today. Wait for 0.1 or come help build it.

What I am asking

Ten minutes gets you the 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:visa

Twenty more minutes gets you a cartridge for your own vertical. If writing it forces you to change anything under keel/, that is the most valuable issue you can open, because it means the core is not as generic as I think it is. There is an issue template for exactly that.

I am building this in the open, ten to twenty hours a week, and the roadmap has a stop condition written into it: at thirty days, one issue, pull request or question from someone I do not know. At ninety days, enough traction to keep going, or I stop and write up why. I would rather say that out loud than pretend the project does not have a clock.

The code is at github.com/angelkurten/keel, the docs at angelkurten.github.io/keel, Apache 2.0. The envelope, the cartridge format and the five tools are meant to be a standard, and they will always be free.

If your fourth agent is coming and you are about to copy the pipeline again, try this first. And if the shape is wrong, open an issue and tell me where. The point was never Keel. The point is that nobody should build the same pipeline four times to get four different answers about one debt.

Related Posts