<!--
  Full-page Markdown export (rendered HTML → GFM).
  Source: https://neotoma.io/de/aauth/integration
  Generated: 2026-04-27T12:48:54.759Z
-->
# 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).