The context layer for the agents inside your product
Keel turns your product's events into one graph per customer that remembers what was true and when. Every agent you build reads it through the same five tools, over MCP, HTTP or a typed SDK, so you stop rebuilding the pipeline for each one.
Tool calls
An illustrative session over the benchmark's Spring tenant.
entities: issue: { ref: issues.id } project: { ref: projects.key } relations: blocks: { from: issue, to: issue } events: issue.status_changed: subject: issue payload: { status: string, status_category: [new, indeterminate, done] } rules: blocked: applies_to: issue definition: "Blocked while an issue that blocks it is not done." when: all: - { not: { predicate: status_category, eq: done } } - { via: blocks, direction: in, any: { not: { predicate: status_category, eq: done } } } memory: ttl: { tenant: never, task: 14d, agent: 1d } requires_evidence: [global, tenant] evidence: [event] # an event this tenant's log applied to the same subject
- Claude Code
- Codex
- LangGraph
- Claude Agent SDK
- Google ADK
- Postgres CDC
- Kafka
- SQS
- Webhooks
- Outbox
Four agents shouldn't mean four pipelines
Every new agent repeats the same work: extract the customer's data, decide what "blocked" or "at risk" means, keep its own memory. Keel does it once, from your events.
Without KeelN pipelines
Each agent has its own pipeline, its own rules and its own memory.
With Keelone layer
One deterministic ingest. Every agent reads the same graph through the same five tools.
How it works
Send your events
From Postgres CDC, an outbox, Kafka, SQS, webhooks or the SDK.
Declare a cartridge
Your entities, events and rules in YAML, plus ten-line handlers. Typed SDKs come out of it.
Keel builds the graph
One bitemporal graph per tenant. No LLM in ingest; replay gives the same graph.
Agents read five tools
context, history, search, related, annotate. Rules arrive evaluated.
Define "blocked" once. Every agent gets the verdict.
Rules live in the cartridge, not in prompts. Keel evaluates them over the whole graph, so the model receives the answer instead of working it out from raw facts.
- 26 points more right on whole-project rule questions with both GPT-6 Luna and Claude Haiku 5.5.
- Fewer tokens: 77k against 149k a question with Luna, 60k against 78k with Haiku.
- Rules compose: all, any, not, follow relations with via, measure durations.
"Which issues in the project are blocked right now?" The same model and the same tools, with and without the cartridge's rule.
No rulethe model applies the definition
rule: blockedKeel evaluates
Illustrative graph; numbers shown are GPT-6 Luna's. Source: the paper's rules ablation, 18 whole-project questions that use the blocked, stalled and urgent_unassigned rules, three repetitions.
Know what was true, and when you knew it
Every fact carries when it was true and when Keel learned it. Late events and corrections close facts instead of overwriting them, so an agent can audit what another agent saw when it decided.
- as_of and as_known_at on every read.
- Deterministic replay: the same log gives the same graph, in any arrival order.
- Provenance on every fact: source, evidence, who corrected it.
{ "id": "spr-142-st-7",
"tenant": "spring",
"type": "issue.status_changed",
"subject": "issue:SPR-142",
"occurred_at": "2026-10-04T09:12:00Z",
"recorded_at": "2026-10-06T16:40:00Z",
"source": "jira",
"payload": { "status": "Blocked",
"status_category": "indeterminate" } }
Memory with scopes, enforced by the layer
Agents write what they learn with annotate. The cartridge says who may write which scope, how long it lives and what counts as evidence. The prompt can't talk its way past it.
- 0% of answers repeat a poisoned write with Keel's checks (the evidence check as fixed after the run), against 91–98% without.
- 0 leaks in 26,666 random reads across tenants, scopes and agents.
- One graph per tenant: no tool can return another tenant's facts.
tools.annotate("issue:SPR-142", "root_cause", "vendor patch", scope="tenant") # no evidence
writing to scope 'tenant' requires evidence
(an event id, a row ref, or a human correction)tools.annotate("issue:SPR-142", "root_cause", "vendor patch", scope="tenant", evidence=late_event.id) # stored; every agent of this tenant can read it
Your cartridge, as a typed client in your language
keel sdk writes one file for your product's repo, the way protoc does: a type per event payload, a constructor per event, subject helpers, the rules, and a client for the five tools. Your compiler catches a payload that drifts from the catalog before Keel ever sees it.
- TypeScript, Python, Go and PHP, each with a small published runtime and tested end to end against keel serve.
- --check in CI fails the build when the cartridge moves on and the generated file is stale.
- One contract: the same cartridge drives ingest, the tools and every client.
$ keel sdk typescript cartridges/jira -o src/keel-jira.ts
import { client, emit, subjects } from "./keel-jira"; const keel = client({ baseUrl, tenant: "spring", agent: "triage" }); await keel.events([emit.issueStatusChanged({ tenant: "spring", subject: subjects.issue("SPR-142"), occurredAt: new Date(), source: "jira", payload: { status: "Blocked", status_category: "blocked" }, })]); // ✗ Type '"blocked"' is not assignable to // '"new" | "indeterminate" | "done" | null' const ctx = await keel.context(subjects.issue("SPR-142")); ctx.evaluation.blocked; // true
$ keel sdk python cartridges/jira -o app/keel_jira.py
from app import keel_jira as kj keel = kj.client("http://keel.internal:8000", "spring", secret=SECRET, agent="triage") keel.events([kj.emit.issue_status_changed( "spring", kj.subjects.issue("SPR-142"), datetime.now(timezone.utc), "jira", {"status": "Blocked", "status_category": "indeterminate"}, )]) # payloads are TypedDicts: your editor checks the keys keel.context(kj.subjects.issue("SPR-142"))["evaluation"]["blocked"] # True
$ keel sdk go cartridges/jira -o internal/keeljira/keel_jira.go --package keeljira
c := keeljira.NewClient(keel.Options{BaseURL: url, Tenant: "spring", Agent: "triage"})
cat := keeljira.IssueStatusChangedPayloadStatusCategoryIndeterminate
env := keeljira.EmitIssueStatusChanged("spring", keeljira.SubjectIssue("SPR-142"),
time.Now(), "jira",
keeljira.IssueStatusChangedPayload{Status: "Blocked", StatusCategory: &cat})
_, err := c.Events(ctx, []keel.Envelope{env}) // enums are typed constants
issue, err := c.Context(ctx, string(keeljira.SubjectIssue("SPR-142")), nil)
$ keel sdk php cartridges/jira -o src/Keel/Jira.php --package 'App\Keel\Jira'
use App\Keel\Jira\{Keel, Emit, Subjects, IssueStatusChangedPayload}; $keel = Keel::client($url, 'spring', $secret, 'triage'); $keel->events([Emit::issueStatusChanged( 'spring', Subjects::issue('SPR-142'), new \DateTimeImmutable(), 'jira', new IssueStatusChangedPayload(status: 'Blocked'), )]); // payloads are readonly value objects, enums are PHP enums $keel->context(Subjects::issue('SPR-142'));
Five tools. Only five.
The names and signatures are part of the public standard. New capabilities go into these five, never into a sixth, so your agents' prompts don't change when Keel does.
context
What Keel knows about a subject now, or at a past moment, with every rule's verdict.
context("issue:SPR-142")Measured, with the results public
387 questions over three Public Jira Dataset projects, with late arrivals and corrections injected, graded against an independent oracle. Three agents.
Accuracy on whole-project questions
GPT-6 Luna
Claude Haiku 5.5
Claude Sonnet 5.5
Sonnet ran once, and so did Luna's RAG; the rest are three repetitions. Sonnet's RAG run is partial (34 of 72 questions).
over the product's own API on whole-project questions, with all three agents (p < 0.001), at the lowest cost per correct answer.
for TGMS on the same questions with GPT-6 Luna, reading 741k tokens a question.
over the API on single-issue questions with Luna and Haiku (p < 0.001), a tie with Sonnet; every system that reads the log scores 0.95 to 0.99 there.
Keel: one deterministic context layer for product agents
Where Keel does not win is in the paper too: a few small tables that fit in a prompt do as well pasted in.
@misc{kurten2026keel,
author = {K{\"u}rten, Angel},
title = {Keel: one deterministic context layer
for product agents},
year = {2026},
publisher = {Zenodo},
doi = {10.5281/zenodo.23571090}
}Running in three commands
An example cartridge and its events ship with the repository. Point Claude Code, Codex or any MCP client at it.
Install
Python 3.10+, or run the container.
git clone https://github.com/angelkurten/keel cd keel && pip install keel-context
Validate and load
Check the events against the cartridge, then build the graph.
keel validate examples/fleet \ --events examples/fleet/events.jsonl keel ingest examples/fleet \ examples/fleet/events.jsonl
Serve the five tools
Over MCP for your agent, or HTTP for your backend.
claude mcp add keel -- keel serve \ examples/fleet --tenant acme-logistics \ --events examples/fleet/events.jsonl
Pilot it on your own events
Your events, your agents, one day to the first answer. We help you through the cartridge and fix what blocks you in the core.