Skip to content

Admin & Remote Provisioning

Automatically inspect Claude and GPT usage, and optionally manage shunt-owned pool accounts.

Updated View as Markdown

shunt can expose an admin-authenticated Accounts and usage view. It automatically observes supported provider logins already present on the gateway host: Claude Code, Codex CLI, Gemini CLI, Kimi Code, Grok CLI, and Cursor.app. It is opt-in: when [server.admin] is absent, none of the /admin* routes are registered and shunt’s default HTTP surface is unchanged.

Observation is strictly read-only. shunt reads current access/session material only in memory and never refreshes, copies, or writes the source credential. Cursor.app’s SQLite state is opened read-only. An expired source is shown as unavailable and must be renewed with its provider client. The table reports each provider’s native signal: Claude’s 5-hour/weekly/model-family windows, response-derived GPT/Codex windows, all Gemini Code Assist model buckets, Kimi’s weekly and 5-hour limits, Grok credit/product usage, and Cursor billing-cycle/Auto + Composer/named-model usage.

Managed account provisioning is a separate advanced action. Expanding Manage pool accounts lets you create shunt-owned Claude or Codex credentials for load-balancing. These managed copies live in ~/.shunt/accounts/ and shunt owns their refresh/writeback; they are not required merely to view local usage. Their usage is surfaced in the main table above — only provisioning and raw store metadata live behind the advanced section.

Enable the admin surface

Add the optional table and provide at least one admin credential through the configured environment variable:

[server.admin]                        # all keys optional; defaults shown
header = "x-shunt-admin-token"
tokens_env = "SHUNT_ADMIN_TOKENS"
session_ttl_secs = 3600
pending_ttl_secs = 600
export SHUNT_ADMIN_TOKENS="ops:$(openssl rand -hex 32)"
shunt check
shunt run

Credentials use the same comma-separated name:token format as SHUNT_CLIENT_TOKENS, but they are a separate security boundary. Do not reuse a [server.auth] client token as an admin token. Startup fails closed if [server.admin] is present but all three credential sources — its token environment variable (or tokens_file), [[server.admin.write_keys]], and [[server.admin.read_keys]] — are empty or malformed. The name:token pairs are the write tier; a read_keys entry passes every GET on the admin surface and is refused on every mutation, the login form included.

Add OIDC/SSO browser login

Keep the admin token configured, then add an optional OIDC provider for browser sign-in:

[server.admin.oidc]
public_url = "https://admin.example.com"
issuer = "https://accounts.example.com"
client_id = "shunt-admin"
client_secret_env = "SHUNT_ADMIN_OIDC_SECRET"
allowed_domains = ["example.com"]
# allowed_emails = ["operator@example.net"]
export SHUNT_ADMIN_OIDC_SECRET="<provider client secret>"

Register https://admin.example.com/admin/oidc/callback as the provider redirect URI. public_url must be the externally reachable bare HTTPS origin; plain HTTP is accepted only for loopback development. At least one allowed domain or full email is required, and shunt accepts only an OIDC UserInfo identity whose email is non-empty and verified.

The login page retains the admin-token form and adds Sign in with SSO (or Sign in with Google for Google’s issuer). The start request is same-origin guarded, uses PKCE and a short-lived single-use state, and is rate-limited with token login. On callback, shunt exchanges the code without exposing it, fetches the verified identity, re-checks the current hot-reloaded allowlist, creates the ordinary HttpOnly admin session, and redirects only to /admin. Provider errors remain generic in the browser, and shunt never logs tokens, secrets, or authorization codes. For GitHub or SAML, put an OIDC broker such as Dex in front rather than configuring provider-specific OAuth2 directly.

See the configuration reference for every key and default. The endpoint reference lists the browser and JSON routes.

Provision a Claude account in the browser

  1. Open /admin and sign in with an admin token.

  2. Enter an account name containing only lowercase letters, digits, and hyphens.

  3. Select Full OAuth (refreshable) (the dashboard default) or Setup token (1-year, inference-only), then select Start.

  4. Open the displayed authorize URL in another tab. Sign in to the target Claude account and approve access.

  5. Copy the resulting <code>#<state> value back to the admin page and select Complete.

  6. shunt stores the account. A provider with an empty accounts list picks it up on its next request without a restart. Otherwise, add a name-only entry and reload:

    [[providers.anthropic.accounts]]
    name = "backup"

