Design note: agent registry¶
Status: implemented on 2026-10-14 as proposed, with both open questions decided the same day: read filtering is in v1, and an unknown or missing agent id is refused whenever the cartridge declares agents. The normative text is now SPEC ยง6.1; this note stays as the reasoning.
Problem¶
A Tools instance is bound to a cartridge and a tenant, and to nothing else. annotate takes a free-text by; any caller may write to any scope as long as it brings evidence; context, related, search and history return everything the tenant has. That is fine for one team's agents on one box. It is not fine when several agents from different owners share a tenant's graph, when one agent must never write tenant-scope memory, when an agent should only see part of the ontology (a billing agent has no business reading inspections), or when the audit trail must say which agent wrote what and be believed.
Goals¶
- Identity. Every tool call is made by a known agent id.
annotaterecords it:bydefaults to the agent id,sourceisannotate:<agent>. - Write policy per agent. Which scopes it may write. Default
taskandagent;tenantonly when declared;globalnever from an agent. - Read policy per agent. Which entity kinds and relations it may see. Everything else is filtered out of the four read tools.
- Enforced in
Tools, never trusted to the agent (design rule 5). No sixth tool (rule 7).
Non-goals¶
- Authentication. The host (your SaaS, the process that launches
keel serve) decides who is calling; Keel takes the agent id as given.keel serve --agent tires-botbinds one server to one agent, the way--tenantbinds it to one tenant. - Per-user permissions. Keel's unit is the agent, not the human behind it.
Proposal¶
Declare agents in the cartridge, next to the ontology they use:
agents:
tires-bot:
description: Reads tire depth, writes task memory for the inspection audit.
writes: [task, agent]
reads: { kinds: [unit, tire, inspection], relations: [mounted_on, has_inspection] }
fleet-copilot:
description: Answers the customer's questions across the whole graph. Writes tenant memory with evidence.
writes: [task, agent, tenant]
Absent reads means everything in the cartridge. Absent agents: means no registry: today's behaviour, any caller, any scope with evidence. Backwards compatible.
Core changes, about eighty lines:
Cartridge.agents: dict[str, AgentSpec];validate()checks that kinds, relations and scopes exist.Tools(cartridge, store, tenant, agent=None). With an agent:annotateraisesPermissionErroroutsidewritesand defaultsbyto the agent id; the read tools filter byreads.keel serve --agent <id>; the MCP server name becomeskeel:<cartridge>:<tenant>:<agent>.keel exportalready carriescorrected_byandsource, so the audit trail gains trustworthy authorship with no change.
MemoryPolicy stays as it is (evidence, TTL): the registry says who may, the policy says how. Order of checks: registry first, then policy.
Alternatives considered¶
- A separate
agents.yamloutside the cartridge keeps a vendor's agents apart from the domain, but then two files must agree on kinds and relations. The cartridge already versions the ontology, so the registry belongs with it. A customer disabling an agent for its own tenant can come later as a tenant-scope fact, not a file. - Enforcing in the MCP layer only leaves the plain-function path open. Rule 5 says the layer enforces, so it is
Tools.
Open questions¶
- Read filtering in v1, or writes only? Writes and identity are about thirty lines and cover the audit need. Read filtering touches every tool and
search. Recommendation: writes and identity first; reads when a pilot has two agents that must see different slices. - Unknown agent id: refuse with
PermissionError, or allow with the default policy? Recommendation: refuse when a registry exists, allow when it does not.