<!--
Full-page Markdown export (rendered HTML → GFM).
Source: https://neotoma.io/ur/aauth/integration
Generated: 2026-04-27T12:48:54.518Z
-->
# AAuth integration guide
End-to-end wiring guide for MCP client authors, local-proxy authors, and operators who need to wire a new agent into Neotoma and confirm the attribution tier their writes land with. For the wire format and trust-tier definitions see the [AAuth spec](/aauth/spec); for cryptographic envelopes see [attestation](/aauth/attestation); for CLI-side keygen see [CLI keys](/aauth/cli-keys).
## 1\. Wire format
Neotoma identifies a writing agent via two complementary channels, stamped into every durable row (observations, relationships, sources, interpretations, timeline events):
1. **AAuth** (RFC 9421 HTTP Message Signatures + AAuth profile). Caller signs the request and sends `Signature`, `Signature-Input` (MUST cover `@authority`, `@method`, `@target-uri`, `content-digest` when there is a body, and the `signature-key` header itself), and `Signature-Key` (the agent's JWK plus an agent-token JWT with `typ: "aa-agent+jwt"` carrying stable `sub` and `iss` claims). Neotoma verifies against the canonical `authority` from `NEOTOMA_AUTH_AUTHORITY`; using the `Host` header is explicitly unsafe.
2. **MCP `clientInfo` fallback**. On `initialize` the MCP transport self-reports `{ name, version }`. Self-reported; subject to generic-name normalisation.
Both channels contribute to a single `AgentIdentity` record persisted on every write.
◆
## 2\. Fallback precedence
For each request Neotoma resolves an `AgentIdentity` by walking these inputs in order; the first populated field at each layer wins:
AAuth (verified signature + JWT) → agent\_thumbprint, agent\_sub, agent\_iss,
agent\_algorithm, agent\_public\_key
│
▼
clientInfo.name + version → client\_name, client\_version
│
▼
OAuth connection id → connection\_id
│
▼
(nothing) → anonymous
The resulting trust tier is derived once per request. After v0.8.0 the cascade is attestation-aware:
- **`hardware`** , AAuth verified AND the JWT carries a verified `cnf.attestation` envelope AND, in v0.12.0+, the bound key has not been revoked.
- **`operator_attested`** , AAuth verified AND `iss` (or `iss:sub`) is in `NEOTOMA_OPERATOR_ATTESTED_ISSUERS` / `NEOTOMA_OPERATOR_ATTESTED_SUBS`.
- **`software`** , AAuth verified but no attestation envelope (or attestation failed and operator allowlist did not match), regardless of algorithm.
- **`unverified_client`** , No AAuth, but `clientInfo.name` survived normalisation.
- **`anonymous`** , Nothing else. `client_info` may have been too generic.
◆
## 3\. Generic-name normalisation
Self-reported client names go through `normaliseClientNameWithReason()`. Names rejected as attribution surface a reason code on the `/session` response:
- `not_a_string`, caller sent a non-string value.
- `empty`, empty or whitespace-only string.
- `too_generic`, name matches the generic-names blocklist (e.g. `mcp`, `client`, `anonymous`).
◆
## 4\. Verifying your session
### 4a. HTTP `GET /session`
Before enabling writes, a local proxy or CLI integrator should call `GET /session` with the same headers they intend to use for writes. The endpoint is read-only and is safe to poll.
curl -sS \\
-H "Signature: …" \\
-H "Signature-Input: …" \\
-H "Signature-Key: …" \\
"https://neotoma.example/session" | jq
Expected shape:
{
"user\_id": "usr\_…",
"attribution": {
"tier": "software",
"agent\_thumbprint": "…",
"agent\_sub": "agent:…",
"agent\_iss": "https://agent.neotoma.example",
"agent\_algorithm": "RS256",
"client\_name": "my-proxy",
"client\_version": "0.3.1",
"decision": {
"signature\_present": true,
"signature\_verified": true,
"resolved\_tier": "software"
}
},
"aauth": {
"verified": true,
"admitted": true,
"grant\_id": "ent\_…",
"admission\_reason": "admitted",
"agent\_label": "Cursor on macbook-pro"
},
"policy": { "anonymous\_writes": "allow" },
"eligible\_for\_trusted\_writes": true
}
**Integrator preflight rule:** a healthy signed client should see `attribution.tier === "hardware"` or `"software"`, `attribution.decision.signature_verified === true`, and `eligible_for_trusted_writes === true`.
`aauth.verified` ≠ `aauth.admitted` on purpose. Verified means the signature checked out. Admitted means Neotoma matched that signature to one of the user's `agent_grant` entities and is treating the caller as authenticated without OAuth/Bearer. A verified-but-unmatched signature stays attribution-only, the caller can still write under existing Bearer/OAuth flows but cannot use AAuth alone for admission. The `admission_reason` field reports the resolver outcome:
- `admitted`, active grant matched. AAuth alone is sufficient on this request.
- `no_grants_for_user`, the owner user has no grants at all yet (Inspector → Agent grants → New).
- `no_match`, this identity does not match any of the user's grants.
- `grant_revoked` / `grant_suspended`, identity matched a grant whose status is `revoked` or `suspended`.
- `strict_rejected`, strict-AAuth gating rejected the signature before admission ran.
- `aauth_disabled`, this deployment has AAuth disabled; admission did not run.
- `not_signed`, no AAuth signature was presented; only attribution-only paths are open.
If `signature_verified === false`, inspect `attribution.decision.signature_error_code`, it mirrors the diagnostic log line 1:1.
### 4b. MCP tool `get_session_identity`
Same payload, reachable over the MCP transport, useful for clients that don't want a second HTTP round-trip:
{
"method": "tools/call",
"params": { "name": "get\_session\_identity", "arguments": {} }
}
### 4c. CLI
neotoma auth session # JSON
neotoma auth session --text # Human-readable summary
◆
## 5\. Policy knobs
The Neotoma server publishes its active attribution policy on the `/session` response under `policy`:
- `anonymous_writes`, controlled by `NEOTOMA_ATTRIBUTION_POLICY=allow|warn|reject`; default `allow`.
- `min_tier`, controlled by `NEOTOMA_MIN_ATTRIBUTION_TIER=hardware|software|unverified_client`; default unset.
- `per_path`, controlled by `NEOTOMA_ATTRIBUTION_POLICY_JSON={"observations": "reject", …}`; default unset.
Per-path overrides accept any of the canonical write paths: `observations`, `relationships`, `sources`, `interpretations`, `timeline_events`, `corrections`. Missing paths inherit `anonymous_writes`.
When a write is rejected by policy the server returns HTTP 403 with:
{
"error": {
"code": "ATTRIBUTION\_REQUIRED",
"min\_tier": "software",
"current\_tier": "anonymous",
"hint": "Sign requests with AAuth or set NEOTOMA\_ATTRIBUTION\_POLICY=allow"
}
}
`warn` mode accepts the write, adds an `X-Neotoma-Attribution-Warning` response header, and emits a structured log event. `allow` is silent. For per-agent capability scoping (grants), see [capabilities](/aauth/capabilities).
◆
## 6\. Diagnostics
The Phase 2 diagnostic log line is the single source of truth for attribution resolution decisions. Every request that hits the AAuth middleware emits exactly one:
// DEBUG
{
"event": "attribution\_decision",
"signature\_present": true,
"signature\_verified": false,
"signature\_error\_code": "jwt\_expired",
"resolved\_tier": "anonymous"
}
Stable fields:
- `event`, always `"attribution_decision"`.
- `signature_present`, caller sent any of `Signature`, `Signature-Input`, or `Signature-Key`.
- `signature_verified`, signature + JWT both valid.
- `signature_error_code`, short code when `signature_verified` is false. Examples: `signature_invalid`, `jwt_expired`, `jwt_invalid`, `verification_threw`.
- `client_info_raw_name`, added on the `/session` response when the caller sent a non-empty clientInfo name.
- `client_info_normalised_to_null_reason`, `too_generic` / `empty` / `not_a_string` when the raw name was dropped.
- `resolved_tier`, final tier after merging AAuth + clientInfo.
**Safety invariants** (must hold at INFO and above): public keys, agent tokens, and signature bytes MUST NEVER appear in logs at INFO or higher. `agent_thumbprint` is safe to log at any level.
### Common integrator failures
- `signature_verified: false`, `signature_error_code: "signature_invalid"`, wrong `@authority`; body hashing misaligned; `content-digest` not covered.
- `signature_error_code: "jwt_expired"`, agent token past its `exp`. Refresh the token.
- `signature_error_code: "verification_threw"`, unreachable JWKS URL or malformed headers. Check network + header casing.
- `tier: "anonymous"`, no signature headers, caller forgot to sign; OR `Host` header rewriting stripped `@authority`.
- `tier: "unverified_client"` but expected `software`/`hardware`, AAuth not wired; clientInfo.name survived but signature is missing.
- `client_info_normalised_to_null_reason: "too_generic"` , clientInfo.name is in the blocklist (`mcp`, `client`, …). Pick a distinctive name.
◆
## 7\. Transport parity
Attribution is threaded uniformly across every transport that reaches Neotoma's write-path services. The single enforcement seam is `enforceAttributionPolicy(path, identity)` inside each service, what changes per transport is only how the identity gets into the per-request `AsyncLocalStorage` context that the services read from.
- **HTTP `/mcp`** , `aauthVerify` global middleware; `attributionContext` + a nested `runWithRequestContext` inside the `/mcp` handler with the server-resolved identity.
- **HTTP direct routes** (`/store`, `/correct`, `/observations/create`, `/create_relationship`, …), `aauthVerify` global middleware; `attributionContext` middleware (globally applied in `src/actions.ts`).
- **HTTP `GET /session`** , `aauthVerify` global middleware; read-only; resolves identity inline for response assembly.
- **MCP stdio**, no AAuth verification (stdio has no HTTP layer); `NeotomaServer.setSessionAgentIdentity` + `sessionClientInfo` assembled in `InitializeRequestSchema`; propagated by `runWithRequestContext` inside `CallToolRequestSchema` dispatch.
- **CLI-over-MCP (`executeToolForCli`)** , same wrap as stdio.
- **CLI over HTTP (`createApiClient`)** , `aauthVerify` global middleware on target; signs outbound requests with `~/.neotoma/aauth/` keypair when configured (see [CLI keys](/aauth/cli-keys)); middleware stamps the same decision as any other HTTP caller.
### HTTP fallback headers
Non-MCP HTTP callers (the CLI in particular) can self-report a `clientInfo`\-equivalent value through two optional headers:
- `X-Client-Name` → fallback attribution `client_name`.
- `X-Client-Version` → fallback attribution `client_version`.
Both go through the same generic-name normalisation as MCP's `initialize.clientInfo` handshake, so a value of `mcp` or `client` is still dropped to `anonymous`.
### CLI signer
The CLI ships an optional AAuth signer so `neotoma …` commands can land as `hardware` / `software` tier instead of `anonymous`:
neotoma auth keygen # Generate ES256 keypair under ~/.neotoma/aauth/
neotoma auth session # Inspect resolved tier + signer configuration
neotoma auth sign-example # Print a debugging curl with a signed JWT
When a keypair is present at `~/.neotoma/aauth/private.jwk`, `createApiClient` transparently signs outbound requests via `@hellocoop/httpsig`. Signing is silently skipped (no error) when no keypair is configured so existing CLI users are unaffected. Hardware-backed keygen is on [CLI keys](/aauth/cli-keys).
Back to [AAuth overview](/aauth). See also [AAuth spec](/aauth/spec), [attestation](/aauth/attestation), [CLI keys](/aauth/cli-keys), [capabilities](/aauth/capabilities).