A started flow remains valid for pending_ttl_secs (10 minutes by default), giving the operator time to open the authorization page and paste the result. The server records the selected mode with the pending attempt, so the completion request cannot switch token types. Full OAuth stores access and refresh tokens and appears as credential kind imported; setup-token mode stores a static credential with kind setup_token. The completion response reports whether the account was stored and whether the current provider configuration makes it live.

Account-store changes are discovered per request, so scan-mode providers do not need a restart after an account is added or removed.

Provision a Codex account in the browser

  1. In Add Codex account, enter a lowercase account name and select Start Codex login.
  2. Open the authorize URL, sign in to the target ChatGPT account, and approve access.
  3. OpenAI redirects the browser to http://localhost:1455/auth/callback. The localhost page will normally fail to load; this is expected because the admin flow does not run a callback server in the operator’s browser.
  4. Copy the full URL from the browser address bar, paste it into the admin page, and select Complete Codex login. The JSON API also accepts <code>#<state>.
  5. shunt exchanges the code, requires a refresh token and ChatGPT account ID, and writes a private Codex auth.json-shaped account file.

An empty-account chatgpt_oauth provider, including the built-in codex provider, discovers the new store account on its next request. An explicitly configured pool needs a name-only entry:

[[providers.codex.accounts]]
name = "codex-backup"

SHUNT_CODEX_TOKEN_URL overrides the ChatGPT token endpoint for local integration testing. shunt accepts HTTPS overrides or plain HTTP loopback URLs only; leave it unset in production.

Inspect automatic usage and managed-pool health

The primary Accounts and usage table is automatic and grouped by provider: the provider is named once and its accounts nest beneath it. Observed logins are keyed by recognizable masked identity rather than a store filename; managed pool accounts appear alongside them under the same provider, keyed by account name. Where an observed login and a managed account hold the same subscription they are coalesced into a single row — one subscription is one row, labelled with the managed account name — so a pool does not make one subscription look like two.

Coalescing is conservative and can decline. When CLAUDE_CONFIG_DIR selects a Claude profile, shunt cannot prove which account the observed credential belongs to (the credential sources are profile-agnostic while the recorded account id is not), so it leaves the rows separate instead of risking one account’s usage being shown against another’s name.

Each row uses its provider’s native signal:

  • Claude — 5-hour, weekly, and model-family quota from Anthropic’s usage API; cached for 60 seconds.
  • Codex — response-derived windows captured from x-codex-* headers after GPT traffic flows through shunt. Waiting for traffic means this shunt process has not yet received those headers; it does not mean 0% used. Send one GPT request through this shunt to populate the row.
  • Gemini — every Code Assist model quota bucket returned by the Gemini CLI account. Needs login means the current provider-owned access token has expired. Read-only observation never uses the refresh token or writes the provider’s credential file; run the provider client to renew its own login.
  • Kimi — weekly and 5-hour Kimi Code limits.
  • Grok — overall credits and per-product usage for the Grok CLI subscription.
  • Cursor — billing-cycle total, Auto + Composer, and named-model usage from a read-only Cursor.app session.

The collapsed advanced section shows managed account-store metadata and current pool state for each provider configured with auth = "claude_oauth" or auth = "chatgpt_oauth". Claude managed rows include the 5-hour, shared 7-day, and 7d_oi utilization observed from upstream responses, along with status and cooldown state. When upstream reported a reset time, each window cell also shows the time remaining until the window resets (e.g. 3d 4h), with the absolute reset timestamp available on hover. Codex managed rows show the windows reported in x-codex-*; unsupported windows stay .

Dead credentials and the Refresh button

An account whose credential no operator-free retry can revive reports Needs re-login rather than cooling, in both the primary table and the advanced pool table. The distinction matters: a cooldown expires on its own after five minutes and the account is tried again, so a permanently dead credential otherwise looks identical to a quota pause — forever. The mark is set when the provider terminally rejects the refresh grant, when a credential that cannot be refreshed at all (a token_env token or an expired setup token) is rejected, and when a request retried with a freshly refreshed bearer is still rejected. It is reported independently of the cooldown and is memory-only, so a restart clears it until the account fails again. The mark follows the credential rather than the provider entry that tripped over it: when one stored account is activated by name in several provider tables, all of its rows flip together. An account that points at its own credentials file or token_env is a different credential even if it shares a name, so a failure on it marks nothing else.

