Your first cartridge in 20 minutes¶
A cartridge teaches Keel your vertical. It is a folder with two files. The core never changes.
cartridges/clinics/
cartridge.yaml
handlers.py
Build yours in cartridges/clinics, the folder for your own cartridges. The finished version is in the repo as examples/clinics, with its events in examples/clinics/events.jsonl: point any command below at examples/clinics to run it without writing a line.
1. Scaffold it (1 min)¶
uv run keel init cartridges/clinics
Completion and validation in your editor¶
The cartridge format has a JSON Schema at cartridge.schema.json. Put this line at the top of cartridge.yaml:
# yaml-language-server: $schema=https://angelkurten.github.io/keel/cartridge.schema.json
VS Code with the Red Hat YAML extension (and any editor that speaks the YAML language server) then completes keys, shows what each one means, and flags the shapes keel validate rejects: an event without subject, forgets: always, an unknown rule operator, a payload hint typo. It checks shape only; keel validate still checks names against each other and that every event has a handler.
2. Declare the ontology (5 min)¶
List the kinds of things your product is about and how they relate. Use your product's own names.
entities:
patient: { description: A person treated at the clinic., ref: patients.id }
appointment: { description: A scheduled visit., ref: appointments.id }
treatment: { description: A procedure performed., ref: treatments.id }
relations:
booked_by: { from: appointment, to: patient }
performed_in:{ from: treatment, to: appointment }
ref is a pointer to your table. Keel stores pointers and meaning, never copies of your rows.
3. Catalog the events (5 min)¶
Write down the 10 to 20 things that happen in your product. Each has a subject kind, an owner team, and a payload shape. This list is the least technical part and the one that saves the most arguments later.
events:
appointment.booked:
subject: patient
owner: scheduling
payload: { id: string, starts_at: datetime, treatment: string }
appointment.no_show:
subject: appointment
owner: scheduling
payload: {}
The shipped cartridge adds a third one, treatment.performed, so the treatment kind gets used. Payload hints can be richer than scalars: status: [booked, done] is an enum, tags: string[] an array, items: [ { code: string, qty: int } ] an array of objects, { ... } a nested object (see the specification).
4. Write one handler per event (5 min)¶
A handler maps a payload to five primitives: upsert_entity, link, unlink, open_fact, close_fact. Ten lines each. close_fact ends the tenant's fact on a predicate (with value=, only that value, so the end of one term never ends an overlapping one); what agents remembered about it (task and agent memory) keeps its own lifetime.
from keel.handlers import HandlerRegistry
registry = HandlerRegistry()
@registry.on("appointment.booked")
def booked(event, store):
p = event.payload
appt = f"appointment:{p['id']}"
store.upsert_entity(event.tenant, "patient", event.subject_id)
store.upsert_entity(event.tenant, "appointment", p["id"], starts_at=p["starts_at"])
store.link(event.tenant, appt, "booked_by", event.subject, event.occurred_at, source=event.source)
store.open_fact(event.tenant, appt, "status", "booked", valid_from=event.occurred_at, source=event.source, evidence=event.id)
@registry.on("appointment.no_show")
def no_show(event, store):
store.open_fact(event.tenant, event.subject, "status", "no_show", valid_from=event.occurred_at, source=event.source, evidence=event.id)
open_fact closes the previous value of the same predicate instead of overwriting it, and a late event takes its place in the history rather than closing a newer value backwards. That is what makes the graph bitemporal: you can ask what was true on any date. Events are deduplicated by id, so retrying a batch is safe.
5. Add your semantics (2 min)¶
Definitions your agents should never invent for themselves:
rules:
at_risk_patient:
applies_to: patient
definition: "A patient is at risk of churn after two no-shows in 90 days."
no_shows: 2
days: 90
appointment_missed:
applies_to: appointment
definition: "An appointment is missed when its status is no_show."
when: { predicate: status, eq: no_show }
The context tool returns the rules that apply to a subject's kind, so every agent reads the same definition. A rule with a when is also evaluated for you: context returns evaluation: {appointment_missed: true} next to the rule, so the agent neither defines nor computes it. at_risk_patient needs a count over 90 days, so it has no when and the agent applies the definition. Conditions compose with all, any and not, follow a relation with via (a patient is at risk if any of their appointments is missed: { via: booked_by, direction: in, any: { predicate: status, eq: no_show } }), compare strings with ci: true, and measure how long a fact has been open with open_for_days or open_for_business_days (or how long it has held one of several values, surviving a change between them, with held_for_days or held_for_business_days). The full grammar is in the specification.
6. Validate and run (2 min)¶
uv run keel validate cartridges/clinics
uv run keel validate cartridges/clinics --events examples/clinics/events.jsonl
uv run keel ingest cartridges/clinics examples/clinics/events.jsonl
uv run keel context cartridges/clinics examples/clinics/events.jsonl patient:P-1
validate fails if an event has no handler, a handler has no event, a relation uses an undeclared kind, a payload hint does not parse, a rule's when uses an unknown operator or an undeclared relation, or the agents, semantic, consolidate or predicates sections are malformed. With --events it also checks a stream against the catalog: unknown types, wrong subject kinds, missing or mistyped payload fields.
7. Point an agent at it¶
uv sync --extra mcp
uv run keel serve cartridges/clinics --tenant clinic-centro --events examples/clinics/events.jsonl
Any MCP client now sees context, history, search, annotate, related. Your second agent uses the same five tools; there is no second pipeline.
8. Declare your agents (optional)¶
When more than one agent shares the graph, say who they are and what each may do. The layer enforces it; the agent never polices itself.
agents:
reception-bot:
description: Confirms appointments. Writes task memory only.
writes: [task, agent]
reads: { kinds: [patient, appointment], relations: [booked_by] }
clinic-copilot:
writes: [task, agent, tenant] # no reads: sees everything
An agent with no writes: may write [task, agent]; one with kind: human is a person and signs as human:<id>. Once agents: exists, every Tools(...) and every keel serve needs --agent <id>, and an id that is not declared is refused. annotate is signed with the agent id, so keel export shows who wrote what. Agent-scope memory is read only by the agent that wrote it; task-scope memory tied to a task id only by calls that pass that task.
9. Search by meaning (optional)¶
search(text, mode="semantic") finds subjects by meaning instead of by substring. Say what goes into each subject's searchable document:
semantic:
kinds: [patient, appointment]
relations: [booked_by] # an appointment's document names its patient
split: {patient: [allergy]} # optional: each allergy (each element, when the value is a list) is also a document of its own, so one long list does not dilute it
Index outside ingest, from a durable store, and serve with the same index and embedder:
uv run keel ingest cartridges/clinics examples/clinics/events.jsonl --db clinic.db
export KEEL_EMBEDDINGS_API_KEY=... # any OpenAI-compatible embeddings endpoint (KEEL_EMBEDDINGS_BASE_URL, _MODEL)
uv run keel index cartridges/clinics --tenant clinic-centro --db clinic.db --index "examples.custom_adapters:make_index?path=vectors.db"
uv run keel serve cartridges/clinics --tenant clinic-centro --db clinic.db --index "examples.custom_adapters:make_index?path=vectors.db"
--embedder hash runs it offline for a try. The index here is the SQLite example adapter; chroma:///path is the bundled one, and your own vector database is an adapter you write (Connect, semantic search). Run keel index from cron: it only re-indexes what changed.
The test that it worked¶
Did you edit anything under keel/? If yes, some domain leaked into the core. Open an issue; that is exactly the kind of thing we want to fix.