<!--
Full-page Markdown export (rendered HTML → GFM).
Source: https://neotoma.io/fr/aauth/capabilities
Generated: 2026-04-27T12:48:55.583Z
-->
# Agent capability scoping
The per-agent capability registry sits above the tier-based attribution policy. Where the [attribution policy](/aauth/integration) asks "is this write attributable at all?", the capability registry asks "is *this* specific agent allowed to touch *this* specific `entity_type` via *this* operation?".
Capabilities are modelled as first-class `agent_grant` entities, one per (user, agent identity) pair, managed in the Inspector under **Agents → Agent grants**. The previous environment-variable registry (`NEOTOMA_AGENT_CAPABILITIES_JSON`, `NEOTOMA_AGENT_CAPABILITIES_FILE`, `NEOTOMA_AGENT_CAPABILITIES_ENFORCE`, `config/agent_capabilities.default.json`) has been removed.
## When does this apply?
- **User-authenticated callers** (Bearer / OAuth / local Inspector session), not enforced; full access to their own user\_id's data, modulo attribution policy.
- **AAuth-verified agent matched to an `active` grant** , enforced; restricted to declared `(op, entity_type)` pairs on the grant.
- **AAuth-verified agent with no matching grant** , falls through to attribution-only behaviour (no admission, must use Bearer/OAuth).
- **Anonymous / unverified-client tier** , no admission; subject to attribution policy.
The canonical use is pinning the Netlify forwarder (`sub: agent-site@neotoma.io`) to the `neotoma_feedback` entity type, so a compromised forwarder key cannot be used to write observations for unrelated entities.
◆
## Grant shape
An `agent_grant` is a normal Neotoma entity, observation history doubles as the audit log. Canonical fields:
{
"entity\_type": "agent\_grant",
"owner\_user\_id": "usr\_…",
"label": "Cursor on macbook-pro",
"match\_sub": "agent-cursor@example.com", // AAuth sub claim
"match\_iss": "https://agent.example.com", // optional; both must match when set
"match\_thumbprint": "abcd…", // optional RFC 7638 JWK thumbprint
"capabilities": \[
{ "op": "store\_structured", "entity\_types": \["neotoma\_feedback"\] },
{ "op": "create\_relationship", "entity\_types": \["neotoma\_feedback"\] },
{ "op": "correct", "entity\_types": \["neotoma\_feedback"\] },
{ "op": "retrieve", "entity\_types": \["neotoma\_feedback"\] }
\],
"status": "active", // active | suspended | revoked
"notes": "issued 2026-04",
"last\_used\_at": "2026-04-26T09:54:00Z"
}
### Identity rule
At least one of `match_sub` or `match_thumbprint` MUST be set; `match_iss` is optional but, when set, BOTH `match_sub` AND `match_iss` MUST match the verified identity for the grant to admit.
### Capability ops
- `store_structured`, creating / observing entities (write path).
- `create_relationship`, creating relationships between entities.
- `correct`, correcting / updating existing observations / fields.
- `retrieve`, reading entities and observations.
`entity_types` is a string array of permitted entity types for that op. Use `["*"]` to widen to every type, only do this for trusted grants.
### Matching order
Admission resolves the verified identity to at most one grant:
1. If the request carries a JWK thumbprint AND any of the user's grants has a matching `match_thumbprint`, that grant wins.
2. Otherwise, the first `active` grant whose `match_sub` equals the request's `sub` and (when set on the grant) whose `match_iss` equals the request's `iss`.
3. Otherwise, no admission, the request stays attribution-only.
◆
## Status lifecycle
active ⇄ suspended
│ │
▼ ▼
revoked (terminal in normal flow)
│
▼ restore (within grace window)
active
Only the user who owns the grant (or an agent the user has authorised with the bootstrap `(store_structured | correct, agent_grant)` capability) can flip status. Admission caches the resolved grant for a small TTL plus invalidates on observation events, so a revoke propagates to in-flight clients within seconds.
◆
## Protected entity types, the trust mechanism
Writes to `agent_grant` (and any future protected type) are gated by the protected-entity-types guard:
- User-authenticated callers (Bearer / OAuth / local Inspector session for the same user) pass through.
- AAuth-admitted callers must hold an explicit capability in their grant for the protected type. The bootstrap capability is `{ op: "store_structured", entity_types: ["agent_grant"] }` (and `correct`).
- Anonymous / unverified-client tier writes to protected types are rejected with `capability_denied`.
This is what lets a user safely delegate grant management to a trusted agent: only that one grant carries the bootstrap capability; every other grant remains locked out of `agent_grant` writes by the protected-types guard, even if it has otherwise broad capabilities.
◆
## Strict-require AAuth for claimed subjects
Set `NEOTOMA_STRICT_AAUTH_SUBS` to a comma-separated list of agent subjects that MUST present a valid AAuth signature whenever the request claims that identity via the `X-Agent-Label` header. This is a second line of defence against a compromised tunnel / edge:
- `X-Agent-Label: agent-site@neotoma.io` + missing signature → 401.
- `X-Agent-Label: agent-site@neotoma.io` + signature verified, but the `sub` claim is something else → 401.
- Any label NOT listed in `NEOTOMA_STRICT_AAUTH_SUBS` behaves as before (best-effort attribution hint).
◆
## Error surface
A denial produces HTTP 403 with:
{
"error": {
"code": "capability\_denied",
"message": "Agent \\"agent-site@neotoma.io\\" is not permitted to store\_structured entity\_type \\"person\\".",
"op": "store\_structured",
"entity\_type": "person",
"agent\_label": "agent-site@neotoma.io",
"hint": "Agent \\"agent-site@neotoma.io\\" holds an active grant but no \\"store\_structured\\" capability for entity\_type \\"person\\". Edit the grant in Inspector → Agents → Agent grants if intentional."
}
}
◆
## Operator runbook
### Upgrading from the env-config era
The previous release loaded capabilities from `NEOTOMA_AGENT_CAPABILITIES_JSON` / `_FILE` / `config/agent_capabilities.default.json`. After upgrading, starting the server with any of those variables set fails fast with a structured error linking to the import command.
Migrate once, per deployment:
neotoma agents grants import --owner-user-id <usr\_…> \\
\[--file path/to/agent\_capabilities.json\]
- `--owner-user-id` decides which user account owns the imported operational grants. Pick the operator's own user account, or a dedicated account for infrastructure agents.
- The command is idempotent on `(match_sub, match_iss, match_thumbprint)`, re-running it after a partial migration upserts grants without duplicating.
- Each created/updated grant is stamped with provenance `import_source: "env_config"` so the audit timeline clearly records the migration origin.
- Once the import succeeds, unset the legacy variables and redeploy.
### Grant a new scope
1. In Inspector, go to **Agents → Agent grants → New grant**.
2. Paste the agent's AAuth `sub` (and `iss`, or thumbprint) and a readable label.
3. Select capabilities by `(op, entity_type)`.
4. Save. Admission picks up the new grant within the cache TTL.
### Revoke or suspend a scope
1. Open the grant in Inspector → **Agents → Agent grants → :id**.
2. Click **Suspend** (reversible) or **Revoke** (terminal).
3. The next request from that agent reverts to attribution-only after the admission cache TTL.
### Roll back a botched grant edit
Grant edits are observations, open the grant detail view and use the audit timeline to see what changed. Apply a `correct` to restore the prior values (or use the **Restore** action to roll back a recent revoke within the grace window).
Back to [AAuth overview](/aauth). See also [integration](/aauth/integration), [AAuth spec](/aauth/spec), [attestation](/aauth/attestation), [CLI keys](/aauth/cli-keys).