@dp-pcs/stacked
v0.2.7
Published
Run Claude Code on whichever of your accounts has the most quota left, and roll over when one runs out.
Maintainers
Readme
stacked
Run Claude Code on whichever of your accounts has the most quota left, and roll over automatically when one runs out.
If you have more than one Claude account and keep hitting usage limits, the
manual fix is to log out, log in as someone else, and lose your session.
stacked does that for you, without the logout.
- What it actually does
- Requirements
- Install
- Setup
- Daily use
- Keep your model across rotations
- Waiting for a reset
- Reading
stacked status - How it picks an account
- Telling accounts apart
- Working through a gateway
- Billing safety
- Troubleshooting
- Try it without changing anything
- Known limits
- Testing
What it actually does
Claude Code decides which account you are on by reading one environment
variable, CLAUDE_CODE_OAUTH_TOKEN. stacked stores a token per account in
your macOS Keychain, checks how much quota each has left, and launches Claude
Code with the best one set. That is the whole trick — there is no logout, no
login, and no browser round trip.
It configures nothing else. Routing, model names, gateway headers — all of
that stays in Claude Code's own ~/.claude/settings.json, which stacked reads
and never writes. If your org routes you through a gateway, stacked follows it
automatically. There is exactly one source of truth and stacked is not it.
Requirements
- macOS (uses the system Keychain via
security) - Claude Code, already installed and working
- Node 18+ (only to install; the tool itself is shell)
- Two or more Claude accounts you are entitled to use
Install
npm install -g @dp-pcs/stackedOr without installing:
npx @dp-pcs/stacked setupSetup
stacked setupThe wizard checks your Claude Code config, then asks for one Keychain entry per account. If an entry already exists it validates it; if not, it offers to store one for you. Prefer to do it by hand, or scripting it for a team? See MANUAL.md.
To get a token for an account: log into that account in Claude Code, then run
claude setup-token. Repeat per account.
Each token is checked before it is saved. If a paste or verification fails, choose Retry, Skip, or Cancel without restarting setup. Accounts you have already completed remain registered, and rerunning setup preserves existing accounts. A confirmed quota limit is accepted; an authentication, network, or gateway error leaves the previous token untouched.
To replace the token for just one registered account:
stacked account repair claude_workThe replacement is hidden while you paste it, validated through the account's configured route, and saved only after verification. Its registry entry and model group are preserved.
Name your entries carefully. No API response identifies an individual seat, so stacked cannot tell you which login a token belongs to. The name you choose is the only label you will ever have.
claude_personalandclaude_workbeatclaude_1andclaude_2.
Daily use
stacked run # launch on the best account
stacked run --auto-rotate # interrupt and resume if it truly exhausts
stacked run --overnight # auto-rotate, and wait out a reset
stacked run --dangerously-skip-permissions # flags pass straight through
stacked run -p "summarise this repo" # non-interactive too
stacked -p "summarise this repo" # a leading flag implies `run`Anything you would type after claude, type after stacked run. There is no
separator and no special syntax.
If you always use the same flags, alias it:
alias cs='stacked run --dangerously-skip-permissions'When the account you are on runs out mid-session, stacked rotates to the next
one and relaunches with --continue, re-applying the flags you originally
passed. You will see it happen; it is not silent.
By default, stacked will not kill a live Claude Code session. Add
--auto-rotate when unattended continuation matters more than preserving the
current in-flight response. In that mode, the watcher interrupts Claude Code
only after the active account is truly exhausted, then stacked relaunches on
the next usable account with --continue. --overnight is the same opt-in
behavior with a shorter default watch interval for long unattended runs.
In either unattended mode, running out of every account is no longer the end of the run: stacked waits for the earliest quota window to reset and then carries on. See Waiting for a reset. Ctrl-C stops it.
Keep your model across rotations
Choose the model when you launch. stacked passes --model to Claude Code and
re-applies it on every rotation:
stacked run --overnight --model 'opus[1m]'
stacked run --overnight --model 'sonnet[1m]'Use [1m] only when your model and account route support 1M context. Without
it, an unrecognized gateway model name can make Claude Code assume a 200K
window and compact there even when the backend supports more. Quote names
containing brackets so your shell does not expand them.
For a preference you set once, add an alias to ~/.zshrc:
alias stacked-opus="stacked run --overnight --model 'opus[1m]'"Run stacked-opus afterward, or use stacked run --model ... for a different
choice. stacked has no separate stored model preference or model-selection
prompt. This preserves the launch selection; a later /model change inside
Claude Code is not captured by stacked and can revert on rotation.
In this version, detecting a gateway group makes stacked set Sonnet as the
child's default. An explicit --model takes precedence, so use it even if you
already chose a default in Claude settings. The opus and sonnet aliases
follow each account's group; an explicit <group>/<model> stays fixed, including
its group. See the TrueFoundry example
for versioned model names and live validation details.
When an account runs out mid-session
stacked cannot switch accounts while Claude Code is running — auth is read at startup, and killing a live session to force a rotation risks losing unsaved conversation state. So the handoff is:
- A desktop notification warns you as the account approaches its limit
- You exit Claude Code when it suits you
- stacked immediately relaunches on the next account with
--continue
While a session runs, a background watcher polls the active account every 5 minutes and notifies twice at most: once at 85% utilization ("wrap up soon"), and once if it runs dry ("exit and stacked will switch accounts and resume"). Notifications rather than terminal output, because Claude Code owns the terminal while it runs.
A failed poll — a network blip, or a poll that was itself rate-limited — is not
treated as the account dying; the watcher rides out up to
STACKED_WATCH_MAX_FAILURES consecutive failures (default 5) before giving
up, so one blip no longer silently ends your quota warnings for the session.
Turn it off with STACKED_WATCH=0; tune with STACKED_WATCH_INTERVAL
(seconds) and STACKED_WATCH_WARN (0–1).
For unattended sessions, stacked run --auto-rotate lets the watcher interrupt
Claude Code after true exhaustion and relaunch on the next usable account with
--continue. stacked run --overnight enables auto-rotation and uses a
60-second watch interval unless STACKED_WATCH_INTERVAL is already set.
Waiting for a reset
Unattended only — --auto-rotate or --overnight. Without it, stacked run
still gives up the moment nothing is usable, exactly as before.
A 5-hour window is shorter than a night, so an account that runs dry at 22:00 is usable again around 03:00. Rotating alone cannot exploit that: once every account has been tried the run is over, and stacked would sit dead while all of them quietly refilled. So when nothing is usable, it waits.
Where the earliest opening is takes two steps, and the direction matters in both:
- Within an account, the latest reset among the windows currently blocked. An account is usable only when every window is allowed, so a seat blocked on both its 5-hour and 7-day windows comes back on the later of the two.
- Across accounts, the soonest of those.
Then it sleeps until then and resumes the same conversation with --continue,
forgetting which accounts it had ruled out — the ones excluded earlier are
precisely the ones that just reset.
Not every exhausted account says when it resets: an HTTP 429 carries no headers
at all. When nothing gives a time, stacked re-probes every STACKED_WAIT_POLL
seconds (default 15m) instead of guessing. A reset epoch that has already passed
while the server still says rejected is treated the same way — reset times
round to ten-minute buckets and clocks drift, so believing it would mean picking
the account, being rejected immediately, and looping.
Bounds, because a hands-off run should not sit forever:
STACKED_MAX_WAIT(default86400, one day) is the total time spent sleeping across the invocation. Time spent actually running Claude Code does not count against it.- If the earliest reset is further out than the budget allows — a 7-day window can be days away — it stops immediately and says so, rather than sleeping out the whole day to fail at dawn.
- Accounts whose token is rejected are never waited on; a revoked token does not come back on a timer. If that is all of them, the run ends at once.
STACKED_MAX_WAIT=0turns waiting off entirely.STACKED_WARM=1opts in to pre-warming; it is off by default.
Every rotation, wait and resume is timestamped on stderr, and notified, so a redirected log tells the whole story:
stacked: [2026-09-05 02:14:07] acct_c is out of quota — rotating and resuming
stacked: [2026-09-05 02:14:09] every account is out of quota (tried: acct_a,acct_b,acct_c) — waiting for the earliest reset
stacked: [2026-09-05 02:14:11] waiting 2h46m before the next check
stacked: [2026-09-05 05:00:22] acct_a is usable again — resumingKeeping idle accounts from going stale
The same two modes can also pre-warm, with STACKED_WARM=1. It is off by
default: every warm is a real API call, and whether starting a window early
helps at all is not yet established (see the caveat below).
A 5-hour window only starts when an account is used, and the in-session watcher polls only the account you are on — so an account left idle through a long session does not begin its next window until stacked finally rotates to it, pushing every later reset further out. The account you reach last ends up being the one still ticking when you would rather it had already reset.
So a single probe fires just after an idle account's window expires, starting the next one immediately. Over a night that means more window boundaries and less waiting.
Three things worth being clear about:
- It adds no quota. The weekly cap is untouched; this only changes when capacity lands.
- It cannot start a window early. There is no way to reset one — a request inside a live window does nothing — so the probe fires after expiry, never before.
- At most one probe per account per window. Each
<account, reset-epoch>pair is tried once: if a probe comes back with the same overdue epoch, the window did not start and it is never retried. One account per watcher tick, so several expiring together never burst against the per-IP limit.
The caveat, and why it is off by default. Whether this helps depends on
windows anchoring to use rather than advancing on a schedule of their own, and
the evidence is not unanimous — an account observed at 0.0% utilization still had
a live window. So every warm logs the reset epoch before and after, and
warmed <account> lines in an overnight log say either next window now resets
in ... or reset did not move. Turn it on for a night, read the log, and decide
from your own data.
Two practical notes. A laptop that sleeps cannot wake to meet a reset — it
resumes when you open the lid — so wrap long runs in caffeinate:
caffeinate -is stacked run --overnight -p "the task"And the run exits 75 when the budget is spent, distinct from 1 for a
configuration or credentials problem, so a wrapper can retry on the former:
until stacked run --overnight -p "the task"; do
[ $? -eq 75 ] || break # anything else is not going to fix itself
sleep 3600
doneReading stacked status
ACCOUNT HEALTH 5H 7D RESETS CRED ORG
claude_personal healthy 13% 40% 0h17m 6bf512ff a8dc2ea4
claude_work healthy 1% 0% 3h37m 0c5a5eea 7493d817
claude_old invalid 0% 0% 517f2606| column | meaning |
|---|---|
| HEALTH | healthy, near-limit (usable — either past a configured threshold or carrying the server's own allowed_warning), exhausted (out of quota), invalid (token rejected), error (no response) |
| 5H / 7D | how much of the 5-hour and 7-day windows you have used |
| RESETS | when the 5-hour window rolls over |
| CRED | a local fingerprint of the token — see below |
| ORG | organization id, informational only |
How it picks an account
Each account is probed with a max_tokens: 1 call. That returns Anthropic's
rate-limit headers without meaningfully consuming quota:
anthropic-ratelimit-unified-5h-utilization: 0.13
anthropic-ratelimit-unified-7d-utilization: 0.40
anthropic-ratelimit-unified-5h-status: allowedAn account counts as exhausted only when Anthropic rejects it:
- the server reports a window as
rejected(or any status outside theallowedfamily) - the call returns HTTP 429
allowed_warning is not a rejection. Once a window passes a soft threshold
the server switches its status to allowed_warning, adds
anthropic-ratelimit-unified-<window>-surpassed-threshold (typically 0.75),
and carries on serving the request. stacked treats that as near-limit, not as
an outage — a weekly window at 78% must not retire an account whose 5-hour
window is untouched.
If utilization crosses STACKED_THRESHOLD (default 0.95), the account is
also near-limit: stacked will prefer another usable account, but it will still
use that account if it is the best remaining option. This keeps rotation
proactive without turning remaining quota into a false outage.
Among usable accounts, stacked picks the most headroom: 1 - max(5h, 7d).
The probe uses the same base URL and headers as your real traffic, so if you go through a gateway, so does the probe.
Picking an account probes every account back to back, and Anthropic rate-limits
/v1/messages per source IP — so with five or more accounts that fan-out starts
tripping limits on the probes themselves. To avoid that, probe results are
cached under ~/.config/stacked/cache for STACKED_CACHE_TTL seconds (default
300), with the expiry jittered ±25% so a batch written together doesn't all
expire together. stacked status marks when it's showing cached figures; set
STACKED_CACHE_TTL=0 to always probe live. The decision that actually gates
rotation — the check after a session ends — is never cached, and it refreshes
the cache on its way out. A probe that fails outright (dropped connection, or a
probe that was itself rate-limited) falls back to the last good reading for up
to STACKED_CACHE_GRACE seconds rather than blanking the account out of the
running.
Telling accounts apart
There is no header that identifies an individual seat. The API exposes only
anthropic-organization-id and anthropic-workspace-id, both org-scoped, so
several seats in one organization report identical values. stacked therefore
shows:
- CRED — a local
sha256prefix of the token. Always distinct per credential, computed on your machine; nothing is transmitted to produce it. - ORG — informational. Useful when your accounts span organizations, meaningless when they do not.
What actually matters is whether two entries share a quota pool, because
rotating between two tokens for the same seat gains you nothing. status
checks that two ways:
- The same token registered twice — caught by comparing CRED
fingerprints. Certain, not inferred. This is the mistake people actually
make: re-run
setup-token, store it under a second name, and now two entries point at one account. - Two different tokens for one seat — inferred from organization plus both reset epochs. Reset windows anchor to an account's first use, so two tokens for one seat report identical resets while separate seats do not. This holds even at zero utilization, so brand-new accounts are covered.
Utilization is deliberately excluded from that second check: it moves between probes, and including it would let two tokens for one seat be sampled either side of a usage tick and score as separate accounts.
Verified against five real accounts — a seat in its own organization, two distinct seats sharing an organization, and two tokens minted for one of those seats. Only the last pair is reported.
Resets appear to round to 10-minute buckets, so two genuinely separate seats first used in the same bucket could collide. That is why the note says probable, not certain.
Working through a gateway
If ~/.claude/settings.json points at a gateway, stacked follows it. Nothing
to configure here.
This works because your account token stays the Authorization bearer while
the gateway header only authenticates the hop in front of Anthropic. The
gateway forwards your credential, so Anthropic returns your seat's rate-limit
headers and rotation behaves exactly as it does direct. Every rotated account
stays visible in your gateway's telemetry.
It does not apply to shared-key gateways, where the gateway holds its own upstream credential. There everyone draws on one pool, there are no per-user accounts, and there is nothing for stacked to rotate.
Billing safety
Three things silently switch you from subscription usage to metered API
billing. stacked doctor checks all of them:
- a top-level
"model"key insettings.json ANTHROPIC_API_KEYset in your environmentANTHROPIC_AUTH_TOKENset in your environment
stacked run clears the last two for the process it launches, so a rotated
session is safe even if your shell is not. stacked supplies the selected setup
token through CLAUDE_CODE_OAUTH_TOKEN, not ANTHROPIC_AUTH_TOKEN. Plain
claude will still pick up shell values, so fix them at the source.
Claude Code Teams + Fable through a gateway
Claude Code has a known setup-token/OAuth entitlement bug where Team seats can be prompted for usage credits when selecting Fable. stacked exports the subscription entitlement workaround for the Claude Code process it launches, including Fable 5.1.
If your gateway uses prefixed model slugs, add the group as the optional second
column in ~/.config/stacked/accounts:
claude_work claude-work-groupor configure it in Claude Code settings by prefixing
ANTHROPIC_DEFAULT_HAIKU_MODEL. Do not add ANTHROPIC_AUTH_TOKEN; stacked
already passes the selected account token as CLAUDE_CODE_OAUTH_TOKEN.
{
"env": {
"ANTHROPIC_BASE_URL": "https://<your-gateway-host>",
"ANTHROPIC_CUSTOM_HEADERS": "x-<gateway>-api-key: <gateway-token>",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "<group>/claude-haiku-4-5-20251001",
"ANTHROPIC_DEFAULT_FABLE_MODEL": "<group>/claude-fable-5-1",
"ANTHROPIC_CUSTOM_MODEL_OPTION": "<group>/claude-fable-5-1",
"ANTHROPIC_CUSTOM_MODEL_OPTION_NAME": "Fable",
"ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION": "Fable 5.1",
"CLAUDE_CODE_SUBSCRIPTION_TYPE": "max",
"CLAUDE_CODE_RATE_LIMIT_TIER": "default_claude_max_5x"
}
}For a TrueFoundry-specific setup, see examples/truefoundry.md.
Troubleshooting
Start with stacked doctor. It reports config, every account, and the
billing checks in one pass.
| symptom | cause |
|---|---|
| invalid in status | credentials or access were rejected. Check gateway authentication if applicable, then use stacked account repair <name> to replace a revoked or mistyped token. |
| error in status | no response. Network, or a gateway rejecting the request — try stacked probe <account> for the raw reply. |
| no healthy accounts available | every account is exhausted or invalid. status shows when the earliest resets. In --auto-rotate/--overnight this means waiting was off (STACKED_MAX_WAIT=0) or every token is rejected. |
| exit 75 | the wait budget ran out, or the earliest reset is beyond it. status shows the resets; raise STACKED_MAX_WAIT, or wait for a 7-day window. |
| resumed, then went straight back to waiting | the window reopened but is still effectively full. stacked backs off to STACKED_WAIT_POLL rather than spinning. |
| unknown command | you typed a subcommand that does not exist. A leading flag is fine (stacked -p ...); a bare word is not. |
| "share a quota pool" note | two entries look like the same seat. If you know they are separate, ignore it. |
| unrecognized-model warning / compacts at 200K | Claude Code cannot identify the gateway model's context window. Launch with an explicit model and [1m] if the route supports it; use an accurate modelOverrides mapping or recognized versioned model name for model identity. See model selection. |
Try it without changing anything
./scripts/sandbox.sh # as a direct user
SANDBOX_GATEWAY_URL=https://your-gateway ./scripts/sandbox.sh # as a gateway userPoints CLAUDE_CONFIG_DIR and STACKED_CONFIG_DIR at temporary directories,
runs against them, and deletes everything on exit. Your real config is never
read or written. Pass any subcommand to run that instead of setup.
Known limits
- Rotation happens between sessions, not inside one. Auth is read at
startup, so stacked rotates and relaunches with
--continueafter Claude exits. The watcher tells you when to exit, but the exit is yours to make; it is not a seamless mid-conversation swap. - Each probe is a real API call.
statuscosts one per account, and each poll of a wait costs one per account. --continueresumes the most recent conversation in that directory. After a multi-hour wait that is no longer a precise handle: if anything else ranclaudethere overnight, the resume lands on the wrong conversation. Do not leave two waiting runs in the same directory.- A sleeping laptop resumes at wake, not at the reset. Use
caffeinate -is. - macOS only.
- The quota-pool check is a heuristic. It is right on every case tested, but it reasons from reset windows rather than an identity the API will confirm.
Testing
./tests/run_all.shThe suite is fixture-driven and stubs external commands, so it should not read
your real Keychain, call Claude Code, or consume quota. STACKED_SIMULATE_EXHAUSTED=<account>
forces a failover so rotation can be exercised without waiting for a real
limit. The wait tests pair STACKED_SLEEP_CMD with STACKED_SIMULATE_CLOCK — a
stubbed sleep that returns at once and pushes a virtual clock forward — so an
eight-hour wait is covered in milliseconds.
Contributor notes live in CONTRIBUTING.md.
