Skip to content

Anthropic Multi-Account

Pool Claude subscription OAuth accounts with session-sticky, model-aware proactive rotation and reactive failover.

Updated View as Markdown

shunt can pool multiple Claude subscription OAuth credentials behind the built-in anthropic provider. Requests are session-sticky when Claude Code supplies x-claude-code-session-id; requests without it use per-provider round-robin. shunt tracks each account’s upstream quota headers and proactively rotates when the sticky account nears the model-relevant quota, while quota rejection, authentication failures, and upstream failures retain reactive failover as the safety floor.

Configure the pool

Set auth = "claude_oauth" and add explicit account entries:

[providers.anthropic]
kind = "anthropic"
base_url = "https://api.anthropic.com"
auth = "claude_oauth"

# Existing Claude Code credentials file. shunt refreshes and writes it back.
[[providers.anthropic.accounts]]
name = "primary"
credentials = "~/.claude/.credentials.json"
uuid = "00000000-0000-0000-0000-000000000000" # optional

# Long-lived `claude setup-token` value. Used verbatim; not refreshed.
[[providers.anthropic.accounts]]
name = "backup"
token_env = "CLAUDE_BACKUP_OAUTH_TOKEN"
uuid = "11111111-1111-1111-1111-111111111111" # optional
export CLAUDE_BACKUP_OAUTH_TOKEN='<value from claude setup-token>'
shunt check
shunt run

Store accounts with any of the three Claude login modes:

# Create a new refreshable login (automatic localhost callback by default).
shunt login claude --name primary --mode oauth

# Import your current refreshable Claude Code login.
shunt login claude --name imported --mode import

# Generate and store a one-year, inference-only setup token.
shunt login claude --name backup --mode setup-token

On a TTY, omitting --mode opens a three-way prompt with OAuth selected by default; non-interactive input retains the historical import default. --long-lived is a deprecated alias for --mode setup-token. Full OAuth normally completes through a one-shot 127.0.0.1 callback. Use --manual to paste <code>#<state> instead; shunt also falls back to manual paste if browser launch, callback binding, or the 5-minute wait fails.

Then use name-only entries:

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

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

Store files live at ~/.shunt/accounts/claude/<name>.json; set SHUNT_CLAUDE_ACCOUNTS_DIR to override the directory. If the configured accounts list is empty, shunt scans the store and uses all valid JSON account files in filename order. Store files are private (0600, with a 0700 directory on Unix).

For remote operators, the opt-in admin web surface can provision either refreshable full-OAuth accounts or one-year setup-token accounts in a browser and show the pool’s current health. Importing an existing credential file remains CLI-only.

Full OAuth creates a fresh refreshable credential; import copies the current ~/.claude/.credentials.json credential into shunt’s store. Both preserve refresh capability, and import also records the current account UUID. Setup-token mode runs the same one-year, inference-only PKCE flow as claude setup-token; after approval, shunt exchanges the displayed authorization code and stores both the token and its issuing account UUID without printing the token. This keeps metadata.user_id.account_uuid aligned when the pool selects a different account. Reusing a name replaces that account’s store file. Existing external setup tokens still need token_env plus an explicit uuid.

Account fields

Field Required Meaning
name yes Unique label containing only lowercase letters, digits, and hyphens. Without another source field, resolves the matching shunt store file.
credentials one usable source Claude Code .credentials.json-shaped file. ~/ is expanded. shunt refreshes near expiry and atomically writes refreshed tokens back.
token_env one usable source Environment variable containing a setup token. The value is used verbatim and cannot be refreshed after a 401.
uuid no Selected account’s Anthropic UUID for rewriting an existing metadata.user_id.account_uuid, and the stable identity used to coalesce aliases in the pool. A name-only entry (resolved by a store scan) is filled in automatically from the store’s shuntAccountUuid before selection runs; a credentials- or token_env-configured entry’s identity is its uuid when set, else its name, and it coalesces with another alias whenever that identity equals the other alias’s explicit uuid or name-fallback identity — set a matching nonblank uuid on both entries for a clear, intentional coalesce (shunt also warns on an accidental cross uuid/name collision).
threshold no Per-account soft quota threshold in [0.0, 1.0] for every window without a per-window value. A low value marks a backup account that rotates out early.
threshold_5h / threshold_7d / threshold_fable no Per-window soft thresholds; each beats threshold for its window.
max_utilization no Per-account hard cap in [0.0, 1.0] for every window without a per-window value. An account at or above it in a governing window is excluded from selection (see Hard caps). Works without [server.pool].
max_utilization_5h / max_utilization_7d / max_utilization_fable no Per-window hard caps; each beats max_utilization for its window. The Fable cap governs Fable-model requests only.
priority no Selection priority when the sticky account is unhealthy; lower is preferred, default 100.
disabled no true removes the account from selection entirely while keeping it in config and on the admin dashboard.

