Serve the tools¶
Agents reach the five tools through one of these surfaces. All of them enforce the same scopes, memory policy and agent registry, because the enforcement lives in the tools, not in the transport.
| Surface | For | Start it with |
|---|---|---|
| Python, in process | An agent that is already Python; no network. | Tools(cartridge, store, tenant="acme", agent="tires-bot") |
| MCP | Claude Code, Claude Desktop, Codex, any MCP client. | keel serve <cartridge> --tenant acme (stdio), or /mcp on the HTTP server |
| HTTP JSON API | A backend in any language; many tenants from one process. | keel serve <cartridge> --transport http --db ... |
| Typed SDKs | TypeScript, Python, Go and PHP backends, over the HTTP API. | keel sdk <language> <cartridge> -o <file> |
| Console | People: browse the graph, try the tools, watch events. | keel serve --transport http --ui, at /ui |
| Semantic search | Finding subjects by meaning; optional, on any of the above. | keel index, then keel serve --index ... --embedder ... |
Running the server in production (configuration, the container, jobs) is in Operate. Where this fits in the whole: Architecture.
Serve the tools over MCP¶
uv sync --extra mcp
uv run keel serve examples/fleet --tenant acme-logistics --events examples/fleet/events.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 examples/fleet --tenant acme-logistics --events examples/fleet/events.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", "examples/fleet", "--tenant", "acme-logistics", "--events", "examples/fleet/events.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. Agent-scope memory is read only by the agent that wrote it, so a server started without --agent reads none.
From LangGraph (LangChain's create_agent), through langchain-mcp-adapters: examples/fleet/agent_langgraph.py starts the same stdio server, hands the five tools to the agent and asks about unit MXK-4821. Its dependencies are declared inline, so uv run installs them and nothing is added to Keel:
OPENAI_API_KEY=... uv run examples/fleet/agent_langgraph.py
# any OpenAI-compatible model: OPENAI_BASE_URL=https://your-gateway/v1 KEEL_DEMO_MODEL=some/model
Checked on 2026-10-18 with openai/gpt-4.1-mini behind an OpenAI-compatible gateway: context with depth=1 on the unit, history of the tire's depth, then annotate with the depth fact's evidence; critical since 2026-10-03, 5.2 mm on 2026-09-20, the 3 mm threshold quoted from the rule. Without the system prompt's hint (keys are <kind>:<id>, find them with search) that small model guessed a key and found nothing.
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 (mariadb:// is accepted too; uv sync --extra mysql); the tables are created on first use (with utf8mb4_bin, so keys compare exactly) and the same conformance suite runs against it in CI. Tables created before 0.24.2 took the server's collation, under which page:RENDIMIENTO and page:Rendimiento (or Numero and NĂºmero) were one entity; the first start of 0.24.2 or later converts them, which rewrites each table once, and warns with the subjects that had already collided (MysqlStore.collided() lists them): publish those entities again. Sharing the database with your application: ?prefix=keel_ names the tables keel_entities, keel_facts, and so on. In code, the same option is a constructor argument: SqliteStore("keel.db", prefix="keel_"), MysqlStore.from_url(url, prefix="keel_") or MysqlStore(connect, prefix="keel_").
uv run keel ingest examples/fleet examples/fleet/events.jsonl --db keel.db # or adapter.follow() in your own process
uv run keel serve examples/fleet --tenant acme-logistics --db keel.db
uv run keel replay examples/fleet examples/fleet/events.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. What agents and people wrote through annotate, and consolidations of it, is not in the log, so replay keeps it and the events land around it. 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 examples/fleet --transport http --db keel.db --host 0.0.0.0 --port 8000
The secret is read from KEEL_SECRET; --secret-env OTHER_VAR reads it from another variable. If the variable is unset and the store holds no API key (below), the server starts open only on a loopback host (127.0.0.1, the default, ::1 or localhost), with a warning. On any other --host it refuses to start unless you pass --allow-open, for when your own proxy authenticates every request.
The console. --ui also serves the console at /ui, the same one hosted Keel runs, in its self-hosted mode: the graph (search, a subject's context as of any instant with its rules, a predicate's history, neighbours as a force graph), the five tools by hand with the same call as curl and Python, an agent's prompt exported to Claude Agent SDK, LangGraph, OpenAI Agents SDK or Google ADK, per-client MCP snippets, the webhook with a live log of recent events, an events.jsonl upload checked against the catalog, and the SDK generated from your cartridge. It holds no data: it asks for the secret (or a key) and the tenant once, keeps them for the browser tab, and reads through the same /v1 API, so every scope and registry rule applies. Off by default.
TLS and HTTP/2. --certfile and --keyfile (PEM; or KEEL_CERTFILE, KEEL_KEYFILE) serve https. --http2 runs the server on Hypercorn instead of uvicorn (uv sync --extra http2) and speaks HTTP/1.1 and HTTP/2 on the same port: h2 negotiated by ALPN over TLS, or cleartext h2c (prior knowledge or Upgrade) without it, which is what a proxy in front that talks h2c to its origin uses. HTTP/1.1 clients keep working. On the client side, keel.client.Client(..., http_client=httpx.Client(http2=True)) (SDKs).
KEEL_SECRET=change-me uv run keel serve examples/fleet --transport http --http2 --db keel.db \
--host 0.0.0.0 --certfile cert.pem --keyfile key.pem
| Call | Body (? optional, default in parentheses) |
Returns |
|---|---|---|
POST /v1/events |
one envelope or a list | the ingest report: processed, duplicates, unhandled, errors, forgotten, skipped |
GET /v1/events/recent |
the tenant's last 200 events received by this server, newest first, each with status (applied, duplicate, unhandled, skipped, error) and the reason; kept in memory, so a restart empties it |
|
POST /v1/events/check |
a list of envelopes | what the catalog would refuse in each (index, problems), the same checks as keel validate --events; nothing is ingested |
GET /v1/sdk/{language} |
the typed client for this cartridge, as keel sdk generates it: filename, code, sha256 |
|
POST /v1/context |
{"subject", "as_of"?, "depth"? (0), "as_known_at"?, "task"?, "detail"? ("full" or "concise")} |
what context returns |
POST /v1/history |
{"subject"?, "since"?, "until"?, "predicate"?, "scope"?, "as_known_at"?, "task"?, "changed_since"?, "limit"?} |
facts |
POST /v1/search |
{"text"? (""), "kind"?, "rule"?, "limit"?, "after"?, "mode"? ("text" or "semantic"), "min_score"?, "related_to"?, "via"?} |
entities and facts |
POST /v1/annotate |
{"subject", "predicate", "value", "scope"? ("task"), "evidence"?, "by"?, "task"?, "key"?, "status"?} |
the fact |
POST /v1/related |
{"subject", "relation"?, "as_of"?} |
neighbours |
GET /health |
{"ok", "cartridge", "mcp"} |
|
GET /openapi.json |
the contract, for generated clients |
Times are ISO 8601 strings. Every request carries Authorization: Bearer $KEEL_SECRET, X-Keel-Tenant (optional when the server is bound with --tenant or KEEL_TENANT, which is then the default) 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. /health, /openapi.json, /docs and /redoc need no token. Two optional headers are stored with every write the request makes: traceparent (W3C trace context) and X-Keel-Tool-Call (the agent's tool call id).
On /v1/events, X-Keel-Tenant is optional: each envelope names its tenant, and when the header is present every envelope must match it. The agent registry does not apply to ingest.
| Status | Meaning |
|---|---|
400 |
bad input: missing X-Keel-Tenant, an envelope of another tenant, an unknown rule, an invalid mode or detail |
401 |
bad or missing bearer token |
403 |
a refusal (scope policy, agent registry), with its reason |
422 |
a malformed body, such as an envelope that fails validation |
503 |
the embeddings provider is rate limiting or down, still after Keel's retries (semantic search); retry after Retry-After seconds |
The same server also exposes /mcp (MCP over streamable HTTP), for agents that speak MCP and cannot run a local process. Each call takes its tenant from X-Keel-Tenant (and its agent from X-Keel-Agent), as /v1 does, so one server answers MCP for every tenant; a key narrowed to a tenant needs no header. With --tenant, /mcp is bound to that tenant instead.
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"}'
Reads follow the memory scopes. Agent-scope memory is read only by the agent that wrote it (send X-Keel-Agent to read your own). Task-scope memory tied to a task id is read only by calls that pass that task (history's task is an exact filter); task-scope memory with no task id belongs to the tenant. Global-scope memory lives in a reserved partition, __global__, that every tenant reads and no event may write; neither a tool call nor an envelope may use that tenant name. A tenant of an account (acme/t, see below) reads acme/__global__ instead.
Several products on one deployment: accounts and API keys¶
One server can serve several products, each confined to its own data. An account is one product (a short slug: lowercase letters, digits and hyphens, at most 32 characters); its tenants are its end-customers. An API key belongs to one account, and the server puts the account in front of the tenant: X-Keel-Tenant: cliente-42 with a key of acme reads and writes the tenant acme/cliente-42, and envelopes posted to /v1/events are prefixed the same way. A tenant sent with a / is refused, so a key cannot reach another account.
uv run keel keys create acme --db keel.db # any tenant of acme
uv run keel keys create acme --db keel.db --tenant cliente-42 # only that tenant (the header may be left out)
uv run keel keys create acme --db keel.db --agent tires-bot # calls are made as that agent
uv run keel keys create acme --db keel.db --global # the only key that may annotate(scope="global")
uv run keel keys list acme --db keel.db # JSON lines; never prints a key again
uv run keel keys revoke kk_3f9a1c2b7d4e --db keel.db # refused by running servers within seconds
create prints the key once; only its SHA-256 is stored, in the api_keys table of the same database. Send it as Authorization: Bearer kk_.... The first key closes a server that had no secret. KEEL_SECRET keeps working as the operator's credential and reaches every tenant by its full name (acme/cliente-42). Each account has its own global memory, acme/__global__: its tenants read it, other accounts never do, and only its --global key writes it. /mcp follows the same rules per call; bound to one tenant with --tenant acme/cliente-42, it accepts only keys of that account that reach that tenant (and that agent, with --agent). The design: docs/design/hosted.md.
An account can also have limits, its own database and a retention policy:
uv run keel accounts set acme --db keel.db --rps 20 --events-month 1000000 # 0 removes a limit
uv run keel accounts set big --db keel.db --store mysql://user:pass@host:3306/big # "" goes back to the shared database
uv run keel accounts set acme --db keel.db --retention 3y # 3y, 18m or 90d
uv run keel accounts list --db keel.db
uv run keel forget --policy --db keel.db --yes # schedule it: each account's retention, on its tenants
Over its requests per second (a token bucket in each server process) or its events for the month (from the metering counters), a key gets 429 with Retry-After. An account with no row is not limited. With --store, the server keeps that account's graph in its own database, opened on first use, while keys, accounts and usage stay in the shared one, so keel usage still sees it; jobs for that account (keel index, keel expire) run with --db pointing at its database. --policy applies each account's retention like keel forget --before, in its own database when it has one.
When accounts bring their own cartridges, run one server per account: a cartridge carries Python, and it should never run next to another customer's data. keel serve <its cartridge> --transport http --db <shared> --account acme accepts only acme's keys (others get 403) and keeps its graph in the account's database (keel accounts set acme --store ...), while keys, limits and usage stay in the shared one. Hosted Keel runs this way, one container per account (docs/design/hosted.md).
What each tenant used, per month, one JSON line per account, tenant and month:
uv run keel usage --db keel.db --month 2027-01 --account acme
{"account": "acme", "tenant": "acme/cliente-42", "month": "2027-01", "events": 18342, "calls": {"context": 911, "search": 2380}}
events counts events applied for the first time, whatever path brought them (/v1/events, keel ingest, keel consume); duplicates, events for a forgotten tenant and keel replay are not counted, so a retry or a rebuild is never counted twice. calls counts tool calls through keel serve --transport http (/v1 and /mcp), kept in memory and written every ten seconds and at shutdown. Both live as daily counters in the metering table; a tenant with no account prefix is reported with "account": null.
Semantic search¶
search(text, mode="semantic") finds subjects by meaning ("worn-out tyre" finds a tire at 2.6 mm), filtered by tenant, the agent's read slice, kind and rule. Two adapters, both replaceable: where vectors live and where embeddings come from.
uv sync --extra chroma # only for the bundled Chroma adapter; Python 3.11+ (chromadb needs onnxruntime)
export KEEL_EMBEDDINGS_BASE_URL=https://api.openai.com/v1 KEEL_EMBEDDINGS_MODEL=text-embedding-3-small KEEL_EMBEDDINGS_API_KEY=...
uv run keel index examples/fleet --tenant acme-logistics --db keel.db --index chroma:///var/keel/vectors # cron it: incremental
uv run keel serve examples/fleet --transport http --db keel.db --index chroma:///var/keel/vectors
keel index keeps the time of its last run in a cursor file and indexes only what changed since; --full rebuilds the tenant's index. The cursor sits next to a chroma:///path index (keel-index-<tenant>.cursor), else in .keel/index-<tenant>.cursor under the working directory; --cursor or KEEL_INDEX_CURSOR puts it elsewhere. In a container, keep it on the same volume as the index: a cursor lost on deploy makes the next run a full rebuild, and keel index says so when it starts without one. Embedding batches go out four at a time (--workers, KEEL_INDEX_WORKERS), and a rate limit or a 5xx from the embeddings API is retried with backoff. --all-tenants indexes every tenant in the store, each with its own cursor (--cursor-dir or KEEL_INDEX_CURSOR_DIR puts them all in one folder), and clears the documents of every tenant forgotten since its last run. One run per tenant at a time: a run that finds another holding the lock next to the cursor skips (two runs at once corrupt a Chroma index), so a cron that overlaps a slow run is safe. --embedder on index and serve picks where embeddings come from: openai (the default, configured by the KEEL_EMBEDDINGS_* variables), hash (deterministic, for tests) or your own pkg.module:factory?k=v. Use the same one for both.
Any OpenAI-compatible endpoint works for embeddings (OpenAI, OfoxAI, Azure, Ollama, vLLM).
chroma:///path opens the index inside the process. When keel serve and keel index run as separate processes (a server and a cron), the server does not see what the other process writes and searches fail with Error finding id. Either run the indexing inside the server, keel serve ... --index chroma:///path --index-every 600 (every tenant every 10 minutes, cursors in KEEL_INDEX_CURSOR_DIR or next to the index; /health shows index with its last run or error), or run Chroma as a server and point both at it with chroma://host:port.
The Chroma adapter ranks the lexical leg itself: it pulls a kind's documents once, keeps their BM25 in memory and reuses it until the collection's count changes or 300 seconds pass (ChromaIndex(bm25_ttl=...)), so a re-index by another process shows in the lexical leg within that time (the dense leg sees it at once). Each document carries its subject as metadata, so related_to filters inside Chroma; a collection built before 0.21 lacks it and filters after retrieval, slower, until one keel index --full.
Weaviate. uv sync --extra weaviate and --index weaviate://host:8080 (Weaviate 1.25 or later). Query parameters: grpc_port (default 50051), grpc_host (default the same host), secure=true for TLS on both, collection (default Keel); the API key comes from KEEL_WEAVIATE_API_KEY, never the URL. Keel uses one multi-tenant collection and makes each Keel tenant a Weaviate tenant, so the server isolates them. Vectors come from Keel's embedder (the collection has no vectorizer), and the lexical leg is Weaviate's own BM25, so nothing is pulled into Keel's memory and a re-index by another process shows at once. Its tokenizer splits cus-1974 into two words where Keel's keeps one, so lexical scores differ slightly from Chroma's. Run the conformance tests against a server with KEEL_WEAVIATE_URL=weaviate://localhost:8080 uv run pytest tests/test_semantic.py.
Your own vector store or embedding model¶
Another vector database (pgvector, Qdrant, Pinecone, OpenSearch...) or your own model is an adapter you write: a plain class, nothing to inherit or register. examples/custom_adapters.py is a complete, tested one, a vector index on SQLite and an embedder wrapping any encode function. Run it with uv run python examples/custom_adapters.py.
class MyIndex: # keel.semantic.VectorIndex
def upsert(self, tenant, items): ... # items: [(subject key, vector, document)]
def delete(self, tenant, keys): ...
def query(self, tenant, vector, k, where=None): ... # [(key, cosine)], best first, this tenant only
def clear(self, tenant): ...
class MyEmbedder: # keel.semantic.Embedder
def embed(self, texts, role="document"): ... # one vector per text, same dimension
The rules an index must keep: every call is scoped by tenant and never returns another tenant's keys; query returns (key, score) with the score a cosine in [-1, 1], best first, at most k. Two capabilities are optional and detected from the signature: where={"kind": [...]} on query filters inside the index (without it Keel filters after retrieval, which an approximate index can miss), and a text_query(tenant, text, k, where=None) method adds the lexical leg for hybrid search. An embedder that takes role gets "query" for questions and "document" for what it indexes.
Name them by a spec, module:factory?param=value (parameters arrive as text), on index and serve alike, and check them with the same conformance suite as the bundled adapters:
uv run keel index examples/fleet --tenant acme-logistics --db keel.db \
--index "examples.custom_adapters:make_index?path=vectors.db" --embedder examples.custom_adapters:make_embedder
KEEL_VECTOR_INDEX="examples.custom_adapters:make_index?path=/tmp/v.db" KEEL_EMBEDDER=examples.custom_adapters:make_embedder \
uv run pytest tests/test_semantic.py
Vectors you already compute. Keel embeds the document it renders for each subject (key, attributes, open facts, the relations the cartridge names) and the query text, with the same embedder, so both sides live in one vector space. It does not take precomputed vectors. To use your own model, wrap it as the embedder: a function from texts to vectors is enough (ModelEmbedder in the example). Vectors you computed elsewhere, from other text, would not match the documents Keel searches.
Search is hybrid by default: ids in the query come first, then a dense and a lexical leg are fused (the lexical weight is semantic.lexical_weight, 0.3). Add --reranker llm (any OpenAI-compatible chat model, KEEL_RERANK_BASE_URL / _MODEL / _API_KEY) or --reranker http (the Cohere, Jina or Voyage /rerank API) to reorder the best candidates. For asymmetric embedders set KEEL_EMBEDDINGS_QUERY_PREFIX / _DOCUMENT_PREFIX (e5: "query: ", "passage: "). How much each piece buys, measured: docs/design/semantic-evals.md.
Indexing never runs in ingest, so ingest stays deterministic and offline. Only tenant and global knowledge is indexed: task and agent memory and pending proposals are not. The cartridge decides the rest:
semantic:
kinds: [card, cliente]
exclude: [comentario] # comments full of URLs and technical noise stay out
relations: [de_cliente] # "de_cliente: cliente:BAT (nombre: ...)" goes into the card's document