Recovery is to remove and re-add the account. Rows for imported accounts also carry a Refresh button, which exercises that account’s refresh grant immediately and reports whether the login is still alive — useful for confirming a re-login worked without waiting for traffic. It is rate-limited because it reaches the provider’s token endpoint, it always goes through the shared credential store so it cannot race the proxy’s own refresh, and it rotates the stored refresh token exactly as ordinary use would. It returns the new expiry and never any token material. setup_token accounts have no refresh grant, so they answer 400 without contacting the provider.

A successful probe clears the mark only when a failing grant was what set it. If the account was condemned because the provider rejected a bearer it had already issued, a working grant proves nothing about serving inference, so the mark survives and the response says so.

The Claude account list exposes account name, credential kind (setup_token or imported), expiry, and UUID. The Codex list exposes account name, access-token expiry, and ChatGPT account ID. Neither endpoint returns token material. See Anthropic Multi-Account and Codex Multi-Account for the respective selection behavior.

In the dashboard the Claude table renders that expiry as a Status derived from the credential kind, because the raw value is the ~8-hour access-token deadline and means opposite things per kind. An imported account holds a refresh token that shunt renews on demand, so it reads Auto-refreshes (raw timestamp on hover) and a past timestamp there is normal, not a fault. A setup_token account cannot refresh at all, so its one-year expiry is actionable: Valid until the date while more than the refresh buffer remains, and Expired, styled as a danger state with a re-login hint, once it is inside that buffer or past the date. The buffer is the same five minutes routing applies (EXPIRY_BUFFER), so the dashboard never calls a credential usable while a routed request would already fail on the no-refresh-token path. Every row also offers a Re-login button, which prefills the add form above with that account’s name and login method — completing the normal flow under an existing name overwrites the account in place. The Codex table carries the same Status column, but unconditionally: that store has no non-refreshable kind — both ways of writing into it require a refresh token — so shunt renews every Codex account and each row reads Auto-refreshes, again with the raw timestamp on hover. Codex rows carry the same Re-login button, minus the login-method preselection — ChatGPT OAuth is the only way into that store. The read-only rows in the usage table above are unaffected; those credentials are refreshed by the provider client that owns them, not by shunt.

For API/curl access to account metadata, pool state, provisioning, or account removal, send the admin credential in the configured header (default x-shunt-admin-token) or in x-api-key — both slots are accepted on the admin routes, never on inference routes — and use the JSON routes documented in HTTP Endpoints. Header-authenticated requests do not use the browser session and are exempt from CSRF checks. Start Claude provisioning with { "name": "backup", "mode": "oauth" } or mode: "setup_token"; omitting mode keeps the API’s backward-compatible setup_token default. Start Codex provisioning with { "name": "codex-backup" }, then complete it with { "code": "<full redirect URL or code#state>" }.

CLI and SSH fallback

Use the CLI when the shunt host is not reachable in a browser. Full OAuth normally opens a browser and completes through a temporary 127.0.0.1 callback; over SSH or in a headless environment, force the same manual-paste redirect used by the admin page:

shunt login claude --name backup --mode oauth --manual

To import the host’s current refreshable Claude Code login instead:

shunt login claude --name primary --mode import

To create a one-year inference-only credential:

shunt login claude --name ci --mode setup-token

To import the host’s current Codex login instead of using browser OAuth:

shunt login codex --name codex-backup

--long-lived remains a deprecated alias for --mode setup-token. The admin surface supports Claude full OAuth/setup-token provisioning and Codex ChatGPT OAuth. Existing-file imports require host access and therefore stay CLI-only.

Security

  • Put the admin surface behind HTTPS or a trusted tunnel such as WireGuard or Tailscale. shunt serves plain HTTP itself; use TLS termination in front when exposing it remotely.
  • Generate a strong admin token and keep it separate from [server.auth] client credentials. Admin access can add and remove upstream accounts.
  • Browser login creates an HttpOnly, SameSite=Strict session cookie whether authentication used the admin token or OIDC. The cookie is Secure except on loopback hosts, so local HTTP development still works.
  • Mutating browser requests require a per-session x-csrf-token and pass a same-origin check. API/curl calls authenticate with the admin header instead and do not carry ambient cookie authority.
  • Provisioning completion is rate-limited. shunt never logs or returns token material, and account additions and removals are audit-logged by account name.

Without [server.admin], the routes do not exist. This is stronger than leaving an unused dashboard unauthenticated: the admin surface is absent unless explicitly enabled.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close