Primitive spec

Gaal

The external agent gateway. Gaal is the front door that lets an AI outside the platform — a Claude Code session, an MCP connector, any MCP client — work against one LatticeKit tenant, under a grant the tenant's operator consented to and can revoke. It is transport, authentication, grant resolution, exposure filtering and audit; it holds no business logic of its own.

Status: Live on production since September 3, 2026 — read capabilities, discovery meta-tools, the OAuth 2.1 connector flow, tier-1 writes, the approval inbox and daily ceilings. An operator mints an API key for an MCP client from the console's Agents page; the endpoint and connection flow are on the Agent API page.

What it owns

Gaal owns the boundary, not the behaviour. Every tool call an outside agent makes routes through the one CapabilityInvoker path that the console and the workflow engine already use, so an agent cannot reach an action a human operator could not, and cannot reach it under weaker rules. What Gaal adds is the parts that only matter when the caller is a machine belonging to someone else: who it is, what it was granted, what it may see, what it may do without asking, and a record of every call.

Gaal is not Beth — the in-console assistant stays Demerzel-internal with its own confirmation flow. It is not a REST API, and it never hosts a model.

Concepts

The endpoint
POST /agent/v1/mcp — stateless streamable-HTTP MCP. One JSON-RPC message in, one JSON response out. Supports initialize, notifications/*, ping, tools/list, tools/call. Batching is rejected, per the 2025-06-18 MCP revision.
AgentGrant
The one record every request resolves to, whatever the credential. Scopes (agent:read, agent:write, agent:propose) live on the grant, never on the transport credential, so revocation is authoritative in one place and is checked per request. sandboxOnly forces sandbox posture server-side; subTenantId narrows a grant to one business unit.
Deny-by-default exposure
A capability is invisible to the gateway until its provider explicitly opts in with Exposure::agent(...). The first sweep granted exactly the read capabilities already exposed to tenant operators — 102 across ten primitives. Staff-only reads were not widened.
Discovery meta-tools
tools/list advertises a fixed core of 15 or fewer: three meta-tools (lattice_search_capabilitieslattice_describe_capabilitylattice_invoke) plus high-traffic reads. Every other visible capability stays callable through lattice_invoke. Advertising less is a context optimisation for the connecting agent, not a security boundary — agents degrade under schema sprawl.
Tiered writes
agent:write unlocks tier-1 actions only — today check-in, attendance and form submission: the things a door scanner, a register and a kiosk do repeatedly with nobody watching. Every write carries an Idempotency-Key, enforced at the gateway, because the retry is a machine's: an agent that times out re-sends without ever learning whether the first attempt landed.
The approval inbox
agent:propose unlocks tier-2 actions in one specific sense: the agent may ask. Calling one parks an AgentProposal and returns; a human reads what is being proposed and decides. Anything that moves money or touches a person's record lives here.
Daily ceilings
Per-grant limits on what an unattended agent can do in a day — call counts and a money ceiling — so a loop that goes wrong is bounded by policy rather than by whoever notices first.
Handbook as MCP resources
The gateway serves a set of documents written for callers — the domain model, the contracts that hold everywhere, and how to connect — to connected agents as MCP resources (resources/list, resources/read, addressed latticekit://docs/...), so an agent can answer "how does a booking get confirmed and what can block it" without anyone pasting context into its prompt. The same set is published as llms.txt for clients that have never connected. The engineering handbook is deliberately not part of it.

API surface

Gaal exposes one HTTP route and the OAuth discovery chain around it. Everything else is MCP method dispatch inside that route.

RoutePurpose
POST /agent/v1/mcpThe MCP endpoint. JSON-RPC over streamable HTTP; bearer-authenticated, resolved to an AgentGrant.
GET /.well-known/oauth-protected-resource/agent/v1/mcpRFC 9728 protected-resource metadata — where a client learns which authorisation server to use. Public and unauthenticated by definition.
GET /.well-known/oauth-authorization-serverAuthorisation-server metadata for the OAuth 2.1 connector flow.
Dynamic client registrationAn MCP client registers itself, then walks the authorise → consent → token exchange. The operator sees the consent screen and picks the scopes.

How it fits with the rest

flowchart TB
  Ext[Outside agent
MCP client] --> G(Gaal gateway) G --> Auth[AgentGrant
scopes + revocation] Auth --> Exp[Exposure filter
deny-by-default] Exp --> Inv[CapabilityInvoker] Inv --> Prim[Any primitive] Exp -. tier 2 .-> Prop[Approval inbox] Prop -. human decides .-> Inv G --> Aud[Audit + rate limit]

The invoker is the same one Demerzel's console and Daneel's workflow steps call, so an agent inherits every check a human caller gets and adds the grant on top. Pirenne is what makes agent configuration reviewable: rather than choreographing fifty writes, an agent emits one bundle a human approves once.