API reference
The gateway's inference, MCP, admin, and SCIM surfaces.
| Surface | Serves | Credential |
|---|---|---|
/v1/* | OpenAI-compatible inference plus the Anthropic Messages API | Client key or service key |
/mcp, /mcp/org | Retrieval tools; the organization's remote connectors | Client key, or an AuthKit token on /mcp/org |
/mcp/admin | The admin MCP for claude.ai and Claude Desktop | AuthKit token for /mcp/admin only |
/admin/*, /admin/v1/* | Configuration and reporting | Admin token, console SSO session, or aak_ key |
/scim/v2/* | Inbound user and group provisioning | SCIM bearer |
Your gateway serves its own explorer at GET /docs, over the OpenAPI 3.1
spec at GET /openapi.json (load that into Postman or a client generator).
Both show only the API shape, but "try it out" sends a real request, so it still needs your key.
Without a deployment in front of you, read the published copy at
docs.anyray.ai/openapi.json.
open http://localhost:8787/docs # or https://<your-anyray-gateway>/docs
Route by route, the index is the Endpoint reference. The optimizer's own hooks are the separate optimizer protocol.
Inference POST requests accept optional optimization selection headers. Absent headers preserve configured behavior; preferences can only narrow the server's policy.
Base URL
The gateway is self-hosted. It runs on your own infrastructure, default port 8787, not on
docs.anyray.ai. Put it behind TLS for any networked deployment.
https://<your-anyray-gateway> # e.g. http://localhost:8787 in-network
Provider keys, content mode, and the optimizer URL: Configure.
Authentication
Send your Anyray client key as a bearer token, in x-anyray-api-key, or, for Anthropic-native
clients on /v1/messages, in x-api-key. The real provider key stays server-side.
curl https://<your-anyray-gateway>/v1/chat/completions \
-H "Authorization: Bearer $ANYRAY_API_KEY" \
-H "Content-Type: application/json" \
-H 'x-anyray-provider: openai' \
-d '{
"model": "gpt-6.1-sol",
"messages": [{ "role": "user", "content": "Summarize the release notes." }]
}'
The six credentials
| Credential | Send it on | It grants |
|---|---|---|
Client key ark_… | /v1/* | The holder's user and team attribution. An admin mints it, or the developer does with anyray-connect --sso. |
Service key ark_svc_… | /v1/* | A revocable non-human identity for an agent, CI job, or SDK script, with an optional monthly dollar budget. No interactive SSO. |
| AuthKit access token | /mcp/org or /mcp/admin, never both | A WorkOS user in the customer organization. See Organization MCP with SSO and Admin MCP with SSO. |
Admin token ANYRAY_ADMIN_TOKEN | Console, /admin/* | Break-glass owner access, kept server-side. Each route still needs its capability. |
Admin API key aak_… | /admin/* | Automation, with either a capability subset or v1 scopes. Never inference, never trace content. See Scoped admin API keys. |
| SCIM bearer | /scim/v2/* | One tenant's IdP provisioning. Only its SHA-256 hash is stored. |
Every /v1/* request needs a minted key. The key's own user and team set attribution, and
x-anyray-metadata cannot override them. OAuth never runs on /v1/* itself.
| Status | code | Cause |
|---|---|---|
401 | missing_key, key_unknown, key_expired, key_revoked, user_deactivated | The identity is absent or rejected. The body carries a safe hint. |
403 | passthrough_only_key, path_key_refused | The key is valid but not accepted this way. A passthrough-only key (ark_pt_) works only in the base URL path, and no other key kind may be carried there. |
503 | key_store_unavailable | The gateway cannot reach the key store. Verification fails closed, so a database outage never reads as a bad credential. |
Organization MCP with SSO
An MCP client that cannot attach a static header adds one URL:
https://<your-anyray-gateway>/mcp/org
The unauthenticated request returns 401 and points at the protected-resource metadata:
WWW-Authenticate: Bearer resource_metadata="https://<your-anyray-gateway>/.well-known/oauth-protected-resource"
That public metadata names WorkOS AuthKit as the authorization server. AuthKit runs the flow through the enterprise SSO connection already attached to the customer's WorkOS organization. The gateway implements no authorization codes, PKCE, client registration, consent, refresh, or revocation.
The access token must pass every check:
| Claim or property | Required value |
|---|---|
| signing algorithm | RS256 |
iss | the managed AuthKit issuer |
aud | one scalar string equal to https://<your-anyray-gateway>/mcp/org |
| time and subject | valid sub, iat, and exp |
| organization | non-empty WorkOS org_id |
No mcp:org scope is advertised or required. The exact resource audience is what separates one
gateway from another. A client key still works on /mcp/org and takes precedence when presented,
which keeps Connect-managed tools compatible.
Keep non-SSO methods disabled in the WorkOS domain policy, and require SSO in the organization policy. Otherwise a manually enabled Google or Magic Auth method can bypass the enterprise IdP.
What happens after JWT verification
The gateway sends only sub and org_id to Billing under its deployment bearer. The AuthKit token
never reaches Billing or storage. Billing requires the tenant's activated SSO marker and a live
WorkOS connection, fetches the current WorkOS user, requires exactly one active membership in the
configured organization, resolves the current groups to an Anyray team, and applies allowed domains
and revocation.
Throttles, outages, and vendor confirmation
Pre-verification and live identity throttles return 429 with Retry-After. Concurrent config,
JWKS, and metadata refreshes share one outbound request. A Billing, WorkOS, or AuthKit outage fails
closed with 503.
Billing also returns 503 until a vendor operator confirms the current WorkOS organization policy
and the exact Resource Indicator derived from this deployment's public gateway URL. Rebinding the
WorkOS organization or changing the gateway URL invalidates that confirmation.
This lane never provisions WorkOS for an ordinary tenant. The WorkOS organization is created only after an authenticated owner chooses Set up SSO, or an operator binds an existing organization. Non-SSO tenants keep using client-key MCP authentication.
Setup: Organization MCP endpoint. Every route: endpoint reference.
Admin MCP with SSO
https://<your-anyray-gateway>/mcp/admin serves the admin MCP
to claude.ai and Claude Desktop. It is a second AuthKit resource with the same token checks as
/mcp/org, except:
| Property | /mcp/admin |
|---|---|
aud | one scalar string equal to https://<your-anyray-gateway>/mcp/admin. A /mcp/org token is refused here, and this token is refused there. |
| challenge | resource_metadata="https://<your-anyray-gateway>/.well-known/oauth-protected-resource/mcp/admin" |
| other credentials | none. Client keys, aak_ keys, the admin token and console sessions are refused. |
| identity | Billing's /v1/mcp-auth/admin/identity returns the person's console role and capabilities, resolved live on every request, never cached. Its tenant must be this deployment's. |
| tools | Discovery and every call use that grant. Without a write grant there is no write tool. Operations marked x-returns-content or x-returns-secret are refused for every role. |
Each call runs in-process through the full /admin/v1 chain as that person, with their scopes, level
and rate limit, and no credential from the request attached. A Billing outage fails closed with 503.
Service keys for agents and CI
| Method | Path | Capability | Shape |
|---|---|---|---|
| post | /admin/service-keys | clientkeys:manage | { "name": string, "team": string, "monthlyBudgetUsd"?: number } → 201 { "key": "ark_svc_…", "record": {...} } |
| get | /admin/service-keys | config:read | { "keys": [...] }, service keys only, source and type set to service |
| delete | /admin/service-keys/:id | clientkeys:manage | { "ok": true } |
curl https://<your-anyray-gateway>/admin/service-keys \
-H "Authorization: Bearer $ANYRAY_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "synthetic-ci-release",
"team": "platform",
"monthlyBudgetUsd": 25
}'
Store the returned key at once. The gateway keeps only its SHA-256 hash and never returns the raw token again. A budget counts successful request cost per UTC calendar month. At or above it, covered inference routes return:
{
"error": {
"code": "service_key_budget_exceeded",
"message": "This service key has reached its monthly budget. Ask an administrator to raise the budget or wait for the next UTC month."
}
}
The cap is soft and fails open. Completed requests update the shared counter after their responses,
so concurrent requests can overshoot, and a counter outage, or a counter read slower than 250 ms,
lets requests through. A key with no
monthlyBudgetUsd, or with 0, is unlimited.
Scoped admin API keys
An admin API key (aak_…) scripts /admin/* without the break-glass ANYRAY_ADMIN_TOKEN.
- Mint it with exactly one grant shape: a subset of the admin capabilities, or native Management API v1 scopes.
- The grant is fixed at mint time and can never exceed the minter's own access. Content, content mode, SSO config, self-update, and key management stay ungrantable.
- Minting needs
adminkeys:manage(security_adminand up). - Keys expire after 90 days. Pass
expiresInDaysfor another horizon, orneverExpires: true.
A scope-minted key is v1-only. It holds no capabilities, so every legacy /admin/* capability
gate rejects it. Use it on /admin/v1/*, plus the scope-less GET /admin/v1/me. A v1 403 names
the exact scope to mint in required_scope.
| Method | Path | Scope | Shape |
|---|---|---|---|
| post | /admin/v1/admin-keys | admin-keys:write | Exactly one of caps: string[] or scopes: string[], plus optional label and expiry → 201 { "key": "aak_…", "record": {...} } |
| get | /admin/v1/admin-keys | admin-keys:read | { "keys": [...] }, metadata only, hashes stripped |
| delete | /admin/v1/admin-keys/{id} | admin-keys:write | { "ok": true } |
The legacy twins under /admin/api-keys take adminkeys:manage (mint, revoke) and config:read (list).
Store the returned key at once. Revocation takes effect on the next request, on every replica.
Admin writes made with a key are attributed in the audit log as apikey:<id>.
- Mint by scope
- Use it
curl -X POST https://<your-anyray-gateway>/admin/v1/admin-keys \
-H "Authorization: Bearer $ANYRAY_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"label": "ci-dashboard",
"scopes": ["spend:read"]
}'
curl https://<your-anyray-gateway>/admin/v1/spend/dashboard \
-H "Authorization: Bearer $ADMIN_API_KEY"
On the legacy surface a key needs a capability grant instead. A budget bot that reads config and
writes one user's caps takes ["config:read", "usercaps:write"]:
- Mint by capability
- Use it
curl https://<your-anyray-gateway>/admin/api-keys \
-H "Authorization: Bearer $ANYRAY_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"label": "budget-bot",
"caps": ["config:read", "usercaps:write"]
}'
curl -X PATCH https://<your-anyray-gateway>/admin/user-caps/alice \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "maxUsd": 250 }'
That PATCH merges a partial edit into one user's entry. Fields, conditional writes, and the
whole-map PUT: per-user caps and budgets.
The console host's proxy replaces the Authorization header with the browser login cookie, so an
aak_ key only works against the gateway itself (port 8787 by default), not through the console
hostname.
If the key store is unreachable, aak_ verification fails closed with 503 and
admin_key_store_unavailable, never 401, and the break-glass admin token keeps working.
Console sessions work the same way: signing out revokes the session on every replica, and if the
gateway cannot read that shared list, a session cookie gets 503 session_store_unavailable.
Management API v1
Stable /admin/v1/* is the /admin/* surface re-cut for scripts and
API keys: plural-noun resources, one permission rule you read off the URL, one error envelope, and a
request id on every response. Response shapes are the documented contract, and a breaking change
would arrive only as /admin/v2.
For spend charts, GET /admin/v1/spend/overview?window=30d returns totals and breakdowns without
latency, cache, retrieval, or strategy diagnostics. savingsByModel keeps attributed savings-only
models that have no billed tokens, and each byUser row includes tokensSavedByModel. Both let
fixed-window clients skip the heavier usage-detail read. Its
holdout field is the
audited holdout's reading for the same
window: status (holdout_off, insufficient, imbalanced, target_met, target_not_proven, or
unavailable when the arms could not be folded), sessions per arm with the minPerArm floor,
costUsdPerSession per arm, observedReductionPct and its ci95, qualityVerdict, and the
optimizer's own holdoutEnabled (null when it could not be asked). GET /admin/v1/spend/active-users?window=7d
returns the complete list of attributed users with billed or saved tokens in that window.
Legacy /admin/* data routes still answer, now with Deprecation: true and a
Link: <successor>; rel="successor-version" header naming their v1 path. Plan their retirement into
your scripts. Requests are capped at 300 per minute per principal, then 429 with Retry-After.
Beyond the re-served routes, v1 adds:
| Addition | Routes |
|---|---|
| Key lifecycle | GET/PATCH /admin/v1/keys/{id}; POST …/{id}/rotate, which returns the fresh token exactly once; POST …/{id}/secret, which returns an agent key's stored value (service keys only, sealed at rest, cache-control: no-store, audited on every read) |
| Provider key writes | PUT/DELETE /admin/v1/providers/{slug}/key return {status, capabilities} with key ids only. |
| Key list pages | GET /admin/v1/keys?limit=&cursor=, a { "data": [...], "next_cursor": ... } page of keys, oldest first (limit 50 by default, 200 at most). Without either parameter the answer stays the whole { "keys": [...] } list. keys:read |
| Audit trail | GET /admin/v1/audit/events?limit=&cursor=&category=, a { "data": [...], "next_cursor": ... } page over the admin and GDPR trail, audit:read |
| Trace content export | GET /admin/v1/traces/export, every body in a time window as NDJSON (below), traces:content |
Developers get self-service on the inference plane instead, with no admin credential:
GET /v1/me, /v1/me/usage, and /v1/me/limits answer with the verified key holder's identity,
month-to-date usage, and the ceilings that would 402 them.
The scope rule
A scope is <resource>:<action>, where the resource is the first path segment after /admin/v1/:
| Request | Scope it needs |
|---|---|
GET /admin/v1/<resource>/… | <resource>:read |
POST, PUT, PATCH, DELETE /admin/v1/<resource>/… | <resource>:write |
POST /admin/v1/<resource>/…/query (a read whose filter needs a body) | <resource>:read |
Resources: keys, admin-keys, users, spend, providers, routing, optimizer, policies,
settings, sso, enrollment, provisioning, fleet, traces, artifacts, audit, playground.
GET /admin/v1/me needs no scope and returns the scopes the caller holds.
| Request | Scope | Result |
|---|---|---|
POST /admin/v1/me/logout | none | Revoke the presented hs1 or sso1 console session across replicas. Returns { "revoked": true }. |
Send the session as a Bearer or cookie. If both present different sessions, logout revokes both.
Admin tokens and aak_ keys get 400 not_a_session.
A revocation store failure returns 503 session_store_unavailable.
Send x-anyray-scope-ceiling: key to restrict a request to key-grantable scopes and capabilities.
This also applies to legacy capability gates and handler checks. A ceilinged session cannot act as
a person, including on artifact reads. Identity and role stay unchanged. An aak_ key keeps its
existing grant. Any other header value returns 400 invalid_scope_ceiling.
Before relying on the ceiling, require key-grade-ceiling in info.x-anyray-spec-features.
Older gateways can ignore the header.
The spec publishes typed JSON request bodies. Every provider-key write rejects a key or field value
that is only whitespace with 400 empty_provider_key. Per-slug PUT /admin/v1/providers/{slug}/key
also rejects an empty one. Remove a provider with DELETE.
Provider-key writes, including PUT /admin/v1/providers, return {status, capabilities}
with provider slugs and key ids only, never credential values. They keep x-returns-secret
for one release so older admin MCPs, and replicas mid-upgrade, still refuse them.
Its extensions describe the operation:
| Field | Meaning |
|---|---|
x-returns-secret: true | On /admin/v1 operations only: the success response contains a credential. Keep it out of logs and model context. |
x-returns-content: true | On /admin/v1 operations only: the success response contains prompt/response or page content. Keep it out of logs and model context. |
x-accepts-secret: true | On /admin/v1 operations only: the request body takes a credential. The admin MCP refuses it with secret_input_refused; enter the credential with admin secret set. |
x-accepts-secret-references: true | Only on PUT /admin/v1/routing/references, beside x-accepts-secret: the body takes credential references and every credential value is refused. The admin MCP calls it only when the spec has this mark. |
info.x-anyray-spec-features | Supported spec features. returns-secret marks credential-returning /admin/v1 operations; returns-content marks content-returning ones; accepts-secret marks credential-taking request bodies; accepts-secret-references marks the references-only routing write. An absent marker proves nothing on legacy operations or without its feature. key-grade-ceiling confirms the request header is enforced. connector-token-update means the connector PUT takes the token-only body. |
Every MCP call needs returns-secret and returns-content; writes also need accepts-secret. The
references-only routing write also needs accepts-secret-references.
Each exception is declared on its operation in the OpenAPI spec as
x-required-scope:
| Scope | Gates | Grantable to a key |
|---|---|---|
users:revoke | Revoking or reinstating a developer | no |
users:gdpr | A user's export and erase | yes |
optimizer:erase | Erasing one tenant's context stash at offboarding | yes |
providers:write on GET /admin/v1/providers | A credential-grade read: provider keys come back in full | yes |
playground:run | The admin playground | yes |
fleet:direct | Queuing a directive for a developer seat | no |
settings:content | Content mode and trace retention | no |
settings:update | The self-update policy and manual dispatch (POST /admin/v1/settings/updates/run) | no |
sso:write | IdP and SCIM configuration | no |
admin-keys:write | Minting and revoking aak_ keys | no |
traces:content | Prompt and response bodies: one trace's, or a window export | no |
traces:scrub | Scrubbing connector credentials from stored traces | no |
artifacts:metadata | The organization's list of shared pages, metadata only (GET /admin/v1/artifacts/all) | yes |
How existing capability grants map to scopes
A role or an aak_ key still carries capabilities, and each capability translates to the scopes it
gated on the legacy paths. config:read is every <resource>:read except the audit, trace, and
fleet lanes; observability:read grants fleet:read (seats and directives) and artifacts:metadata; idp:manage grants
fleet:direct (queuing a directive); usercaps:write is users:write; enrollment:manage grants
enrollment:write; provisioning:manage grants provisioning:write. The legacy trust boundaries
between enrolling, provisioning, and directing stay separate.
Three differences are deliberate, each pinned by a test:
- The audit lane (
/admin/v1/audit/*) needsaudit:read, whichconfig:readno longer implies. Auth-rejection events are not in that lane: they record what a developer's tool did rather than what an admin did, so they areGET /admin/v1/users/auth-eventsatusers:read. - Single-route capabilities of one tier fold into one write scope.
routing:writecovers routing config and aliases,policies:writecovers team skills and the Claude Desktop policy, andoptimizer:writecovers settings and purge. - The non-content runtime settings (
anthropicCacheLookback,adaptiveMcpInstructions,subagentSharedContext,servedRetrieval,servedRetrievalClasses,harnessRetrieval,optimizerWireDebit,promptCacheTtl,promptCacheKeepWarm,promptCacheKeepWarmIdleMaxMinutes,promptCacheKeepWarmSubscription,promptCacheRecordedExpiry,claudeCodeRemoteMcp,claudeDesktopRetrievalMcp,toolDefer,ccBudgetNotice, and the deprecatedpromptCache1handpromptCacheKeepWarmSubscriptionIdle) aresettings:write. The prompt-cache fields are held off in this release: a write to any of them answers 400, and reads reportclient,offandfalsewhatever is stored.promptCacheKeepWarmOffSinceis read-only: the gateway stamps it when keep-warm is switched off, and three hours later a session's cache is attested cold by its lifetime alone.promptCacheSiblingWriteAheadSinceis read-only too: the gateway stamps it once, when the first replica that records sub-agent requests before forwarding them starts, and two hours later a sub-agent launch in the transcript stops refusing the attestation on its own. Only a launch that can read the conversation counts: a fork, or one of unknown shape. A launch of a named agent type starts that agent fresh and never refuses it.promptCacheRecordedExpiryChangedAtis read-only: the gateway stamps it with every write ofpromptCacheRecordedExpiry, and a launch keeps refusing until 61 minutes 20 seconds after it. Every other field ofPUT /admin/v1/settings, including content mode, trace retention, the heartbeat tier,storeCapacityBytes,hookStash,hookAmortizationTurns,artifactsEnabledandartifactsOrgShare, stays behindsettings:content.seatOverflow,seatOverflowHourlyMaxRequestsandcodexSeatSourceTrimare retired: a write of any of them answers 400retired_setting. Seat overflow is the subscription fallback policy,fallback.orgonPUT /admin/v1/policies/teams.
anthropicCacheLookback
Defaults to false pending paired session validation. Set it through PUT /admin/v1/settings (or
legacy PUT /admin/settings).
When enabled, native Anthropic API-key Messages requests can use a spare cache breakpoint after a
large tool batch. It adds a 5-minute marker inside the client's cached conversation without editing
tools, system text, or message content. Four-marker requests, extended and scoped caching, custom
hosts, subscription passthrough, and other providers are excluded. x-anyray-optimize: off
bypasses it. Setting it back to false stops annotations, and peer replicas refresh within about
a second. No keep-alive requests are sent.
Tenant and deployment resources
Each operation carries x-scope: tenant or x-scope: deployment. In fleet mode
(ANYRAY_MULTI_TENANT=true) a tenant-bound principal reaches tenant resources only (keys, spend,
optimizer, traces, the GDPR routes, health); a deployment resource answers 403 with
deployment_scope_required. Off fleet mode the level is informational.
Paging the trace list
GET /admin/v1/traces pages two ways:
| Query | Answer |
|---|---|
?page= (default 1) | { "data": [...], "meta": {...}, "next_cursor": ... }. A page that skips more than 10,000 rows answers 400, unless the list names a sessionId. |
?cursor= | The previous page's next_cursor, passed back verbatim. Answers { "data": [...], "next_cursor": ... } with no count, so every page costs the same. |
next_cursor is null on the last page and for every sort except ts, and a cursor with any
other sort answers 400. Keep limit and the filters the same on every page. A list that names
neither fromTimestamp nor sessionId reads the last 7 days.
fields=list returns the console's compact rows, keeping only model, effort, promptTokens, completionTokens, cacheStatus, servedFromCacheTokens, reasoningStartMs and outputStartMs in metadata.
curl "https://<your-anyray-gateway>/admin/v1/traces?fromTimestamp=2026-10-01T00:00:00Z&limit=100" \
-H "Authorization: Bearer $ADMIN_API_KEY"
# Repeat with the answer's next_cursor until it is null:
curl "https://<your-anyray-gateway>/admin/v1/traces?fromTimestamp=2026-10-01T00:00:00Z&limit=100&cursor=$NEXT_CURSOR" \
-H "Authorization: Bearer $ADMIN_API_KEY"
Exporting trace content
GET /admin/v1/traces/export returns the prompt and response bodies of every trace in a time window
as NDJSON, oldest first. It needs traces:content, which security_admin and owner hold and an
aak_ key never does, so script it with ANYRAY_ADMIN_TOKEN. Each export is
audited.
A response holds up to 1,000 traces, one {"type":"trace",…} line each with its sessionId, then
one {"type":"end","traces":…,"next_cursor":…} line. Pass that next_cursor back until it is
null. fromTimestamp is required unless you name a sessionId, and the other trace-list filters
(toTimestamp, users, models) narrow the window. A response that ends without its end line
stopped part-way.
Run this script with ANYRAY_ADMIN_TOKEN set. It writes content.ndjson readable only by you
and exits non-zero if any response is incomplete.
#!/bin/bash
set -u
umask 077
FROM=$1
TO=${2:-}
URL="https://<your-anyray-gateway>/admin/v1/traces/export"
CURSOR=
: > content.ndjson
while true; do
printf 'Authorization: Bearer %s\n' "$ANYRAY_ADMIN_TOKEN" |
curl -fsS -o page.ndjson -H @- --get "$URL" \
--data-urlencode "fromTimestamp=$FROM" \
${TO:+--data-urlencode "toTimestamp=$TO"} \
${CURSOR:+--data-urlencode "cursor=$CURSOR"} ||
{ echo "Request failed: content.ndjson is incomplete" >&2; exit 1; }
END=$(tail -n 1 page.ndjson)
echo "$END" | jq -e '.type == "end"' > /dev/null ||
{ echo "Stopped part-way: content.ndjson is incomplete" >&2; exit 1; }
sed '$d' page.ndjson >> content.ndjson
CURSOR=$(echo "$END" | jq -r '.next_cursor // empty')
[ -n "$CURSOR" ] || break
done
rm -f page.ndjson
bash export-content.sh 2026-09-14T00:00:00Z 2026-10-14T00:00:00Z
A request still running is written when it finishes, so end the window a few minutes in the past for a complete export.
In encrypted mode (the default) the bodies come out as stored: v1: or v3: ciphertext.
Decrypt the file on a machine that holds the content key.
import { createDecipheriv } from 'node:crypto';
import { createInterface } from 'node:readline';
import { gunzipSync } from 'node:zlib';
const key = Buffer.from(process.env.ANYRAY_CONTENT_KEY ?? '', 'hex');
if (key.length !== 32) {
console.error('Set ANYRAY_CONTENT_KEY to the deployment content key (64 hex characters).');
process.exit(1);
}
// v1 is AES-256-GCM, v3 the same over gzip. Anything else is left as it is.
const open = (value) => {
if (typeof value !== 'string') return value;
const [version, iv, tag, data, extra] = value.split(':');
if ((version !== 'v1' && version !== 'v3') || data === undefined || extra !== undefined) return value;
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'), { authTagLength: 16 });
decipher.setAuthTag(Buffer.from(tag, 'base64'));
const body = Buffer.concat([decipher.update(Buffer.from(data, 'base64')), decipher.final()]);
const text = (version === 'v3' ? gunzipSync(body) : body).toString('utf8');
try {
return JSON.parse(text);
} catch {
return text;
}
};
for await (const line of createInterface({ input: process.stdin })) {
if (!line) continue;
const row = JSON.parse(line);
if (row.type === 'trace') {
row.input = open(row.input);
row.output = open(row.output);
for (const o of row.observations ?? []) {
o.input = open(o.input);
o.output = open(o.output);
for (const d of o.output?.decisions ?? []) {
d.before = open(d.before);
d.after = open(d.after);
}
}
}
console.log(JSON.stringify(row));
}
Keep the key off the command line. The output is readable prompts and responses outside Anyray's retention, erase and audit trail: keep it on a machine you trust, and delete it when you are done.
node decrypt-export.mjs < content.ndjson > content-decrypted.ndjson
Errors
Every error uses one envelope, whatever the status:
{
"error": {
"code": "insufficient_scope",
"message": "This operation requires the 'audit:read' scope, which this API key was not granted.",
"required_scope": "audit:read",
"request_id": "8c2f0b1e-synthetic-request-id"
}
}
code is stable: unauthenticated, insufficient_scope, deployment_scope_required,
invalid_request, not_found, conflict, rate_limited, unavailable, plus legacy reason codes
such as admin_key_store_unavailable and session_store_unavailable. 401 means the credential is missing or bad. 403 means it
is fine but lacks the scope, and names it. request_id repeats the x-request-id response header;
send your own identifier in that request header and it is echoed back.
curl -X PATCH https://<your-anyray-gateway>/admin/v1/users/alice/limits \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "maxUsd": 250 }'
The resource-by-resource map, with the legacy twin of every v1 path, is on the
endpoint reference. Every v1 operation in the spec also
names its twin in x-legacy-operation.
Routing reads (GET /admin/routing-config and GET /admin/v1/routing) mask inline provider
credentials as {"$anyrayCredentialRef":"<64 lowercase hex HMAC>"}. The response also includes
inlineCredentials: {present, targets}, a presence flag and the number of nodes holding inline
credentials. Stored config and request dispatch stay unchanged. Guardrail check credentials (fields
under a check's credentials, a sink's headers, every URL a check sends data to such as
webhookURL, a top-level apiKey, and any address that embeds userinfo, a query or a fragment) are
masked the same way. When the store cannot be read, a read serves the gateway's cached copy and
says so with fromCache: true; reload before acting on it.
Keep these objects unchanged when saving. A successful PUT replies with the config as stored, read
like a GET, so that reply saves back unchanged. If that read fails, the save still happened and
the reply is 503 routing_config_saved_unreadable; reload before saving again. A reference resolves
against the authoritative stored config. The receiving node and its ancestors must keep the same raw
fields, spellings and value types, except weight and request_timeout. Inherited credential uses
stay bound. You can remove targets and reorder siblings; siblings without references can be edited
independently. An absent or empty root api_key can also be represented by a reference; saving
restores its original state. That root object makes previous releases reject a projected config
rather than store references as credentials. Saves normalize host spellings in subtrees without
references while keeping referenced nodes and their ancestors' own fields raw. A save that would
change or disappear on reload returns 400 routing_config_load_mismatch without changing the stored
config.
Add new credentials in Provider keys, then select provider_key_id.
Routing saves return 400 inline_credentials_refused for new literal credentials,
400 routing_sealed_value_refused for a sealed value (a string starting rsec:v<n>:, or an
anyray_sealed object; only the gateway writes those),
400 routing_credential_reference_invalid for unknown or stale references, and
400 routing_credential_address_changed when a reference would send credentials somewhere new.
A guardrail reference stays on its unchanged check, in the same guardrail field and spelling (same
plugin, same parameters in the same key order, except is_enabled); otherwise the save returns
400 guardrail_credential_check_changed. A reference where the read put none returns
400 routing_credential_reference_invalid.
Conflicting spellings of any field on a provider or group node return
400 routing_field_alias_conflict; keep one spelling. Existing equal duplicates round trip unchanged.
A referenced node or its ancestor cannot introduce a second spelling, even with an equal value.
Without a private admin token, references and revisions use a random server-held key in the shared
spend database (a file under ANYRAY_DATA_DIR for the single-replica, no-database fallback).
The gateway warms that key at boot. If it cannot be loaded, routing API reads and saves return
503 routing_credential_key_unavailable without a revision. Requests still use the cached routing
config. Restore the store and reload before saving.
Schema and depth checks precede credential resolution. Revisions are opaque keyed tokens: send expectedRevision
unchanged. A stale one returns 409 routing_config_conflict even when a referenced credential or a
pinned key changed since. A 409, including across mixed releases, means reload and reapply the
change. Without a database, an unreadable routing file returns 500 routing_config_unreadable on
GET and on any save with expectedRevision, and the file is left as it is.
Routing saves with references only
PUT /admin/v1/routing/references (routing:write) runs the same save as PUT /admin/v1/routing
with tighter rules. It is the route the admin MCP saves routing through, and it holds for every
role, the owner included. Nothing is stored when a rule refuses.
| Refusal | When |
|---|---|
400 routing_credential_literal_refused | Any credential value where a read masks one: inline provider fields, custom_body, and guardrail credentials, headers and URLs, at every depth. Checked first, before the stored config is read. |
400 routing_field_alias_conflict | Two spellings of one field anywhere in the body (such as input_guardrails and inputGuardrails) that the gateway would read differently. Keep one spelling. |
400 routing_expected_revision_required | The body is not { config, expectedRevision }. Send the revision from GET /admin/v1/routing. |
403 routing_outbound_host_refused | A provider request sent somewhere, or with options, the stored config does not use with that key choice: a new custom host or endpoint map, a new resource, bucket, region or project, or a provider option such as openai_project, openai_organization or anthropic_beta. A target that only picks a provider and key (provider_key_id) is fine: it goes to the provider's own destination or the key's own host. |
403 routing_guardrail_address_refused | A check that can send content out must be an exact copy of a stored check at the same node and hook field, with the same hook settings (such as async), and no more copies than are stored. That covers its credentials, settings and is_enabled. Each keeps its place in the order checks run, and a shorthand check keeps its key. A stored check that rewrites content, such as default.regexReplace or default.addPrefix, also cannot be removed or changed at its node. A save that routes requests around it, such as a weight of 0 or a branch that never matches, is not prevented yet. Local verdict checks such as default.regexMatch or default.wordCount are fine. |
403 routing_override_params_refused | override_params adds or changes more than sampling settings (temperature, top_p, top_k, max_tokens, max_completion_tokens, frequency_penalty, presence_penalty, seed, stop) or a stored node's value for the same key and destination (provider, host and provider options). model is not a sampling setting. Make that change in the console Routing page. |
403 routing_retry_status_refused | retry.on_status_codes or a fallback strategy.on_status_codes lists anything but error statuses (400 and above), or lists 446. A success status there, or the 446 an output check returns for an answer the provider already billed, sends every answered request again. Make that change in the console Routing page. |
So a save here keeps every stored credential where it was. It cannot add a credential, or send a
stored key or request content anywhere routing does not already send it. Make those changes in the
console. During an upgrade, a replica from an older release answers this route 404 and stores
nothing.
Per-session strategy gate
| Method | Path | Scope | Does |
|---|---|---|---|
| get | /admin/v1/optimizer/session-gate | optimizer:read | Returns the tenant's tenant, evaluator settings, strategies, and toolUnits. |
| put | /admin/v1/optimizer/session-gate/{kind} | optimizer:write | Takes {"override":"force_on"}, {"override":"force_off"}, or {"override":null}. {kind} may be a per-tool unit. |
| get | /admin/v1/optimizer/session-gate/{kind}/sessions | optimizer:read | Takes policy, days and limit. Returns the unit's arm-labelled session tuples, summary statistics over all of them, sessionsPerArm, truncated, and its last 20 evaluations. Names no user or session. |
Legacy twins: /admin/optimizer/session-gate and …/session-gate/{kind}; legacy read capability
config:read. A deployment owner may select ?tenant=synthetic-team, but a tenant-bound principal
cannot widen its scope that way. A concurrent change returns 409. State and audit commit together
in Postgres.
The strategy record
| Field | Values |
|---|---|
kind | The strategy id, or a lane: connect_hook or tool_defer. Both read and override like a strategy. |
state | Durable off, shadow, or on. |
effectiveState | state with any override applied. It has no expiry. |
stateLabel | off, shadow, on:grandfathered, on:promoted, on:forced, off:forced. |
origin | grandfathered or new. Only cohort capture sets it. |
reason | existing_spend, fresh_deployment, new_tenant, promoted, measured_loss, consecutive_losses, recovered, operator_override, operator_cleared, legacy_loss_reverted. |
evidenceStatus | available, session_gate_disabled, withheld_unmeasured, no_spend_store, store_loading, store_unavailable. Both session_gate_disabled and withheld_unmeasured mean the tenant's measurement draw is 0; withheld_unmeasured is a strategy with no override that is not on, so it can never run or be promoted. Older responses may say holdout_disabled. |
lastEvaluation | null before the first check. Carries evaluatedAt, from, to, policyKey, qualityVerdict, metrics, inputTokensRatio, pairedSession, the settings used, passed, and decision (the epoch's progress and its lastLook, with each test's p, level and rejected). imbalancedEvaluations appears only on evaluations the retired paired rule wrote. |
| Also present | enabled, override, failedEvaluations, revision, updatedAt, evidenceSince, policyKey. |
Per-tool units
A strategy can pay on one client tool and lose on another. When a strategy's paired sessions split
across at least two client tools, each with enough sessions to judge alone, the gate adds a unit per
tool, named {kind}__{tool} with dashes as underscores (columnar_fold__claude_code). A new unit
starts in the strategy's own state and is then judged on that tool's sessions only. A session keeps the tool it
started with, so a turn that reports another tool cannot switch a strategy mid-session.
toolUnits lists the units the gate holds, as strategy records with two more fields: strategy
(the kind) and tool. A unit decides its tool's sessions. Either override on the strategy's own
record decides for every tool.
Clearing an override restores grandfathered tenants to on:grandfathered and new tenants to shadow,
with a fresh evidence cutoff. force_on bypasses statistical approval, but strategy configuration
and lane guards still apply.
Paired session fields
GET /admin/spend/quality-parity?cohortKind=thinking_trim, and its
/admin/v1/spend/quality-parity twin, add tenant (null for an owner-wide query) and
pairedSession (null without cohortKind). Existing metrics and grossCostObjective remain. The
session gate no longer decides on these fields; they stay for display.
pairedSession field | Meaning |
|---|---|
method | same_shape_without_replacement_v1 |
sessionsPerArm.holdout, .treated | Observed sessions in each arm |
pairs, wins, ties | Distinct matched pairs; strictly cheaper treated sessions win |
winRate, winRateWilson95.lo, .hi | Wins divided by matched pairs, with a two-sided 95% Wilson interval; null without observations |
costRatio.median, .q3, .max | Nearest-rank treated/control cost ratios; null if absent or unbounded |
unpairedTreated, unusedControls | Sessions with no remaining same-shape partner |
unpairedTreatedCostShare | Unmatched treated cost divided by total treated cost; null when total cost is zero |
unboundedRatios | Matched positive cost against a zero-cost control |
excludedMixedSessions | Sessions observed in conflicting arms or policies |
invalidCostSessions | Excluded sessions with missing or invalid usage on a successful provider turn |
excludedLongSessions | Excluded labels spanning UTC dates or more than 200 observed turns |
excludedLongSessionShare | Long-session exclusions divided by classified candidate sessions, excluding error-only sessions |
errorOnlySessions | Sessions whose every turn has status >= 400; excluded from arm counts and pairs |
Evaluator settings
Set config.sessionGate in the optimizer settings JSON through
PUT /admin/v1/optimizer (legacy /admin/optimizer/settings). No
environment variable is needed. The measurement fraction is per tenant; the other settings govern
the evaluator across tenants.
| Field | Default |
|---|---|
evaluatorEnabled | true; false pauses evaluations and evidence resets |
measurementFraction | Unset: sized on the tenant's session volume for a tenant seeded new (0.01 to 0.065, 0.05 under 7 days of history), 0 otherwise. Per-kind size of each control and treated arm, maximum 0.5; an explicit 0 turns the draw off. The response reports the draw the tenant runs. |
evaluationIntervalSeconds | 3600 |
windowSeconds | 2592000 (30 days); the longest one evidence epoch runs |
failedEvaluationsBeforeOff | 3 |
alpha | 0.05; may only be lowered |
alphaSpending | information; look_count restores the per-look schedule |
minSessionsPerArm, winRateFloor, maxQ3Ratio, maxUnpairedTreatedCostShare and
maxExcludedLongSessionShare are retired. Stored values still validate and are ignored.
Durable approvals never expire, and explicit overrides stay until cleared. Pausing evaluation stops state changes while existing gate permissions keep applying.
When a strategy is promoted or demoted
The gate compares whole sessions in the two arms. Each session gets a cost score: its list-price cost, adjusted for the size of the request the gateway sent the optimizer on the session's first turn. That size is measured before any strategy runs, so a strategy that inflates prompts cannot inflate its own adjustment. Sessions over 200 turns, crossing a UTC date, failing every turn or carrying unpriceable usage rank as the most expensive outcome, so pushing sessions out of the sample cannot make a strategy look cheap. Only sessions labelled by the per-kind draw count.
| Test | A loss when the treated arm |
|---|---|
cost | ranks higher on cost score |
cost_tail | is over-represented above the pooled cost-score Q3 |
errored_sessions | has more sessions with a failed turn |
turns | ranks higher on turns per session |
output | ranks lower on output tokens per successful turn |
An epoch spends alpha by information. t is the sessions the epoch holds over the sessions the
tenant's traffic will hold by windowSeconds, projecting the rest of the window at the rate since
the last look (at least half the epoch's average rate). At t the epoch has spent
2(1 - Φ(z/√t)), scaled to reach alpha at t = 1 (O'Brien-Fleming type). The first hours spend
almost nothing and the last look runs near alpha. Each look spends what that adds since the
last look that could reject. With alphaSpending: look_count, look k spends alpha / (k(k+1))
instead. An epoch keeps the schedule it started spending under. A look's budget goes only to
verdicts that can change the state: a loss for on, a win for off, and half each for shadow. The
loss tests share their part by Tarone's
procedure, so only tests that can reach significance at that sample size take a share. Rank tests
are exact for small arms. Any rejection is a loss. A win needs cost to rank lower within the win's
part, no more treated sessions above the Q3 than chance predicts, and no loss. Anything else is
inconclusive and changes nothing.
Evidence builds up from the start of an epoch. A look falls due one evaluationIntervalSeconds in,
again each time the epoch's age doubles, and a last time at windowSeconds, when a fresh epoch
starts. The budgets sum to alpha, so a strategy that does nothing changes state in at most that
share of epochs. Evaluations between looks read nothing.
A loss moves on to shadow. failedEvaluationsBeforeOff losses with no win between them reach off.
Every win or loss starts a new epoch.
Automatic off keeps one bounded recovery arm: it runs only in sessions that select it with the
treated arm. A win returns off to shadow with reason recovered, and a later win may promote.
force_off disables recovery sampling.
A strategy demoted by the retired paired rule is judged again over a full window. Once that window
holds enough sessions to test, no significant loss returns it to its seed state with reason
legacy_loss_reverted, and a significant loss keeps the demotion. Until then it stays demoted.
How sessions are paired
The paired read is for display; decisions use the tests above. A session key is the tenant, the
attributed user, and the store's clientSessionId. Sessions group
by client tool, then match on task size inside that group: each arm is ordered by the entry turn's
size (promptTokens + tokensSaved), and evenly spaced positions through each arm are paired off.
Both samples therefore span their own full size range, so an unbalanced arm never compares treatment
against systematically smaller control tasks. Pairing never sorts by cost, crosses tenants, or
reuses a control.
sessionGate.measurementFraction (unset: sized on traffic for a tenant seeded new, else 0; capped at
0.5) is the per-kind measurement rate.
Each configured kind draws equal, disjoint holdout and treated slices of that size. A
session-level selector then measures at most one kind, so no session controls two strategies.
Selection competes among a session's candidates, so each kind's realized rate lands below
sessionGate.measurementFraction. Only gateway-derived conversation IDs qualify; static
metadata.session and metadata.sessionId labels do not. Subagents inherit the parent
conversation's draw.
Cost uses costUsdFrom and official list prices. Failed requests and cache hits (the response or
semantic cache) cost zero, and telemetry rows are not turns. Mixed-arm sessions are dropped from
both the pairs and the gate's sample, and counted.
Cohort capture and policy keys
On its first successful store connection, the gate captures tenants with pre-upgrade spend as
grandfathered, and every byte-changing kind starts on for them. A capture that finds no spend marks
the deployment fresh (fresh_deployment), and its tenants also start on. Where there was spend at
capture, later tenants start shadow. The cohort is immutable.
Each kind's policyKey fingerprints the assignment epoch, that kind's merged tenant parameters, and
registry flags. Changing those parameters clears the kind's evaluation and restarts its evidence
window, preserving its state. An epoch change resets every evidence window and preserves every
durable state. Without a readable permission snapshot or the metadata-only boot cache
(sessionGateOrigins.json under ANYRAY_DATA_DIR), unknown tenants withhold byte-changing
strategies (session_gate_unavailable).
Request headers
Beyond Authorization and Content-Type, the gateway reads a few x-anyray-* headers:
Provider to route to: openai, anthropic, vertex-ai, bedrock, azure-openai, … Optional
when a default provider or routing config is set server-side.
Attribution, e.g. {"user":"alice","team":"platform"}. Drives spend attribution and per-user caps.
Never carries prompt/response content.
Per-request routing: fallback list, load-balance weights, retry policy. No credentials; they resolve from the server-held key store.
Response headers
Every response carries x-anyray-provider (the provider the request was routed to).
x-anyray-trace-id is the id of the request's trace; read it with GET /admin/v1/traces/{id} once
written. It is absent when the request records no trace (no trace store, or a token count only).
When the provider returns a 5xx, such as an Anthropic 529 overloaded_error, the gateway adds
the markers below. The status and the error type are forwarded unchanged; only the human-readable
error.message gains a sentence naming the provider:
{
"type": "error",
"error": {
"type": "overloaded_error",
"message": "Overloaded — this error was returned by Anthropic (api.anthropic.com) and relayed unchanged by the Anyray gateway; it is not an Anyray gateway failure. Check https://status.claude.ai/ for provider status."
}
}
Present and set to upstream when the error originated at the provider, not the gateway. Absent on
success and on gateway-generated errors.
The upstream provider that returned the error: anthropic, openai, vertex-ai, … (omitted when the
provider is unknown).
The provider's own HTTP status (e.g. 529, 503).
No x-anyray-error-source: upstream header on a 5xx means the gateway itself failed, so check your
deployment. With it, the fault is on the provider's side, and retrying shortly usually clears it.
Endpoints
/v1/* serves an OpenAI-compatible inference API plus the Anthropic-native Messages API. /admin/*
is the admin-gated configuration and reporting API. /sso/cli/* is the pre-key self-service login
handshake Connect uses. /scim/v2/* is an inbound SCIM 2.0 server. The complete
capability-annotated index is the Endpoint reference; full schemas are in the
OpenAPI spec.
POST /connect/optimize-output also accepts Claude's native PostToolUse HTTP hook envelope. It
returns {} to preserve the original tool result while session retrieval is unverified. Command
hooks keep using the Connect envelope.
Chat completions, embeddings, and the Anthropic Messages API work across all supported providers.
Image, audio, Responses, file, and Batch endpoints are OpenAI / Azure-only passthrough (audio
also works on groq).
On POST /v1/chat/completions and POST
/v1/messages, an API-key request that declares tools but no retrieval tool goes through unchanged
by default. Once an admin opts its class in (servedRetrievalClasses below), the gateway may
resolve its anyray_retrieve calls itself (up to 3 rounds) and return the model's final answer. A
streaming request keeps streaming: the gateway holds back only the tail it must decide on. The spend row sums usage from every upstream round. The rounds the gateway added
cost, and come off the row's savings, which never go below zero. See servedRetrieval below.
Retrieval via MCP: POST /mcp
The gateway is a remote MCP server (streamable HTTP). It serves anyray_retrieve and
anyray_recall behind the same client key as every other route, so an MCP-capable agent runtime
closes the retrieval loop with one server entry and no code:
{
"type": "http",
"url": "https://<your-anyray-gateway>/mcp",
"headers": { "x-anyray-api-key": "<client key>" }
}
Claude Desktop on a person's key also gets the two shared-page tools.
/mcp, not /v1/mcp/v1/mcp is not a route: an MCP client's POST there answers 404 with a hint that lists the
main inference routes. Point the client at /mcp on the gateway origin.
The wire rules:
- One JSON-RPC 2.0 message per POST:
initialize,ping,tools/list,tools/call. - Notifications answer
202with no body. Batch arrays are rejected.GET /mcpandDELETE /mcpare405: the gateway keeps no MCP session to stream or end. initializereturns anMcp-Session-Id, and every call still authenticates independently.- Successful initialization and other valid messages renew the presenting key's 6-hour retrieval lease, the same evidence the stdio server's heartbeat gives. Rejected initialization claims no capability.
- Tool failures come back in-band (
isError: true, with"unknown or expired handle"or"retrieval unavailable"), never as protocol errors, so the model can read them and adapt.
A client that declares the two tools keeps them on the upstream wire from a session's first request,
unless that request sends x-anyray-retrieval-tools: lazy, the session already has a recorded
request, or it is a Claude Code thread or an app tool loop
(details). The lane counts as
retrieve-capable from the first request either way, so reversible strategies run as before. In an
app tool loop, harnessRetrieval (on by default) decides both: while it is
off, the tools never reach the model and the lane has no read path.
Hosted connectors (Anthropic mcp_servers, OpenAI Responses type:"mcp") may point here and count
as retrieve evidence when they target this gateway's own /mcp. First-party provider APIs only (not
Bedrock or Vertex), and the endpoint must be reachable from the provider's cloud.
servedRetrieval
servedRetrieval defaults to true. It is the master switch for gateway-served retrieval, which
arms only for the evidence classes in servedRetrievalClasses (none by default). For an armed
API-key request that declares tools but carries no retrieval tool, the gateway serves
anyray_retrieve and anyray_recall after the first elider marker so the reversible eliders can
run. Set it to false through PUT /admin/v1/settings (or legacy PUT /admin/settings) to turn
every class off at once; on a warm session that rewrites the cached prefix once. Requests with
extended thinking enabled are not served: a thinking signature would bind to rounds the client
never sees.
servedRetrievalClasses
servedRetrievalClasses defaults to [], so no request is served and a tool loop goes through
unchanged. It lists the evidence classes where served retrieval may arm. Each class is its own
opt-in:
| Class | The client | Wire |
|---|---|---|
unlatched_deferred | Claude Code with a deferred tool catalog and no Anyray MCP server | /v1/messages |
unapproved_client | Codex 0.153 or later whose Anyray MCP tool was never approved | /v1/responses over HTTP |
no_discovery | A client with its own tools and no read path, such as an agent SDK loop | /v1/chat/completions, /v1/messages, and /v1/responses over HTTP |
Set it with PUT /admin/v1/settings, for example
{"servedRetrievalClasses": ["unapproved_client"]}. servedRetrieval must also be true, and
every exclusion above still applies. On /v1/responses the request must send store: false (Codex
always does; an agent SDK has to set it). The gateway also stands aside for a tool_choice other than auto and for
background: true, and never serves Codex's WebSocket transport. An opted-in session changes its
tools block once, on the first turn that carries a marker, and keeps the tools if the client later
approves or registers its own retrieval path.
Opt a class in only after a paired comparison against pass-through on that session shape clears.
no_discovery on the two chat wires failed one: on a LangGraph tool loop, the served rounds
re-sent the whole context and cost more than the trims saved.
harnessRetrieval
harnessRetrieval defaults to true. It decides whether the gateway trims tool output in an app
tool loop: a request whose x-anyray-metadata tool is sdk-agent, langchain or langgraph
and that declares anyray_retrieve itself, as the Add agent prompts wire it. Other clients are
unaffected, and the gateway never serves retrieval to these requests.
While it is true, the gateway may trim such a request's tool output behind a ctx_ handle the
model reads back with the declared anyray_retrieve, and /connect/self-check reports the
runtime's verdict as wired. In a new session the declared tools reach the model only from its
first handle on, so a loop where nothing is trimmed does not pay for them.
Turn it off in Console → Optimizer → Harness retrieval, or set it to false through
PUT /admin/v1/settings (or legacy PUT /admin/settings). Such a request then has no read path.
Strategies that need the model to read trimmed content back stand aside, and the gateway removes
the declared anyray_retrieve and anyray_recall tools from the upstream request on every path,
even when the optimizer skips or fails open. The verdict reads parity, with
readPath: "declared": the loop's own read path is ready for when the setting is on.
The upgrade that made true the default also switched it on where an admin had set false. Set
it to false again after upgrading and it stays off.
One app can choose for itself by sending the header x-anyray-harness-retrieval: on or off on
every request. It wins over the setting for that app. With on the gateway stops removing the
declared tools and records the request as direct, and the session sends them upstream from its
first ctx_ handle on. In such a session the first
value sent holds for every later request that carries the header, so changing the value later does
not rewrite its tools. Two cases fall outside that: a request without a valid header uses the
setting, and a request the gateway cannot check against the held value in time (a store outage)
uses its own header. Either can change the tools and re-read the cached prompt at full price, so
send the same value on every request and don't count on a changed value being ignored.
Changing it rewrites the tools block of a live app session at most once, and that request re-reads the cached prompt at full price. Turning it on, a session that was already running gets the tools back at its first trimmed turn. Replicas pick up a change within about a second. Sessions that load tools through tool search are never gated.
hookStash
hookStash defaults to false. When on, a Claude Code hook digest keeps its full output in the
gateway's encrypted context stash (48 hours) instead of a local hook-tee file, and its footer names
a ctx_ handle for anyray_retrieve. Only a seat with fresh evidence it calls anyray_retrieve
gets a handle; every other seat keeps its local file, as does every seat while content mode is
off. It sends command output to the gateway, so it needs settings:content. Turning it off sends
new digests back to the local file; issued handles keep working until they expire.
artifactsEnabled
artifactsEnabled defaults to true. When on, people can publish pages from a Claude session
and share them inside the organization (POST /connect/artifacts). Pages are stored encrypted in
the trace store's database, so it needs settings:content, a content key, and a content mode
other than off. Only a stored false turns it off; a stored value that is not a boolean reads
as off too. Upgrading turns it on once for every deployment, including one that stored the old
default false; an admin who turns it off afterwards keeps it off. Turning it off, or setting
the content mode to off, stops new pages. Pages already shared stay until someone deletes them or a GDPR erase removes their owner.
Each seat reads artifacts.publish on GET /connect/policy to learn whether it can publish now,
and a publish answers with url, the page's link in the console. See
the connect contract.
hookAmortizationTurns
hookAmortizationTurns defaults to 100, which turns amortization on. Store 0 to opt out
and credit only the cache write. A hook-lane trim (a command digest or trimmed tool output) is
credited the cache write it avoided, once, and above 0 also the cache read of its bytes on
later requests, at the model's read price. The horizon is the deployment's measured session
curve for that client tool, read at the request the hook fired on (the hook sends
requestsSoFar): the later requests three sessions in four alive at that request still made,
after the one that carries the trimmed output. A hook that names no request takes the
request-weighted lower quartile over every request position of those lives. A deployment with
no curve yet prices on the optimizer's own prior at that request, a third of the requests so
far (at least 3 from the third request on); a curve with fewer than 100 sessions credits its
floor. The read credit is capped at this setting, which is a ceiling, not the credit itself.
The per-user hourly budget has a billed-value ceiling that does not rise with the setting. The
claimed-token cap and billed-value cap can each refuse: the claim check can refuse before
pricing, and the final charge rechecks both caps. A curve needs 100 session observations for the
client to use its quartile. The main tool curve can build from 40; a valid curve with fewer than
100 credits its floor. Subagent curves start at 100 observations. When both main-thread and
subagent curves have valid quartiles, the lower credit wins. Claude Code without a valid subagent
curve uses the main curve's floor. The credit is write-once, with no read share, when the
stored setting is 0 or a replica cannot read it, when the hook names no request and there is
no main curve or trace store, when the curve is corrupt, and for a seat whose model the gateway
has not seen. Changing it moves
hook rows written from then on, their limit units and dollars alike; nothing already written is
restated. The one exception is the upgrade that turned the credit on: rows written before it are
restated once, in limit units only, unless the stored value is 0. It raises a billed figure, so
it needs settings:content.
codexSeatAppsDefer
codexSeatAppsDefer defaults to true. While on, the gateway defers the ChatGPT app connectors
(mcp__codex_apps__*) in every Codex ChatGPT seat request behind OpenAI's hosted tool search, on
the WebSocket and HTTPS alike. Each turn reads only the connectors' names and descriptions instead
of their full schemas (about 25k tokens on a typical seat), and the model loads a connector when
it needs one, so every connector stays usable. Requests for models before gpt-5.4 go as sent.
Set it to false to send the full schemas on every turn. Changing it costs each live Codex session
one cache re-write.
claudeCodeRemoteMcp
claudeCodeRemoteMcp defaults to false. When on, GET /connect/policy carries
retrievalMcp: { url: "https://<gateway>/mcp" }, offering the gateway's MCP server to Claude
Code seats in place of connect's local stdio one. The field is withheld while content mode is
off (only the stdio server can read a local-transcript handle) and when
ANYRAY_GATEWAY_PUBLIC_URL is not set. Claude Code reads its MCP servers at session start, so a
running session keeps its transport. If content mode turns off under a session already on
/mcp, that session gets no reversible trims until it restarts on the stdio server.
The remote server serves anyray_retrieve and anyray_recall only. Seats lose anyray_read_batch
and anyray_history, the hook-tee id route in the retrieve description, and the stdio server's MCP
instructions, so the tool list in every Claude Code prefix changes. Before turning it on, run a
paired comparison against the stdio transport on that deployment's session shape. Judge the tail,
not the mean: the win rate must sit clearly above the pass-through noise floor, the third quartile
of per-round paired cost ratios must stay below 1, and the maximum cost, the long-tail turn counts
and retrieve re-read loops (repeated anyray_retrieve calls for the same handle) must not grow.
Once a seat has switched to /mcp, its hook stops handing out local hook-tee handles (digest
and ranged footers, green-collapse handles) to any session that has not retrieved
through the stdio server since the switch, because /mcp cannot open them. This includes a
resumed session. Those outputs pass through unchanged, or take a gateway ctx_ handle when
hookStash is on. Include that lost trimming in the comparison. A missing field sends the seat
back to stdio only after two policy pulls in a row leave it out. The seat also stays on stdio when
another MCP server already uses the name anyray-retrieval, or when the URL is not on the seat's
own gateway. Tool approvals for the transport a seat leaves stay in place for sessions still
running on it.
Each switch, either way, costs a resumed session one full prompt-cache write, because its tool
list changes. Also check two things only the remote server can do. First, it can re-initialize
mid-session, for example during a rolling deploy. If its instructions or tool text change when
it does, the session's cached prefix is lost. Second, a session whose /mcp connection failed at
launch has no retrieve tool, yet it can still be handed gateway ctx_ handles.
The remote entry carries the seat's key as a static header for the whole session. Claude Code
2.1.282 ran headersHelper once per connection and did not re-run it after a 401, so a helper
would not help. Refresh extends the same key. A new key is issued only on re-enrollment or when the
local key is lost; connect then writes it into the entry for new sessions, and the old key stays
valid until its own expiry. A session that outlives that expiry, or whose key an admin revokes,
can no longer retrieve. Check for that in the comparison too.
personalUpstream
personalUpstream is { "url": "https://litellm.example.com" }, or null (the default) for off.
The URL is saved as its origin: a path such as /v1 is dropped, and a query, a fragment, a
user name, cloud-metadata hosts and Anyray's own hosts are refused with 400. While set, a request
that names that exact origin as its custom host is allowed when it runs in passthrough mode and
carries its own Authorization. The org's stored key is never sent there. A private address is
allowed, since the admin named it. GET /connect/policy serves the URL to personal developer
keys.
Changing it needs providerkeys:manage and the deployment owner on top of content:manage,
and the settings:content scope on /admin/v1/settings. A body without the permission is
refused with 403 before any field in it is applied.
claudeDesktopRetrievalMcp
claudeDesktopRetrievalMcp defaults to true. While on, the Claude Desktop bootstrap that MDM
devices fetch carries an anyray element in managedMcpServers pointing at the gateway's /mcp
(what it carries), so a managed
Mac gets anyray_retrieve and anyray_recall, plus the shared-page tools on a person's key. Set it to false to drop
the element from the next bootstrap fetch. Claude Desktop applies the change at its next launch or
policy poll. The element is also left out while content mode is off.
optimizerWireDebit
optimizerWireDebit defaults to false. When on, the gateway withholds a session's measured round
trip to the optimizer (phase_gateway_wire, the median of its last six measurements in 100ms steps,
cap 400ms, and never more than half the hook budget) from the hookBudgetMs it advertises. The optimizer then stops admitting strategies before the
gateway's abort fires, instead of timing out and forwarding the original bytes. The gateway itself
waits exactly as long as before. A measured session whose debit is above zero, below the cap and withheld whole
may let its stash writes run to the write allowance instead of stopping at 300 ms. A session's
first six measurements set its debit at once. After that the debit moves only when five
consecutive medians agree on a new value, so it can lag the
latest median by a few turns, and two optimizer replicas of unequal speed cannot flap it from turn
to turn. Sessions whose round trip stays under 100ms keep a zero debit. Turn it on per deployment
through PUT /admin/v1/settings once the paired gate clears on that session shape.
promptCacheRecordedExpiry
promptCacheRecordedExpiry defaults to false, pending paired session validation. It decides how
the gateway tells the optimizer that a pass-through Claude Code session's prompt cache has expired
(promptCacheExpired in the optimizer protocol).
Off, the gateway judges against the client's own markers and, on a pinged session, waits out the
longest a ping could have kept the cache alive (44 minutes after a finished turn under a 30-minute
idle cap). On, the session row answers: it stores the lifetime the gateway sent (5m on a session
the 5m setting or a 5m header lowered from 1h), counts every keep-warm ping before the ping
is sent, and notes each request of the session's sub-agents. The cache is expired when no turn,
ping or sub-agent request touched it for that lifetime.
An expired turn lets observation_mask, relevance_filter and thinking_trim rewrite the
cached prefix at no premium, so turning this on changes what the model sees. Turn it on per
deployment once a run paired against pass-through clears on long sessions with idle gaps:
curl -X PUT https://<your-anyray-gateway>/admin/v1/settings \
-H "Authorization: Bearer $ANYRAY_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"promptCacheRecordedExpiry": true}'
Send false to go back. Replicas follow within about a second, and one that has not read its settings
yet reads this as off. Keep-warm refreshes already scheduled when you turn it off stay recorded,
and the session row can still hold them back until the session's next request.
adaptiveMcpInstructions and session IDs
adaptiveMcpInstructions defaults to true. In this mode initialize always omits the
instructions key, and recovery guidance lives in the retrieval tool descriptions, which reach the
model with the first elision.
The signed Mcp-Session-Id holds only a decision and a random nonce. It binds to the authenticated
identity and grants no access. Reuse it to keep the decision through a settings change. Invalid or
foreign IDs return 404 on initialization, and admin-token rotation also invalidates IDs.
Set the field to false through PUT /admin/v1/settings to restore the legacy instruction text on
new MCP sessions; peers refresh within about a second. Apply the switch between conversations, since it
changes the prefix of a continuing one. A cold replica that cannot read the setting answers 503
with Retry-After: 30 rather than guessing. Older gateway versions always announce instructions, so
reconnecting a warm conversation during a mixed-version upgrade can change its prefix.
GET /admin/v1/health reports process-local mcpInstructions handshake counters by the closed
reasons adaptive_omitted and runtime_disabled. They carry no identity or content.
Retrieval: POST /connect/retrieve
An agent you build yourself needs a read path, or optimization runs one way only. Declare a tool
named anyray_retrieve and implement it against this route with the client key you already
send. Three independent requirements:
| Requirement | What proves it |
|---|---|
| Declaration | The model can select the tool this turn. Matched by name (anyray_retrieve, or a namespaced mcp__…__anyray_retrieve), so renaming it silently turns retrieval off. |
| Implementation | The route call that resolves a handle. |
| Liveness | An authenticated empty POST /connect/mcp-heartbeat at worker start and every 5 minutes while the worker can execute tools. |
Without both a callable path and a fresh lease, the gateway keeps clientCanRetrieve false.
Retrieval-dependent strategies stand down, and degradable ones keep their handle-free mode.
curl -X POST https://<your-anyray-gateway>/connect/retrieve \
-H "x-anyray-api-key: $ANYRAY_CLIENT_KEY" \
-H "Content-Type: application/json" \
-d '{"handle":"ctx_abc123"}'
The ctx_… handle from an [anyray: … retrieve ctx_…] marker in the message the model received.
Read back a slice instead of the whole original.
Return matching lines with context. Max 256 characters.
Answers {"status":"ok","content":"…","handle":"ctx_…"}. An unknown or expired handle is 404, an
unavailable retrieval store is 503, and a missing or invalid key is 401. POST /connect/recall
takes a natural-language query instead of a handle and returns ranked handle + score +
preview matches, for when the marker has scrolled out of the model's view.
This is the route the /mcp endpoint's own anyray_retrieve tool calls. Implement against it
directly only when the runtime cannot register an MCP server. A human or CLI caller that is only
probing sends x-anyray-retrieve-probe: 1 so the call does not latch capability. Worked examples per
SDK: Retrieval reference.
Retrieval read-back
Read-back shows whether a lane's handles actually resolve. Every read (served rounds, /mcp,
/connect/retrieve, /connect/recall) is counted under the reading request's client tool and, for
a service key, its agent.
GET /admin/v1/spend/dashboard returns retrieval.byClientTool, one entry per tool with read-back
activity in the window. The tool id is the tool in x-anyray-metadata, else the User-Agent match.
| Field | Meaning |
|---|---|
clientTool | The tool id. A missing or malformed one reads unknown. |
freshHandlesEmitted | Handles this tool's requests minted. The only valid denominator for a read-back rate. Omitted when an older gateway's rows make it partial. |
retrieves, recalls | Read events from every reader. |
hits, misses | Those events by outcome. |
servedRounds | Rounds the gateway served. |
servedOutcomes | The served loop's own counts: hits, misses, unavailable, malformed, capped (still asking at the round limit), mixed (client and served calls together), errors. |
GET /admin/v1/keys/:id and GET /connect/self-check add readBack to a measured
retrievalRuntimes, per tool over the same 24 hours:
"readBack": [
{
"clientTool": "langchain",
"emitted": 12,
"retrieved": 9,
"missed": 1,
"served": { "unavailable": 0, "capped": 0, "errors": 0 }
}
]
emitted counts handles minted; retrieved and missed count anyray_retrieve reads. served
counts the served loop's failures on this key and tool: reads the stash could not answer, requests
that hit the round cap, and loop errors. The Agents card and the control plane's read-back alert
read these counts (Read-back). emitted is
omitted when an older gateway's rows make it partial. An empty list means nothing was minted or
read; a missing readBack means an older gateway. Reads through /mcp or /connect/retrieve count
under the caller's own tool id, so send the same x-anyray-metadata tool to land on the same
entry.
Claude Desktop managed bootstrap
An organization routing Claude Desktop through Anyray manages its third-party profile through the deployment-owner API:
curl -X PUT https://<your-anyray-gateway>/admin/claude-desktop-policy \
-H "Authorization: Bearer $ANYRAY_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"enabled": true,
"settings": {
"inferenceModels": [{"name": "claude-sonnet-5-5"}],
"managedMcpServers": {},
"allowedPluginMarketplaces": [],
"chatTabEnabled": true,
"coworkEgressAllowedHosts": ["*"]
}
}'
Optional updatedAt from GET /admin/claude-desktop-policy (or its
/admin/v1/policies/claude-desktop twin). Send null for a policy that has never
been saved. A stale revision returns 409 without changing the policy or deleting
connector grants. Reload before saving again. Omit for an unconditional save.
| Route | Does |
|---|---|
GET /connect/claude-desktop/bootstrap?os=macos|windows|linux | Returns the effective secret-free policy, with the gateway provider and base URL, helper credential mode, and that OS's inferenceCredentialHelper path injected by the server. The gateway's own Skills and Guidance marketplace is merged into allowedPluginMarketplaces as one more element, leaving any the admin set in place. os defaults to macos. 404 while disabled. |
GET /admin/claude-desktop-policy/mobileconfig | Downloads the MDM trust profile. It pins the bootstrap endpoint and /usr/local/bin/anyray-bootstrap-headers-helper without embedding a key, and names /usr/local/bin/anyray-credential-helper. |
POST /admin/claude-desktop-policy/connectors/probe | Takes {"url": "https://..."} and asks that MCP server for its name and tool list. Answers ok (with name, version, tools, truncated), needs-auth, or unreachable with a reason. HTTPS only, nothing stored. |
GET /admin/claude-desktop-policy/connectors/readiness | Returns the most-blocking organization sign-in state and the /mcp/org endpoint URL. Never probes vendors. v1 twin GET /admin/v1/policies/claude-desktop/connectors/readiness (policies:read). |
POST /admin/v1/policies/claude-desktop/connectors/readiness/refresh | Refreshes the process-local access cache with at most four probes in flight (policies:write). |
POST /admin/claude-desktop-policy/connectors/authorize | Takes {"name": "Atlassian"} and returns a vendor authorizationUrl. No response contains a token. |
PUT /admin/claude-desktop-policy/web-search | Takes {"provider": "brave", "apiKey": "..."} (brave, tavily or exa) and stores the search key encrypted in the gateway. Needs providerkeys:manage. GET reads {configured, provider, updatedAt, sealingAvailable}, never the key; DELETE removes it. v1 twin /admin/v1/providers/web-search. |
POST /connect/claude-desktop/websearch | Where the policy's Web search row sends each query, with the seat's client key from the bootstrap-headers helper. Answers {"results": [{"title", "url", "snippet"}]}. |
Current Claude Desktop sends Authorization: Bearer <client key> on the bootstrap fetch through the
MDM-pinned bootstrapHeadersHelper. The route verifies a presented bearer (401 when rejected) and
still serves an unauthenticated fetch in the no-OIDC lane. The write boundary rejects
credential-shaped string values at every depth, including under otherwise allowed free-text keys.
The bootstrap serves a built-in github row only with its clientId, and a microsoft365 row only
with its tenantId and clientId; it names a row that has no name after its server. A websearch
row is served pointed at /connect/claude-desktop/websearch once a Web search key is stored, unless
it names your own service (provider: "custom" with a customUrl). GET /admin/claude-desktop-policy
reports each built-in row's state in builtinConnectors.
After consent, the authorize callback shows a short claim code. Redeem it with
POST /connect/mcp-oauth/complete and a client key. The grant belongs to the account that redeems it,
never the one that started the flow, and developers start their own flow with
POST /connect/mcp-oauth/authorize. On the readiness read, OAuth adoption is a count, fewerThan,
or unavailable union, and counts of one and two return only as fewerThan: 3.
Import and MDM deployment: Claude Desktop on managed devices.
Organization plugin install states
GET /admin/v1/desktop-plugins/install-states (legacy
/admin/org-plugins/install-states) needs orgplugins:manage. Admin API keys cannot call it.
The response has status: "supported", windowHours: 72 and a plugins array. Each row holds
tool (claude-code or claude-desktop), id, version, states (a count for each
install state), and totalSeatsReporting.
Counts come from the shared Postgres store. Each machine's latest report in the window is kept, and
a seat's machines are combined into one entry: the worst state wins, and the version is null when
the machines differ. They exclude seats that have not reported that plugin.
An empty plugins array on a supported response means there are no matching recent reports.
An older gateway without this route means needs gateway upgrade; clients must keep that
separate from zero reporting seats.
Rejected subscription tokens
When Anthropic answers 401 or 403 to a pass-through request that carries a person's own Claude
subscription token (sk-ant-oat…), the gateway records the refusal for that person. It
appends this sentence to the relayed error.message and leaves the status as it was:
"Anyray: Anthropic rejected the Claude subscription approval this app uses. The Anyray app will ask
you to approve it again."
Only the token's fingerprint is kept (the first 16 hex characters of its SHA-256), never the token.
A 2xx for the same person clears the refusal of that token, and every refusal from the same client
tool when the gateway knows the tool. A success in one tool never clears another's: Claude Code and
Claude Desktop hold different tokens. Refusals are pruned after 90 days (sooner when
ANYRAY_SPEND_RETENTION_DAYS is shorter) and a GDPR erase removes them.
On GET /connect/policy: the caller's own refusals. Omitted when there are none, and by an older
gateway, so read its absence as nothing rejected.
On each GET /admin/client-keys and GET /admin/v1/keys row: the refusals for that row's user,
newest first, at most 5. Each entry has provider (anthropic), tool (a client tool id such as
claude-desktop, or null when unknown), rejectedAt and reason. No fingerprint. Omitted when
there are none.
SCIM v2 provisioning
Point the IdP at this base URL and send the separately configured SCIM bearer on every request:
https://<your-anyray-gateway>/scim/v2
| Resource | Routes |
|---|---|
| Discovery | GET /ServiceProviderConfig, GET /ResourceTypes, GET /Schemas |
| Users | POST /Users, GET /Users, GET /Users/:id, PUT /Users/:id, PATCH /Users/:id, DELETE /Users/:id |
| Groups | POST /Groups, GET /Groups, GET /Groups/:id, PUT /Groups/:id, PATCH /Groups/:id, DELETE /Groups/:id |
User lists support filter=userName eq "dev@example.com", startIndex, and count. Group lists
support the same paging fields. User PATCH accepts active:false for immediate offboarding. Group
PATCH accepts add, remove, and replace member operations, and a missing member can be created when it
carries a verified email in display or value.
Configure the bearer, admin group, and team mappings through the deployment-owner admin lane:
curl -X PUT https://<your-anyray-gateway>/admin/scim/settings \
-H "Authorization: Bearer $ANYRAY_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"bearerToken": "synthetic-scim-bearer-token-replace-me-0001",
"adminGroup": "Anyray Admins",
"groupTeamMap": { "Engineering": "platform" }
}'
GET /admin/scim/settings returns configured, adminGroup, groupTeamMap, and updatedAt, never
the bearer or its hash. A later PUT can omit bearerToken to keep the current credential.
SCIM errors always use application/scim+json and this envelope:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"detail": "invalid bearer token",
"status": "401"
}
Errors
Most inference errors use the OpenAI envelope ({ "error": { "message", "type", "code" } }).
Client-key authentication uses the fixed machine envelope described under
Authentication.
| Status | Meaning |
|---|---|
400 | Malformed request: failed validation, bad JSON, or an unknown provider or model. |
401 | Missing, malformed, unknown, expired, revoked, or SCIM-deactivated client identity. The body carries status: failure, a reason code, the fixed valid client key required message, and a safe hint. |
402 | A governance ceiling was reached: the deployment entitlement or spend cap, a service key's monthly budget, or a per-user token cap or USD budget. error.code is service_key_budget_exceeded, user_token_cap_exceeded, or user_budget_exceeded. Terminal, not retryable: only an admin raising the limit, or the window rolling, clears it. |
429 | Rate limit exceeded. Honors Retry-After. |
503 | A dependency is temporarily unreachable. Client-key verification returns code: key_store_unavailable. This is infrastructure, not a credential problem, so re-running anyray-connect will not help. Verification fails closed, so the request never reaches the provider. If it persists, check GET /admin/health. |
Source trim evidence
The admin key list and detail, and the GET /v1/me key object, carry these UTC ISO fields:
| Field | Meaning |
|---|---|
sourceTrimSince | When observation began. |
sourceTrimRequestCount | Successful inference requests from source-capable clients since observation began, or since the last recorded source trim. A missing count means an older gateway, not zero. |
sourceTrimLastRequestAt | The last such request. |
sourceTrimAt | The last recorded source trim. A successful hook spend event resets the count and start stamp. |
Migration 0097_client_keys_source_trim adds the columns without backfilling historical traffic. Key
records also expose short-lived sourceHook installation and retrieval reports, separately from
retrieveCapableAt.