| Method | Path | Purpose |
|---|---|---|
HEAD |
/ |
Liveness probe |
GET |
/ |
Human-readable landing (version + endpoint list) |
GET |
/health |
Healthcheck — {"status":"ok","version":"x.y.z"} |
GET |
/v1/models |
Model discovery — returns your [[models]] entries |
GET |
/routes |
shunt-native route discovery — returns the configured [[routes]] table verbatim (model → provider/upstream_model/effort mapping, including claude-prefixed discovery aliases); distinct from /v1/models, which serves the narrower Anthropic-protocol discovery response (id, display_name, and upstream model metadata). A routers array is added when at least one [[models]] entry configures a [models.router] table, naming each router’s model id, its algorithm (the configured type), and every targets id it can route to; a router that configures a judge also reports judges, the ids it consults but never serves (omitted when it has none); a stage router or auto entry additionally reports its two tier targets and the tier its picker falls back to when no signal decides |
POST |
/v1/messages |
Inference — routed per the request’s model id |
POST |
/v1/messages/count_tokens |
Token counting |
GET |
/managed/settings |
Per-user Claude Code managed settings for a gateway JWT; supports ETag, If-None-Match, and 304 Not Modified |
GET |
/user/bootstrap |
Claude Desktop bootstrap configuration for a gateway JWT; supports ETag, If-None-Match, and 304 Not Modified |
GET |
/v1/organizations/spend_limits |
List stored spend caps with directional cursor pagination |
POST |
/v1/organizations/spend_limits |
Create or replace a spend cap for one (scope, period) |
GET |
/v1/organizations/spend_limits/{id} |
Fetch one stored spend cap |
DELETE |
/v1/organizations/spend_limits/{id} |
Delete one stored spend cap |
GET |
/v1/organizations/spend_limits/effective |
Binding cap and period-to-date spend per principal and period |
POST |
/v1/metrics |
Inbound OTLP/HTTP metrics from managed Claude Code clients — relayed verbatim to opted-in gateway telemetry destinations |
POST |
/v1/logs |
Inbound OTLP/HTTP log records — relayed only to destinations with logs = true |
POST |
/v1/traces |
Inbound OTLP/HTTP spans — relayed only to destinations with traces = true |
GET |
/admin, /admin/ |
Admin dashboard — the SPA shell, served unauthenticated like the rest of the bundle; the bundle itself redirects to /admin/login after GET /admin/api/session answers 401. The mount root is registered under both spellings, because a wildcard segment cannot match the empty string and /admin/ would otherwise match no route at all. In a binary built without --features ui both answer 404 with a body naming the feature |
GET, POST |
/admin/login |
Admin-token login form, optional OIDC affordance, and browser-session creation |
POST |
/admin/api/oidc/start |
Start the optional same-origin admin OIDC/PKCE login |
GET |
/admin/oidc/callback |
Complete OIDC login, enforce the current allowlist, and create the browser session |
POST |
/admin/api/logout |
Clear the browser session |
GET |
/admin/api/session |
The per-session values the admin SPA needs before it can render: the session’s csrf token, expiry_buffer_ms (claude::auth::EXPIRY_BUFFER in milliseconds, the boundary at which a setup token stops being usable), and access — read or write, the tier the session authenticates with, which is what the dashboard renders its write affordances from. It also carries hide_observed, the boolean value of [server.admin].hide_observed, from which the dashboard decides whether to request /admin/api/observed at all. A header-credential caller is CSRF-exempt and receives an empty csrf. Returning the token over a GET is safe because no CORS layer exists on this surface, so a cross-origin page can send the request but cannot read the reply |
GET |
/admin/api/accounts |
Claude account-store metadata: name, kind, expiry, and UUID; never token material |
GET |
/admin/api/accounts/codex |
Codex account-store metadata: name, expiry, and ChatGPT account ID; never token material |
GET |
/admin/api/accounts/antigravity |
Antigravity account-store metadata: name, expiry, and email (when Google returned one); never token material |
GET |
/admin/api/accounts/xai |
xAI account-store metadata: name and xAI user ID; never token material |
GET |
/admin/api/observed |
Read-only local Claude Code, Codex, Gemini, Kimi, Grok, and Cursor identity plus provider-native usage; never returns token material or refreshes source credentials. The Claude and Codex rows also carry the account uuid (the Claude account uuid or the ChatGPT account id; null when it cannot be established) so the dashboard can tell an observation and a managed pool account holding the same subscription apart from two different accounts. Returns { "accounts": [] } without reading any of those sources when [server.admin].hide_observed is true |
GET |
/admin/api/pool |
Per-claude_oauth/chatgpt_oauth/kimi_oauth/xai_oauth/antigravity_oauth-provider managed-pool state; account objects may include an optional plan string; a file-derived value can later be refined toward a more precise one via a profile lookup; Codex rows include reported 5h/7d usage (7d_oi has no Codex analog); each account also carries a boolean needs_relogin: the credential was terminally rejected (invalid_grant), carries no refresh token at all, or had a rotated pair lost before it could be stored, so no retry can revive it and only an operator re-login will. It is reported independently of the cooldown fields — a cooldown expires by itself, this does not — and both dashboard tables show it as needs re-login rather than the cooling a quota pause produces. Memory-only: a restart clears it, and the account’s next terminal failure re-establishes it. It is reported even for an account no provider table has ever selected — alongside has_state: false — because the admin refresh probe records its verdict by store name. Each account also carries a boolean paused, a runtime-only exclusion an operator toggles without touching shunt.toml (see PATCH .../accounts/{account_ref} below); the response’s top-level sort_by_reset reflects [server.pool] sort_by_reset or its runtime override (see the bare PATCH below), and is process-wide rather than per-provider. Each account also carries an opaque account_ref, derived from its provider-scoped pool identity and used by mutations so two distinct accounts may safely share the same display name. Each account also carries a boolean credential_override, true when its pool entry sets an explicit credentials file or token_env and so does not read the store file named after the account. On an xai_oauth row with credential_override: true, the dashboard does not match it to the store’s same-named login, so a different user’s Grok CLI observation is not folded into it. Each account also carries request counters since this process started: requests_attempted counts the proxied requests shunt sent to the account (a same-account retry after a token refresh or a throttle pause counts again; usage polls, model discovery, catalog lookups and token refreshes are not counted) and always equals the sum of the next three: requests_succeeded, those answered with a 2xx status; requests_failed, those answered with any other status or that failed before response headers arrived (a transport error or the time-to-first-byte timeout); and requests_cancelled, those abandoned before they resolved because the client went away or a deadline cut them. mean_latency_ms is the mean time from sending to response headers over the requests that received headers, null until one has. Requests over the Codex WebSocket transport, served or failed, are not counted; an HTTP fallback after a failed WebSocket attempt is. Rows that resolve to the same account identity share one set of counters. The counters survive a config reload, reset on restart, and are dropped when the account is deleted through the admin API. Each account also carries its resolved max_utilization_5h, max_utilization_7d, and max_utilization_fable hard caps as fractions in [0.0, 1.0] (account window → account → pool window → pool default; null when no cap governs that window), reported from configuration even when has_state is false; the dashboard marks them on the usage bars and in the pool table. max_utilization_fable governs only the Fable 7d_oi window, so it applies to Fable-model requests only. It is always null on chatgpt_oauth accounts, which have no 7d_oi window, and all three caps are null on antigravity_oauth accounts, whose quota does not feed caps. |
PATCH |
/admin/api/pool/{provider}/accounts/{account_ref} |
Write-tier only. Body {"paused": true|false} toggles the named account’s runtime pause: it is excluded from selection exactly as disabled would, but memory-only (cleared on restart) and independent of shunt.toml. 404 for an unknown provider or account The {account_ref} comes from the matching account object in GET /admin/api/pool; display names are not used for mutation targeting. |
PATCH |
/admin/api/pool |
Write-tier only. Body {"sort_by_reset": true|false} toggles the reset-priority sort at runtime for the whole pool ([server.pool] is process-wide, not per-provider): available accounts then rank by soonest quota reset instead of burn-rate headroom. {"sort_by_reset": null} explicitly clears a previously set override, reverting to the config file’s own value (an omitted field is a no-op, not a clear); the override is also memory-only, reverting on restart. Has no effect — and GET /admin/api/pool reports sort_by_reset: false regardless of the override — while [server.pool] itself is absent, since that legacy selection path never consults it |
GET |
/admin/api/status |
Observation-only view of [server.status] polling: each configured source’s most recently observed Statuspage indicator, description, incidents, and observed timestamp. Empty sources when [server.status] is absent or unconfigured — never consulted by routing or failover |
GET |
/admin/api/routes |
The resolved routing table behind the admin credential, available to the dashboard — the same body GET /routes above returns, built from the same snapshot so the operator’s view and the table a client resolves against cannot drift. It is registered separately rather than redirected so the admin namespace keeps its own authentication: the public /routes is unauthenticated on purpose, and pointing this one at it would tie the two together. Requiring the admin credential here means leaving /routes open does not widen what that credential gates |
POST |
/admin/api/accounts/claude |
Start Claude browser provisioning with {name, mode} where mode is oauth or setup_token (omitted defaults to setup_token); returns {authorize_url} |
POST |
/admin/api/accounts/claude/{name}/complete |
Complete Claude provisioning with {code} containing <code>#<state>; stores the account and reports whether it is live |
POST |
/admin/api/accounts/claude/{name}/refresh |
Exercise an imported Claude account’s refresh grant on demand and report whether the login is still alive. Rate-limited (it reaches the provider token endpoint), and always routed through the shared credential store so it cannot race the proxy’s own refresh. Returns the new expires_at and never any token material, plus a needs_relogin read back from the pool after the probe’s own clear — a grant can succeed against an account the pool still considers dead, and the response says so rather than claiming a recovery /admin/api/pool would contradict. Answers 400 for a setup_token account (it carries no refresh grant) or any terminal verdict, and 502 for a non-terminal failure |
DELETE |
/admin/api/accounts/claude/{name} |
Remove the named Claude account’s store file |
POST |
/admin/api/accounts/codex |
Start ChatGPT OAuth with {name}; returns {authorize_url} |
POST |
/admin/api/accounts/codex/{name}/complete |
Complete Codex provisioning with {code} containing the full localhost redirect URL or <code>#<state>; stores the account and reports whether it is live |
DELETE |
/admin/api/accounts/codex/{name} |
Remove the named Codex account’s store file |
POST |
/admin/api/accounts/antigravity |
Start Antigravity OAuth with {name}; returns {authorize_url} |
POST |
/admin/api/accounts/antigravity/{name}/complete |
Complete Antigravity provisioning with {code} containing the full localhost redirect URL or <code>#<state>; stores the account and reports whether it is live |
POST |
/admin/api/accounts/antigravity/{name}/refresh |
Exercise an Antigravity account’s refresh grant on demand and report whether the login is still alive, the same probe shape as the Claude refresh route above |
DELETE |
/admin/api/accounts/antigravity/{name} |
Remove the named Antigravity account’s store file |
GET |
/admin/assets/{*path} |
Embedded admin SPA bundle files, each served with the Content-Type its extension implies plus X-Content-Type-Options: nosniff. Only in a binary built with --features ui |
GET |
/admin/{*path} |
SPA shell for any unmatched path under the /admin mount, so a client-side deep link survives a reload. Only in a binary built with --features ui |
GET, POST |
/backend-api/codex/responses |
Inbound Codex CLI passthrough — mirrors the real ChatGPT backend path |
GET, POST |
/responses |
Inbound Codex CLI passthrough — bare base_url form |
GET, POST |
/v1/responses |
Inbound Codex CLI passthrough — /v1-suffixed base_url form |
POST |
/backend-api/codex/alpha/search |
Codex CLI web search (web.run) relay — ChatGPT-style base path |
POST |
/alpha/search |
Codex CLI web search relay — bare base_url form |
POST |
/v1/alpha/search |
Codex CLI web search relay — /v1-suffixed base_url form |
GET |
/models |
Codex CLI model catalog fallback — returns {"models":[]} |
GET |
/backend-api/codex/models |
Codex CLI model catalog fallback — ChatGPT-style base path |
POST |
/backend-api/codex/analytics-events/events |
Codex CLI analytics sink — accept and discard; record sanitized event-name counters only |
POST |
/codex/analytics-events/events |
Codex CLI analytics sink — root-style chatgpt_base_url form |
GET |
/usage |
Client-facing sanitized pool usage — per-window remaining headroom and reset for the shared account pool, plus the same aggregate per pooled provider; never account identity or capacity |
GET |
/api/oauth/usage |
Claude Code CLI’s own native usage-bar fetch path — sanitized, Claude-only, routing-aware worst-case pool usage in Anthropic’s own wire shape |
GET |
/.well-known/oauth-authorization-server |
Gateway OAuth discovery metadata |
POST |
/oauth/device_authorization |
Start a gateway device authorization grant |
POST |
/oauth/token |
Poll a device grant or refresh a gateway session |
GET, POST |
/device |
Browser device-code confirmation page and password approval form |
POST |
/device/authorize |
Start OIDC approval after explicit same-origin browser confirmation |
GET |
/device/callback |
Complete the OIDC authorization-code callback |
The gateway discovery, device, token, and browser routes exist only when [server.gateway] is configured at startup. POST /device/authorize and GET /device/callback additionally require [server.gateway.oidc]; without OIDC configuration they return a browser error page rather than starting an external sign-in. Adding or removing [server.gateway] requires a restart because route registration is fixed at boot, while changes to its resolved credentials and OIDC configuration hot-apply.
The /managed/settings route exists only when [server.gateway] was enabled at boot. A valid gateway bearer JWT is required; static [server.auth] tokens do not authenticate this endpoint. When [[server.gateway.policies]] is configured, the response is:
{
"uuid": "sha256:<stable-user-hash>",
"checksum": "sha256:<settings-hash>",
"settings": { "availableModels": ["claude-opus-4-8"] }
}ETag is the quoted checksum ("sha256:<settings-hash>"). Send it back in If-None-Match to receive 304 Not Modified with an empty body when settings have not changed; comma-separated validator lists, weak validators, *, and legacy unquoted checksum values are accepted. No configured policies returns 404; a policy that resolves to an empty document returns 200 with settings: {}.
The /user/bootstrap route exists only when [server.gateway] was enabled at boot and requires a valid gateway bearer JWT; static [server.auth] tokens do not authenticate it. It returns the [server.gateway.desktop_bootstrap] template as application/json, with ${user.email}, ${user.sub}, and ${user.name} replaced inside string values. Every response, including errors and 304, carries Cache-Control: no-store, and the route never redirects.
| Status | Error type | Meaning |
|---|---|---|
200 |
n/a | Template body, with a quoted ETag ("sha256:<body-hash>") |
304 |
n/a | If-None-Match matched the current ETag; empty body |
401 |
authentication_error |
Gateway bearer missing, invalid, or expired |
403 |
permission_error |
Valid bearer, but the user is not entitled by [[server.gateway.policies]] |
404 |
not_found_error |
desktop_bootstrap not configured |
500 |
api_error |
The template became unreadable or is no longer a JSON object |
With no policies configured every signed-in user is entitled; with policies, a user is entitled when a policy’s match.emails lists them or a catch-all policy (no match) exists. The device-code endpoints accept requests without a client_id (Claude Desktop sends none) as well as client_id=claude-code; any other value is rejected. For a request without client_id, a rejected refresh token returns 400 {"error":"invalid_grant"}, while claude-code requests keep 401. See Connect Claude Desktop.
The POST /v1/{metrics,logs,traces} telemetry-ingest routes exist only when [server.gateway] was enabled at boot, and require the same gateway bearer JWT as /managed/settings. They accept the OTLP/HTTP payloads managed Claude Code clients export — [server.gateway.telemetry] points those exporters at the gateway — and relay the exact request bytes to every destination that opted in to the signal, preserving the inbound content-type and content-encoding and applying the destination’s own configured headers over them (a configured key replaces the forwarded value rather than duplicating the header). The client’s Authorization header is never forwarded, and relays do not follow redirects. Destinations opt in per signal (metrics on by default, logs and traces off), and a signal with no opted-in destination is accepted and discarded. Relays are detached, so the response is always an immediate 200 regardless of destination health — the success body mirrors the request protocol per OTLP/HTTP ({} for application/json, an empty application/x-protobuf body otherwise); a body over the 32 MiB inbound cap returns 413.
The spend-limit routes exist only when [server.spend] was configured at boot — independently of [server.gateway], since they authenticate with the [server.admin] credential. Send that credential in the configured admin header (x-shunt-admin-token by default) or in x-api-key; both slots are accepted. A write credential (a write_keys entry, or a tokens_env/tokens_file pair) can use every operation, while a read_keys credential can use GET only and receives 403 on mutations. POST accepts user and organization scopes, a daily/weekly/monthly period, a user_id of 1–256 bytes for user scopes, and an amount that is either a 1–19 digit whole-number string of USD cents or null. It upserts by (scope, period). List pagination accepts limit (1–1000, default 20), after_id, before_id, and scope_type; the two cursors are mutually exclusive. Every response includes request-id, and errors use the Anthropic error shape. Caps and mutation audit records persist together in the configured versioned JSON state file, each mutation attributed to admin-key:<id> or admin-token:<name> — when both slots carry a different credential of the same tier, the configured header is the attributed one. GET /v1/organizations/spend_limits/effective (read or write credential) returns one row per principal and period with the binding cap (amount, source, spend_limit_id) and period_to_date_spend in US cents; it accepts limit (1–1000, default 20, counts principals), period[], user_ids[] (at most 100, unpaginated), q, sort=spend_desc (with exactly one period[]) and page, and answers invalid input with 400 invalid_request_error. period_to_date_spend reports only the counters the meter could read: a principal whose persisted counter record failed to restore can show zero or partial spend until its unavailable flag lifts, and the restore warning in the log names it. Caps are enforced on /v1/messages and on the inbound Codex endpoint’s Responses routes and /alpha/search aliases (an over-cap Codex turn gets 429 usage_limit_reached; see Inbound Codex Endpoint), but not on a chain made only of passthrough or type = "noop" routes; /audit is not exposed.
The /admin* routes exist only when [server.admin] is configured; without that table, none of them are registered. They accept the admin credential in the configured header or x-api-key, and a read_keys credential passes every GET below while being refused with 403 on every mutation. It may sign in: POST /admin/login accepts it and mints a read-tier session, whose mutations are refused with the same 403. That login POST is same-origin guarded: a cross-site form submission is refused with 403 before the credential is even checked, so no credential holder can replace a signed-in operator’s session cookie with one of their own. A scripted login is unaffected — the check passes when neither Sec-Fetch-Site nor Origin is present. The SPA shell and its bundle files are the exception: GET /admin (both spellings), GET /admin/{*path}, and GET /admin/assets/{*path} are served without admin authentication. That is safe because they carry no operator data — everything the SPA reads sits behind /admin/api/*, which authenticates every request — and the two wildcard routes still exist only when [server.admin] is configured and the binary was built with --features ui. GET /admin/api/observed auto-discovers supported Claude Code, Codex CLI, Gemini CLI, Kimi Code, Grok CLI, and Cursor.app credentials on the gateway host. It never refreshes or writes those sources. Set [server.admin].hide_observed to skip that discovery: the route still authenticates and returns { "accounts": [] }, GET /admin/api/session reports hide_observed: true, and the dashboard skips the request and lists managed pool accounts only. Claude usage is cached for 60 seconds; Codex usage is response-derived and remains unavailable until traffic through this shunt returns x-codex-* headers; the other providers use their first-party read-only quota surfaces. Managed account CRUD and /admin/api/pool remain the separate shunt-owned credential lane.
Admin SPA bundle (--features ui)
/admin/assets/{*path} and the /admin/{*path} SPA fallback exist only in a
binary built with --features ui, which embeds the bundle built from the ui/
package. A default cargo build needs no Node toolchain, carries no bundle, and
registers neither route; the prebuilt release binaries are built with the
feature.
The fallback is confined to the /admin mount:
- an unmatched path under
/admin/returns the SPA shell astext/htmlwith200, so a client-side deep link survives a reload; - an unmatched path under
/admin/api/returns404in the Anthropic error shape — that namespace is JSON, not UI, and answering it with HTML would break a client’s error handling; - an unmatched path outside the mount is unaffected and still returns
404.
GET /admin is the shell too, and so is GET /admin/ — the mount root is
registered under both spellings, because a wildcard segment cannot match the
empty string, so /admin/ matches neither the exact route nor the fallback and
would answer a bare 404 on the dashboard’s own root.
Those two are the admin paths registered in both builds whose answer depends on
the feature — the shell with it, and without it a 404 whose body names
--features ui. They stay registered either way so that 404 carries that
sentence instead of being axum’s empty-bodied 404 for an unregistered path,
which an operator cannot tell apart from an unconfigured [server.admin].
Admin path migration
Breaking. Every admin JSON and mutation route moved from /admin/* to
/admin/api/*. The old paths are removed, not aliased — no request to one
reaches an admin handler any more.
What a caller sees at an old path depends on the build, so do not treat a 200
there as success:
- default build — the path is registered nowhere and answers
404. --features ui(what the prebuilt releases ship) — aGETfalls through to the/admin/{*path}SPA fallback like any other deep link under the mount and returns the200 text/htmlshell —HEADlikewise. Every other method answers405withAllow: GET,HEAD, because the fallback is registered withget, notany.
So a script left on an old GET URL gets HTML rather than a migration signal.
Move it to /admin/api/*; a response’s Content-Type is the reliable check.
/admin stays the dashboard shell, and /admin/login and /admin/oidc/callback
stay where they are — they are server-rendered pages, not API routes. Everything
else moved, so that the dashboard can claim /admin/* as browsable deep links: a
path cannot be both a JSON endpoint and a client-side route, which is why no
compatibility alias was kept.
Rewrite scripted callers once, mechanically — the path gains one /api segment
after /admin:
| Before | After |
|---|---|
GET /admin/accounts |
GET /admin/api/accounts |
GET /admin/accounts/codex |
GET /admin/api/accounts/codex |
GET /admin/observed |
GET /admin/api/observed |
GET /admin/pool |
GET /admin/api/pool |
GET /admin/status |
GET /admin/api/status |
POST /admin/accounts/claude |
POST /admin/api/accounts/claude |
POST /admin/accounts/claude/{name}/complete |
POST /admin/api/accounts/claude/{name}/complete |
POST /admin/accounts/claude/{name}/refresh |
POST /admin/api/accounts/claude/{name}/refresh |
DELETE /admin/accounts/claude/{name} |
DELETE /admin/api/accounts/claude/{name} |
POST /admin/accounts/codex |
POST /admin/api/accounts/codex |
POST /admin/accounts/codex/{name}/complete |
POST /admin/api/accounts/codex/{name}/complete |
DELETE /admin/accounts/codex/{name} |
DELETE /admin/api/accounts/codex/{name} |
POST /admin/oidc/start |
POST /admin/api/oidc/start |
POST /admin/logout |
POST /admin/api/logout |
Authentication is unchanged: the same admin credential, in the same header or
x-api-key, with the same read/write tiering.
The Codex Responses, web-search, model-catalog, and analytics routes exist only when [server.codex_endpoint] is configured; without that table, none of them are registered. The three Responses paths relay raw OpenAI Responses over HTTP/SSE (POST) or as Responses events over a WebSocket upgrade (GET), optionally routed per model to other Responses-compatible upstreams, unlike the Anthropic-Messages-translating /v1/messages above. The two Codex-only model paths return {"models":[]}; the shared /v1/models returns that shape only when its query contains client_version, otherwise preserving its Anthropic contract. All catalog variants use the normal model-discovery auth gate. The two analytics paths use the same inbound-auth policy, never forward or retain the client payload, and return 200 {} after authentication even for malformed or oversized bodies. Only sanitized event names are counted in shunt.codex_client_events; with no metric sink configured they are pure discard sinks. The three web-search paths relay the Codex CLI’s built-in web search verbatim to the backend’s /codex/alpha/search over the same account pool and inbound-auth policy as the Responses paths; they accept POST only and never follow a [[server.codex_endpoint.routes]] entry. See the inbound Codex endpoint guide.
The /usage route exists only when [server.usage] is configured, which itself requires [server.auth] or [server.gateway]. It authenticates either the same client token as GET /v1/messages (configured header, x-api-key, or Authorization: Bearer) or a gateway login access token sent as Authorization: Bearer — what a session started with shunt gateway claude presents. A valid gateway login is accepted without consulting [server.auth], so an unreachable [[server.auth.jwt]] key set does not lock it out. It returns a sanitized, aggregated view of the shared account pool — per-window remaining headroom (mean(clamp(cap - utilization, 0, 1)) across non-disabled accounts reporting the window, where cap is the account’s resolved max_utilization hard cap for that window and 1.0 when none applies, i.e. the fraction of the pool’s combined capacity still usable before caps exclude accounts; an account a cap already excludes from the window’s requests counts zero, and a 5h or 7d cap excludes it from Fable requests too) and the earliest reset among those accounts (including when a cap holding an account’s term at zero clears), plus a coarse ok/degraded/exhausted/capped status — so a non-admin caller can anticipate throttling. It never exposes account names, counts, priorities, disabled flags, thresholds, or per-account numbers; the full per-account detail stays behind admin-only GET /admin/api/pool. A window is null only when no non-disabled account reports it. Codex response x-codex-* headers and optional wham/usage polling populate the observed 5-hour and shared weekly windows; an unobserved window alone is null. On the WebSocket transport the in-stream codex.rate_limits event supplies the same windows on every turn, reused connections included. Codex has no Fable-scoped (7d_oi) signal, although another provider in a mixed pool may supply the aggregate Fable window. pool is the aggregate across every pooled provider; providers carries the same sanitized aggregate per pooled provider, keyed by the configured provider name, so a client that routes to one provider can read that provider’s headroom and status instead of the blended pool-wide mean. Providers whose auth mode is not pooled are omitted. Response shape:
{
"pool": {
"status": "ok",
"windows": {
"5h": { "remaining": 0.21, "resets_at": 1751990000 },
"7d": { "remaining": 0.37, "resets_at": 1752400000 },
"fable": { "remaining": 0.85, "resets_at": 1753000000 }
}
},
"providers": {
"claude": {
"status": "ok",
"windows": {
"5h": { "remaining": 0.42, "resets_at": 1752000000 },
"7d": { "remaining": 0.61, "resets_at": 1752500000 },
"fable": { "remaining": 0.85, "resets_at": 1753000000 }
}
},
"codex": {
"status": "exhausted",
"windows": {
"5h": { "remaining": 0.0, "resets_at": 1751990000 },
"7d": { "remaining": 0.12, "resets_at": 1752400000 },
"fable": { "remaining": null, "resets_at": null }
}
}
}
}Pool status capped means no account is available and at least one enabled (non-disabled), unpaused account is excluded by a max_utilization* hard cap (see [server.pool]); it is distinct from exhausted.
A Claude Code client can read this without reaching for curl: the shunt mod in this repository’s plugin marketplace reads this endpoint: it keeps each window’s pool usage (1 - remaining) and time to reset in a band above the prompt, refreshed every minute and after each turn, and answers /shunt:usage with the full breakdown. It needs function hooks (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1), which are early access.
The /api/oauth/usage route exists only when [server.oauth_usage] is configured. It is the exact path the Claude Code CLI’s own usage bars fetch (fetchUtilization), so when the CLI is pointed at shunt via ANTHROPIC_BASE_URL, its unmodified UI can show real numbers — but only for CLIs using a full interactive claude login session; claude setup-token and shared-gateway client-token setups were verified not to trigger the CLI’s own fetch (see the M14 behavior specification for the full precondition evidence). Unlike GET /usage, auth is bind-topology-gated: unauthenticated on a loopback [server] bind, and requiring a valid credential — a configured client token or a valid gateway JWT, exactly as /v1/messages is gated — on a non-loopback bind (which itself then requires [server.auth] or [server.gateway] to be configured). Bare header presence is not accepted. It reports only claude_oauth-provider accounts, using a routing-aware, priority-tiered worst case per window rather than /usage’s pool-wide mean headroom — the worst case among the accounts the next request can actually route to, not a pool-wide average. It never exposes account names, counts, priorities, disabled flags, thresholds, or per-account numbers. Response shape (Anthropic’s own /api/oauth/usage schema, resets_at in RFC3339):
{
"five_hour": { "utilization": 42.37, "resets_at": "2026-07-20T23:00:00Z" },
"seven_day": { "utilization": 61.02, "resets_at": "2026-07-27T00:00:00Z" },
"limits": [
{
"kind": "weekly_scoped",
"scope": { "model": { "display_name": "Fable" } },
"percent": 12.5,
"resets_at": "2026-07-27T00:00:00Z"
}
]
}five_hour/seven_day are omitted (not null) when no non-disabled Claude account reports that window; limits is omitted entirely (not an empty array) when no account reports the Fable-scoped window.
GET / and GET /health stay open even when [server.auth] is enabled (healthcheck tools usually cannot attach tokens) and expose nothing sensitive — only status, version, and the already-public endpoint list. With [server.auth] enabled, GET /v1/models requires a valid client token in the configured header, x-api-key, or Authorization: Bearer; it stays open when inbound auth is not configured. When [[server.auth.jwt]] entries are configured, a verified external JWT in Authorization: Bearer authenticates the same gated routes as a client token; a token that verifies against no entry is 401, while a matching entry that has no cached key set and cannot fetch one answers 503. GET /routes remains open as shunt-native routing metadata.
Gateway protocol
shunt implements the official Claude Code LLM gateway protocol: correct header and body-field forwarding, feature pass-through, and system-prompt attribution handling. Gateway-owned errors are returned in the Anthropic error shape, upstream context-overflow errors are rewritten to Anthropic’s prompt is too long wording so Claude Code’s compact-and-retry fires, and streaming responses are relayed without buffering (with optional keepalive pings).