Do not set both credentials and token_env on one account.

Selection and proactive rotation

  • With x-claude-code-session-id: a stable hash picks the sticky account. If that account is available and under the switch threshold, shunt keeps it first.
  • Without the header, or with it sent blank: each provider has its own round-robin counter. A blank value is not a conversation, so it shares that rotation instead of hashing to one fixed account.
  • On every upstream response handled by the claude_oauth account pool, shunt records these headers when present:
    • anthropic-ratelimit-unified-5h-utilization, anthropic-ratelimit-unified-7d-utilization, and anthropic-ratelimit-unified-7d_oi-utilization;
    • anthropic-ratelimit-unified-5h-reset, anthropic-ratelimit-unified-7d-reset, and anthropic-ratelimit-unified-7d_oi-reset (Unix seconds); and
    • anthropic-ratelimit-unified-status, anthropic-ratelimit-unified-5h-status, anthropic-ratelimit-unified-7d-status, and anthropic-ratelimit-unified-7d_oi-status.
  • The default switch threshold is 0.98. An account is near quota when its 5-hour status is rejected, shared 7d status is rejected, a Fable request has 7d_oi status rejected, shared 5-hour utilization reaches its threshold, or the governing weekly utilization reaches its threshold. Status rejection is evaluated independently of whether the matching utilization header is present. For utilization thresholds and headroom, Fable models use 7d_oi when that utilization is present and shared 7d otherwise. The aggregate status is a compatibility fallback only when no per-window status has been recorded. Thresholds can be lowered per account (threshold* fields above) or pool-wide (see Tuning selection).
  • The 5-hour bucket applies to every model. Fable model ids use the 7d_oi weekly bucket when its utilization is present, with shared 7d as fallback. Every other model family uses shared 7d; Sonnet also uses 7d because there is no Sonnet-specific header today.
  • A near-quota, cooled, or disabled sticky account rotates off proactively. shunt prefers available under-threshold accounts ordered by priority (lower first), then by the soonest-resetting governing weekly bucket, spending use-or-lose quota first. Accounts with unknown weekly reset sort first. Available near-quota accounts follow, then cooled accounts ordered by soonest recovery. With [server.pool] configured, burn-rate headroom replaces the weekly-reset tiebreak (see below).
  • Local quota and cooldown state never empties the attempt order: every non-disabled, unpaused account remains in it, even if all are near quota or cooled. The one exception is a hard cap (max_utilization*, see Hard caps), which excludes an account while its utilization is at or above the cap.
  • Utilization expires at the earlier of its own observation-time cap or that window’s reset. If only the observation cap has passed while that window’s reset is still in the future, shunt clears that window’s utilization and its timestamp but preserves that future reset as standalone metadata. Per-window status expires at the earlier of its own observation-time cap or the reset boundary captured when that status was observed; clearing status also clears its captured boundary. Later usage and reset-only updates cannot move a captured status boundary. A passed reset for that window clears that window’s utilization and reset, and clears status when its captured boundary has also passed. A stamped aggregate status has its own lifetime and survives an unrelated window expiry; only the legacy unstamped aggregate follows window expiry. A non-Fable success clears the account-wide cooldown but leaves a Fable-only cooldown intact; a Fable success clears both cooldowns.

The pool’s selection, cooldown, and quota state survives config hot reloads for the life of the process. Reactive failover remains active if proactive rotation cannot avoid the upstream limit.

Tuning selection ([server.pool])

