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; for cryptographic envelopes see attestation; for CLI-side keygen see 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):
- AAuth (RFC 9421 HTTP Message Signatures + AAuth profile). Caller signs the request and sends
Signature,Signature-Input(MUST cover@authority,@method,@target-uri,content-digestwhen there is a body, and thesignature-keyheader itself), andSignature-Key(the agent's JWK plus an agent-token JWT withtyp: "aa-agent+jwt"carrying stablesubandissclaims). Neotoma verifies against the canonicalauthorityfromNEOTOMA_AUTH_AUTHORITY; using theHostheader is explicitly unsafe. - MCP
clientInfofallback. Oninitializethe 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) → anonymousThe 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 verifiedcnf.attestationenvelope AND, in v0.12.0+, the bound key has not been revoked.operator_attested, AAuth verified ANDiss(oriss:sub) is inNEOTOMA_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, butclientInfo.namesurvived normalisation.anonymous, Nothing else.client_infomay 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 isrevokedorsuspended.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 byNEOTOMA_ATTRIBUTION_POLICY=allow|warn|reject; defaultallow.min_tier, controlled byNEOTOMA_MIN_ATTRIBUTION_TIER=hardware|software|unverified_client; default unset.per_path, controlled byNEOTOMA_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.
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 ofSignature,Signature-Input, orSignature-Key.signature_verified, signature + JWT both valid.signature_error_code, short code whensignature_verifiedis false. Examples:signature_invalid,jwt_expired,jwt_invalid,verification_threw.client_info_raw_name, added on the/sessionresponse when the caller sent a non-empty clientInfo name.client_info_normalised_to_null_reason,too_generic/empty/not_a_stringwhen 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-digestnot covered.signature_error_code: "jwt_expired", agent token past itsexp. 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; ORHostheader rewriting stripped@authority.tier: "unverified_client"but expectedsoftware/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,aauthVerifyglobal middleware;attributionContext+ a nestedrunWithRequestContextinside the/mcphandler with the server-resolved identity. - HTTP direct routes (
/store,/correct,/observations/create,/create_relationship, …),aauthVerifyglobal middleware;attributionContextmiddleware (globally applied insrc/actions.ts). - HTTP
GET /session,aauthVerifyglobal middleware; read-only; resolves identity inline for response assembly. - MCP stdio, no AAuth verification (stdio has no HTTP layer);
NeotomaServer.setSessionAgentIdentity+sessionClientInfoassembled inInitializeRequestSchema; propagated byrunWithRequestContextinsideCallToolRequestSchemadispatch. - CLI-over-MCP (
executeToolForCli) , same wrap as stdio. - CLI over HTTP (
createApiClient) ,aauthVerifyglobal middleware on target; signs outbound requests with~/.neotoma/aauth/keypair when configured (see 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 attributionclient_name.X-Client-Version→ fallback attributionclient_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.
Back to AAuth overview. See also AAuth spec, attestation, CLI keys, capabilities.