Skip to content

Connect your events

Keel ingests envelopes, never raw rows. Every source produces the same shape, and the cartridge's handlers are the only code that reads a payload. Pick the source that matches where your events already are.

The envelope

One event, as the development adapter reads it from examples/events/fleet.jsonl:

{"tenant": "acme-logistics", "type": "unit.registered", "subject": "unit:MXK-4821",
 "occurred_at": "2026-09-01T09:00:00Z", "source": "fleet.vehicles",
 "payload": {"id": "MXK-4821", "plate": "MXK-4821", "model": "Freightliner Cascadia"}}
Field Required Meaning
tenant yes The end-customer this fact belongs to. Opaque to Keel.
type yes Dotted lowercase type from the cartridge catalog.
subject yes <kind>:<id> of the entity the fact is about.
occurred_at yes When it happened in the world. Timezone-aware.
source yes System of record or adapter name.
id no Generated when absent. Becomes the evidence of derived facts.
recorded_at no When Keel learned it. Defaults to now.
payload no Event data. Only handlers read it.

The full rules are in the specification.

A .jsonl file

For development and tests. One envelope per line; blank lines and lines starting with # are ignored.

uv run keel ingest cartridges/fleet examples/events/fleet.jsonl

A Postgres outbox table

Your SaaS inserts envelopes into a table. Keel polls it in order and remembers the last row it took in a cursor file, so a restart resumes instead of re-ingesting.

CREATE TABLE keel_events (
    seq          bigserial PRIMARY KEY,
    id           text NOT NULL,
    tenant       text NOT NULL,
    type         text NOT NULL,
    subject      text NOT NULL,
    occurred_at  timestamptz NOT NULL,
    recorded_at  timestamptz NOT NULL DEFAULT now(),
    source       text NOT NULL,
    payload      jsonb NOT NULL DEFAULT '{}'
);
uv sync --extra postgres
import psycopg

from keel import Ingestor, MemoryStore, SqliteStore, load_cartridge
from keel.adapters.postgres import PostgresTableAdapter

ingestor = Ingestor(load_cartridge("cartridges/fleet"), SqliteStore("keel.db"))  # durable; MemoryStore() for a throwaway graph
adapter = PostgresTableAdapter(lambda: psycopg.connect(DSN), table="keel_events", cursor_path=".keel/cursor")

ingestor.ingest(adapter)                      # one pass over everything after the cursor
ingestor.ingest(adapter.follow(interval=5))   # or poll forever

Delivery is at-least-once: the cursor advances after the consumer has taken an event, so a crash mid-event re-delivers it, and the store applies each event in one transaction, so a redelivery is skipped as a duplicate. The table name, the cursor column and the batch size are parameters. Any DB-API 2.0 connection works; pass placeholder="?" for sqlite3.

sequenceDiagram
    participant S as Your SaaS
    participant T as keel_events
    participant A as PostgresTableAdapter
    participant C as cursor file
    participant I as Ingestor
    S->>T: INSERT envelope (seq 41)
    A->>C: read position
    C-->>A: 40
    A->>T: SELECT * WHERE seq > 40 ORDER BY seq LIMIT 500
    T-->>A: row 41
    A->>I: Event 41
    I->>I: dispatch to the handler
    I->>A: next event?
    A->>C: write 41
    Note over A,I: a crash before "write 41" re-delivers event 41 on restart

Concurrent writers

A serial cursor can skip a row that commits late while a later row was already read. Use one writer per table, or a recorded_at cursor with a lag, if that bites.

A webhook

Keel receives envelopes over HTTP, validates them and ingests them in the request.

uv sync --extra webhook
# app.py
from keel import Ingestor, MemoryStore, SqliteStore, load_cartridge
from keel.adapters.webhook import build_app

ingestor = Ingestor(load_cartridge("cartridges/fleet"), SqliteStore("keel.db"))  # durable; MemoryStore() for a throwaway graph
app = build_app(ingestor, token="shared-secret")
uv run uvicorn app:app --port 8000
curl -X POST localhost:8000/events \
  -H "Authorization: Bearer shared-secret" -H "Content-Type: application/json" \
  -d '[{"tenant": "acme-logistics", "type": "unit.registered", "subject": "unit:MXK-4821", "occurred_at": "2026-09-01T09:00:00Z", "source": "fleet.vehicles", "payload": {"id": "MXK-4821", "plate": "MXK-4821", "model": "Freightliner Cascadia"}}]'

The body is one envelope or a list. The response is the ingest report: processed, unhandled types and errors. A malformed envelope is a 422; a wrong token is a 401. Without a token the endpoint accepts any caller, so use that only on localhost.

Consume a Kafka topic or an SQS queue

Your SaaS publishes envelopes as JSON messages; Keel consumes them. uv sync --extra kafka (confluent-kafka) or --extra sqs (boto3).

from keel.adapters.bus import KafkaAdapter, SqsAdapter

adapter = KafkaAdapter.connect("broker:9092", topic="keel-events", group_id="keel")
adapter = SqsAdapter.connect("https://sqs.us-east-1.amazonaws.com/123456789012/keel-events")
ingestor.ingest(adapter)            # drain what is there now, then stop
ingestor.ingest(adapter.follow())   # consume forever