The optional [server.pool] table (issue #135) adds soft per-window thresholds and burn-rate–aware ordering on top of the behavior above. Without the table, selection uses the single built-in 0.98 threshold exactly as before.

[server.pool]
# hard_threshold = 0.98      # (default) backstop; at/above always sorts last
default_threshold = 0.9      # soft default for every window
default_threshold_5h = 0.95  # per-window overrides
default_threshold_fable = 0.85
burn_rate_avoidance = true   # avoid accounts projected to hit a threshold before reset
usage_refresh_seconds = 300  # reconcile out-of-band usage for refreshable accounts
state_path = "shunt-state.json"  # persist quota across restarts (warm start)
ramp_initial_concurrency = 2 # storm control: slow-start a freshly switched account

[[providers.anthropic.accounts]]
name = "primary"
priority = 1                 # preferred whenever the sticky account is unhealthy

[[providers.anthropic.accounts]]
name = "backup"
threshold = 0.5              # backup: rotate out once half its quota is spent

[[providers.anthropic.accounts]]
name = "spare"
disabled = true              # kept configured, never selected
  • Threshold resolution. For each window X (5h, 7d, fable), the effective soft threshold is: account threshold_X → account threshold → default_threshold_X → default_threshold → hard_threshold, capped at hard_threshold. All values are utilization fractions in [0.0, 1.0]; out-of-range values fail shunt check.
  • Burn-rate headroom. From each window’s utilization and reset instant (window lengths are fixed at 5 hours and 7 days), shunt projects the time until the soft threshold is hit at the observed average pace, minus the time until the window resets. Positive headroom means the account survives to its reset at the current pace. Available accounts of equal priority order by largest headroom; unobserved windows count as unlimited headroom.
  • Predictive avoidance. With burn_rate_avoidance = true, an account with negative projected headroom is treated as near quota and rotated off before it hits a threshold. Off by default — ordering by headroom happens regardless.
  • All-near guard. When every account is past a soft threshold (or predicted to exhaust), the pool does not empty: near accounts serve ordered by best headroom, while accounts at or above hard_threshold still sort last, followed only by cooling accounts.
  • Scope. The quota knobs act on both pool families: this pool from its anthropic-ratelimit-unified-* headers, and Codex pools from their reported x-codex-* 5h/7d windows (issue #195). Codex has no Fable-scoped 7d_oi window, so default_threshold_fable is inert there; priority and disabled apply everywhere.
  • The admin pool endpoint (GET /admin/api/pool) reports each account’s priority, disabled flag, and — when [server.pool] is configured — its current headroom projection in seconds; the dashboard’s state column marks disabled accounts.

Hard caps (max_utilization)

Soft thresholds only reorder accounts. A hard cap removes an account from selection for as long as its utilization is at or above the cap.

[server.pool]
default_max_utilization = 0.95     # every window, unless overridden
default_max_utilization_5h = 0.9   # per-window default

[[providers.anthropic.accounts]]
name = "primary"
max_utilization_fable = 0.5        # Fable stops at ~50% of this account's Fable weekly bucket; Opus keeps using it
  • Keys. Per account: max_utilization, max_utilization_5h, max_utilization_7d, max_utilization_fable. In [server.pool]: default_max_utilization, default_max_utilization_5h, default_max_utilization_7d, default_max_utilization_fable. Values must be in [0.0, 1.0]; anything else fails startup and shunt check.
  • Resolution. For each window: account max_utilization_X → account max_utilization → default_max_utilization_X → default_max_utilization → no cap. Caps are not clamped to hard_threshold. Account caps work without [server.pool]; store-scanned accounts (no [[providers.anthropic.accounts]] entry) get the pool defaults only.
  • Excluded, not reordered. An account whose observed utilization in a governing window is at or above its cap is excluded from selection. The 5h and 7d caps govern every request; the Fable cap governs Fable-model requests only, and only once the account’s 7d_oi utilization has been observed. A window with no observed utilization never excludes.
  • Recovery. The account becomes eligible again automatically once its utilization falls below the cap: through window reset or expiry, a lower observation, or a raised cap on config reload. A cap stops new routing; it is not a precise budget, and nothing reserves usage for dispatched requests, so in-flight or concurrent requests can overshoot the cap by whatever they consume.
  • Exhaustion. When caps leave a pool with no selectable account, shunt returns a gateway-owned HTTP 429 rate_limit_error (“all selectable accounts for provider ‘X’ are at their max_utilization cap for this request”), with retry-after when the earliest eligibility is known. Like an upstream 429, it advances the [[upstreams]] failover chain, so a later upstream (possibly pay-per-use) serves the request; only when none does does the client see the 429. The exception is a streaming request already committed to a Codex/ChatGPT pool (HTTP 200 sent): when no later upstream serves it, the client receives a terminal SSE error event inside the stream, not an HTTP 429, and no retry-after header. On the inbound Codex endpoint the same error comes back in the OpenAI error shape.
  • Providers. Effective on Claude OAuth pools (5h, 7d, 7d_oi) and Codex/ChatGPT pools (5-hour and weekly windows; the Fable keys are inert there). Caps also apply to Kimi OAuth pools when the Kimi upstream reports utilization headers; it has not been observed to, so in practice they stay inactive there, and an unobserved window never excludes. Antigravity quota does not feed caps.
  • Status. The admin dashboard shows a capped account as “Capped”, or “Capped (Fable)” for a Fable-only cap, and marks each configured cap on the account’s usage bars and in the pool table; GET /admin/api/pool reports the resolved max_utilization_5h, max_utilization_7d, and max_utilization_fable per account (null when no cap governs the window). GET /usage reports pool status capped when no account is available and at least one enabled (non-disabled), unpaused account is capped.

Usage-API reconciliation

Quota headers only reflect traffic that flowed through shunt. usage_refresh_seconds closes that gap by shunt itself calling out to Anthropic’s GET /api/oauth/usage and applying authoritative utilization, each reported window’s own reset time, and its utilization observation time to the same 5-hour, shared weekly (7d), and Fable-scoped weekly (7d_oi) windows.

Polling is off when the field is absent or 0; positive values below 60 are clamped to 60 seconds. Only imported, refreshable accounts are eligible. Long-lived claude setup-token and token_env accounts are skipped because their tokens cannot call the endpoint. The interval is fixed at boot, so a config reload does not start, stop, or re-tune the poller. This periodic correction refreshes each window’s utilization, reset, and utilization observation time. Only per-window and aggregate status freshness and the reset boundary captured when a status was observed remain header-driven.

This is the outbound direction. shunt can also serve the same path inbound: [server.oauth_usage] registers GET /api/oauth/usage on shunt itself, so the Claude Code CLI’s own native usage bars get real numbers when the CLI is pointed at shunt (Claude-only, routing-aware priority-tiered worst case per window — not this section’s pool-wide reconciliation; see the endpoint reference and the M14 note for the precondition evidence — partially verified, with one leg presumed rather than directly observed — on which CLI login modes actually call it). Do not point a claude_oauth provider’s base_url at this gateway’s own bind while [server.oauth_usage] is enabled — shunt refuses to boot in that configuration (ConfigError::OauthUsageSelfPollLoop), because the outbound poller above would otherwise read back its own synthesized inbound aggregate instead of Anthropic’s real usage.

Quota-state persistence

Pool quota lives in memory, so a restart begins cold: every account looks unseen until its first post-restart response, which disables burn-rate avoidance and leaves GET /usage blank until traffic re-populates the pool. Setting state_path persists each account’s per-window utilization and reset to that file, so the pool warm-starts from the last observed state.

The file is a best-effort cache, not a source of truth — quota is re-derived from upstream responses regardless, so a missing, stale, or corrupt file only costs a cold start, never a boot failure. Writes use a private (0600 on Unix) temp file, atomically rename it over the target, and happen on a 15-second background timer only when quota changed. Failed writes remain dirty and retry on the next tick. Cooldowns are not persisted (they lapse on restart), and any restored window whose reset has already passed is dropped during import, before the first selection or snapshot after restore. Utilization and per-window status have independent reset-less lifetimes; a future reset for that window may remain after that window’s utilization expires, while a captured status reset is cleared with its status. Version-2 files migrate through the legacy path and are rewritten as version 3. An aggregate status without observed_at_status captures the earliest persisted reset_5h, reset_7d, or reset_7d_oi as an immutable deadline. If that reset has already passed, the expired reset, the unstamped aggregate status, and its synthesized stamp are removed during the same import. A reset beyond the plausible seven-day horizon is conservatively bounded at boot plus seven days; an aggregate with no reset starts its seven-day cap at boot. Existing v2 stamps are not reinterpreted from reset metadata, while normal import still normalizes orphan metadata, expires elapsed signals, clamps future timestamps to boot, and supplies boot time to a surviving unstamped aggregate when appropriate. Later reset-only or usage updates cannot extend the captured deadline, and the result remains equivalent after the v3 rewrite and a second restore. A version-3 reset-less status stays reset-less after reset-only updates. The path is fixed at boot; persistence is off when the field is absent.

Failover rules

Response Behavior
2xx Relay and mark healthy.
429 with only anthropic-ratelimit-unified-7d_oi-status: rejected, on a Fable request Fable quota exhausted: cooldown applies only to Fable requests using numeric retry-after (default 60s, clamped to 1–3600s), then rotate. Non-Fable requests can still use the account. For a non-Fable request, the same 7d_oi-only rejection creates an account-wide cooldown.
429 with anthropic-ratelimit-unified-5h-status: rejected or -7d-status: rejected Shared quota exhausted: account-wide cooldown using numeric retry-after (default 60s, clamped to 1–3600s), then rotate for every model. This remains account-wide when 7d_oi is also rejected.
Plain 429 Transient throttle: wait using numeric retry-after (default 1s, cap 300s), retry the same account once, then relay that retry response.
401 with credentials Force-refresh, retry the same account once; if still 401, cooldown 5 minutes and rotate.
401 with token_env or a store-managed setup token Cannot refresh: cooldown 5 minutes and rotate.
5xx Cooldown 30 seconds and rotate.
Transport failure (no response, such as a dropped connection or TLS error) Resend to the same account once, including during a throttle or refresh retry; only a second transport failure cools down 30 seconds and rotates. A timeout is not resent. The resend is a full re-POST, so in rare cases the turn runs twice.
Other status Relay without failover.

Classification happens before the response body streams, so a mid-stream failure is never replayed. If the pool exhausts its attempts after receiving responses, the client gets the last real upstream status and body. If every account fails before any upstream response, shunt returns a gateway-owned error.

Anthropic-routed POST /v1/messages/count_tokens requests use the same pool.

Keep a Claude Sonnet 5.5 conversation on one account

Anthropic binds the thinking blocks that Claude Sonnet 5.5 produces to the account that produced them (Preserved thinking). When a different account sends one of those blocks back, the API drops it before the model sees it. The request still succeeds, but the model answers without that earlier reasoning. Thinking blocks from earlier models are not affected.

The pool is session-sticky, so a conversation normally stays on one account. It moves to another account in these cases:

  • The sticky account is near quota, cooled down, or disabled, so proactive rotation moves the session off it.
  • A response triggers a rotation under the failover rules: a quota 429, a 401 that a refresh does not fix (a setup token cannot be refreshed, so any 401 on one rotates), or a 5xx, or a transport failure that repeats on the immediate resend.
  • The pool gains or loses a distinct account, for example a new shunt login claude store entry, which the pool picks up without a restart. The sticky account is chosen by hashing the session id over the pool’s distinct accounts, so most existing sessions map to a different account.
  • The request has no x-claude-code-session-id, so it uses round-robin.

After a move, later Sonnet 5.5 turns run without the reasoning produced on the previous account. Nothing fails. shunt does not add the thinking-binding-controls-2026-08-01 beta header, so the response does not report the drop unless the client sends that header itself (shunt keeps the client’s anthropic-beta values and only appends oauth-2025-04-20 when it is missing). shunt does not log the drop.

If you want every Sonnet 5.5 request on one account, give that model its own upstream scoped to that account. Account scoping uses the ordered [[upstreams]] form (see Configuration, section [[upstreams]]), which cannot be combined with [providers.*] in one config file. Store the accounts with shunt login claude and refer to them by name:

[server]
default_provider = "anthropic"

# Declared first, so it is tried first for every model that maps to it.
[[upstreams]]
name = "anthropic-sonnet55"
provider = "anthropic"
auth = { mode = "claude_oauth", account = "primary" }

# Every other model: the whole Claude store, with the usual rotation.
[[upstreams]]
name = "anthropic"
provider = "anthropic"
auth = "claude_oauth"

[[models]]
id = "claude-sonnet-5-5"
[models.upstream_model]
anthropic-sonnet55 = "claude-sonnet-5-5"
anthropic = "claude-sonnet-5-5" # remove for a strict pin

Sonnet 5.5 requests go to primary first. With the anthropic line in the model map, a failure on primary that advances the chain (such as a quota 429 or a 5xx) falls through to the whole pool. A turn can use only the Sonnet 5.5 reasoning produced on the account that serves it, so a turn served by another account runs without the reasoning produced on primary. Because the whole-store pool includes primary, it can also retry primary, which keeps the reasoning produced there. The request fails only when every account the pool tries fails. Without that line, the pin is strict: the client receives primary’s error instead, and nothing moves the conversation.

Keep these trade-offs in mind:

  • Every Sonnet 5.5 session spends the same account’s 5-hour and weekly quota.
  • primary also stays in the anthropic pool, which scans the whole store. To reserve it for Sonnet 5.5, scope the pool instead, for example auth = { mode = "claude_oauth", accounts = ["backup", "team-2"] }.
  • Quota, cooldown, and refresh state belong to the account, not the upstream. A store account listed in both upstreams shares one state and one refresh lock. An inline account table is keyed per upstream when shunt cannot identify it: it has no uuid, and its credential carries no shuntAccountUuid. Repeating such a credential inline in two upstreams splits its state and refresh lock, so refer to a store account by name instead.
  • shunt check does not verify that a scoped name exists in the store. A missing name fails when a request resolves the pool.

Request and response changes

For the selected account, shunt replaces client auth with:

Authorization: Bearer <selected OAuth token>
anthropic-beta: ...,oauth-2025-04-20

It removes both incoming authorization and x-api-key, appends oauth-2025-04-20 only when absent, and preserves other end-to-end headers.

Pooled responses identify the account:

x-shunt-account: backup

Use neutral account names on a shared gateway. This header exposes the configured label to every authorized client that receives the response. The final last-upstream-response relay after pool exhaustion omits x-shunt-account.

account_uuid

Claude Code may encode account metadata as JSON inside the string-valued metadata.user_id. If the selected account has uuid, shunt replaces an existing inner account_uuid with that value. It leaves the body untouched if the metadata is absent, malformed, lacks account_uuid, or the selected account has no UUID. It does not inject missing metadata.

Security constraints

claude_oauth is accepted only when:

  • the provider has kind = "anthropic";
  • base_url uses HTTPS; and
  • its host is anthropic.com or a subdomain such as api.anthropic.com.

These startup checks prevent an OAuth bearer from being sent off-origin or over plaintext. The HTTPS and host checks are relaxed for loopback hosts (localhost, 127.0.0.1, [::1], etc.): a loopback base_url may use plain HTTP and any host, so a local debugging proxy or mock can receive the traffic — the bearer cannot leave the operator’s machine. Non-loopback hosts are always held to HTTPS + anthropic.com. On a shared deployment, also configure [server.auth] because claude_oauth spends gateway-owned credentials. Clients then authenticate with the ANTHROPIC_AUTH_TOKEN they already send (accepted as the client token via Authorization: Bearer, alongside x-shunt-token and x-api-key) — on a pool-only gateway no ANTHROPIC_CUSTOM_HEADERS line is needed.

Storm control

Setting [server.pool] ramp_initial_concurrency (off by default) gates concurrent admissions per account identity with a slow-start ramp, so a failover switch cannot stampede the freshly selected account with every in-flight request at once. An identity that just started taking traffic admits at most the configured number of concurrent requests; each successful response doubles the allowance, a failover restarts the ramp, and a denied request spills to the next account in selection order (the last candidate is always attempted). See [server.pool].

The implementation behavior was informed by KarpelesLab/teamclaude and the shipped Claude Code binary. shunt has no runtime dependency on teamclaude.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close