Delivery is at-least-once: the offset is committed, or the SQS message deleted, only after the handler ran, so a crash mid-event re-delivers it. A message that is not a valid envelope raises and stays on the bus; pass dead_letter=lambda body, error: ... to log it somewhere and move on. Both adapters only need two calls from their client (poll/commit, receive_message/delete_message), which is how the tests run them on fakes and how you would wrap another client.

Serve the tools over MCP

uv sync --extra mcp
uv run keel serve cartridges/fleet --tenant acme-logistics --events examples/events/fleet.jsonl

With Claude Code, one command registers the server. The agent sees exactly five tools and nothing else:

claude mcp add keel -- uv run --directory $PWD keel serve cartridges/fleet --tenant acme-logistics --events examples/events/fleet.jsonl

With Codex CLI (OpenAI), declare the server in ~/.codex/config.toml. Keel annotates context, history, search and related as read-only, so Codex runs them without asking even under approval_policy = "never"; annotate writes, so it prompts unless you set default_tools_approval_mode = "auto" on the server:

[mcp_servers.keel]
command = "uv"
args = ["run", "--directory", "/path/to/keel", "keel", "serve", "cartridges/fleet", "--tenant", "acme-logistics", "--events", "examples/events/fleet.jsonl"]
startup_timeout_sec = 60

A headless run, for a smoke test or CI:

codex exec --json "Which tires on unit:MXK-4821 are critical according to the cartridge's own rules? Use only the keel tools." < /dev/null

Checked on 2026-10-14 with Codex CLI 0.162 and openai/gpt-5.2-codex behind an OpenAI-compatible gateway: seven calls (search, related, context, history with predicate=depth_mm), the right tire, the right depth on 2026-09-20, and the threshold quoted from the cartridge rather than assumed. Any other MCP client works the same way: a stdio server, five tools, one tenant per server instance. If the cartridge declares agents:, add --agent <id>: the server then reads and writes only what that agent may, and signs its annotations.

Where is the graph stored?

In memory with --events: the server ingests the stream at start and serves it, which is enough for a demo. For a deployment, pass --db keel.db instead: a SQLite file (stdlib, WAL mode) that an ingest process fills while keel serve reads it. Or point --db at a MySQL or MariaDB you already run: --db mysql://user:password@host:3306/keel (uv sync --extra mysql); the tables are created on first use and the same conformance suite runs against it in CI. Sharing the database with your application: ?prefix=keel_ names the tables keel_entities, keel_facts, and so on.

uv run keel ingest cartridges/fleet examples/events/fleet.jsonl --db keel.db   # or adapter.follow() in your own process
uv run keel serve cartridges/fleet --tenant acme-logistics --db keel.db
uv run keel replay cartridges/fleet examples/events/fleet.jsonl acme-logistics --db keel.db   # wipe one tenant, rebuild it from the log

The log is the truth and the file is derived: replay rebuilds one tenant from the external log (your outbox table, your bus, a .jsonl) and never from the file itself. The store is pluggable: SqliteStore is the default, and any store that passes tests/test_store.py (see contributing) can take its place.

Serve the tools over HTTP

For a backend, or for many tenants from one process, serve the same five operations as a JSON API (uv sync --extra http):

KEEL_SECRET=change-me uv run keel serve cartridges/fleet --transport http --db keel.db --host 0.0.0.0 --port 8000
Call Body Returns
POST /v1/events one envelope or a list the ingest report
POST /v1/context {"subject", "as_of"?} what context returns
POST /v1/history {"subject", "since"?, "until"?, "predicate"?, "scope"?} facts
POST /v1/search {"text"} entities and facts
POST /v1/annotate {"subject", "predicate", "value", "scope"?, "evidence"?, "by"?} the fact
POST /v1/related {"subject", "relation"?} neighbours
GET /openapi.json the contract, for generated clients

Every request carries Authorization: Bearer $KEEL_SECRET, X-Keel-Tenant and, when the cartridge declares agents, X-Keel-Agent. Your product authenticates its users and sets the two headers; Keel takes them as given and builds the tools for that tenant and agent per request. A refusal (scope policy, agent registry) is a 403 with its reason. With --tenant, the same server also exposes /mcp (MCP over streamable HTTP) bound to that tenant, for agents that speak MCP and cannot run a local process.

curl -s localhost:8000/v1/context -H "Authorization: Bearer change-me" -H "X-Keel-Tenant: acme-logistics" \
  -H "Content-Type: application/json" -d '{"subject": "tire:T-9001"}'

Export the audit trail

One tenant, everything Keel knows about it, as JSONL: entities, relations and facts, closed ones included, each with source, evidence, scope, recorded_at and corrected_by. Hand it to the customer, an auditor, or a diff.

uv run keel export acme-logistics --db keel.db --out acme-logistics.jsonl

Each line is one record: {"record": "fact", "subject": "tire:T-9001", "predicate": "depth_mm", "value": 5.2, "valid_from": ..., "valid_to": ..., "source": "tires.measurements", "evidence": "<event id>", ...}. Another tenant's export never contains this one's rows.