claude-multiacc
v2.0.19
Published
Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.
Downloads
10,632
Maintainers
Readme
claude-multiacc
Multi-account addon for Claude Code AND OpenAI Codex CLI: every claude /
claude -p invocation — and every codex / codex exec invocation — runs under a
randomly picked subscription account with limit headroom (no API keys, ever). The
account sets mirror automatically from the Mac (source of truth) to the deploy server.
Installs as PATH shims + helper CLIs from this repo. Never touches the Claude Code or
Codex apps: no binary edits, no install-dir writes, survives claude update /
codex update — the real binaries are resolved dynamically at exec time.
One install covers both: the same install.sh / npm i -g claude-multiacc wires up
the claude shim + claude-accounts CLI (pool at ~/.claude-accounts) and the
codex shim + codex-accounts CLI (pool at ~/.codex-accounts). The two pools are
completely independent — separate manifests, credentials, telemetry, and logs — so
either provider can be used, re-authenticated, or emptied without touching the other.
Tested on: macOS (bash 3.2, zsh, Claude Code 2.1.207+, Codex CLI 0.147) and Ubuntu 24.04 (bash 5.2). The compatibility suite currently covers 645 sandboxed cases with no network/quota use; the unified selector has its own adversarial contract suite.
Unified selector for app-robot
DEFAULT_ENGINE=both uses one pure selector across the Claude and Codex pools. It
does not change either interactive shim and never launches an agent itself:
multiacc-select --request-json - --response-json -
# equivalent npm dispatcher:
claude-multiacc select --request-json - --response-json -The command reads one strict UTF-8 JSON request and emits one JSON response using
the versioned claude-multiacc/pool-selection.v2 protocol. It ranks the caller's
locked candidate snapshot by usable weekly/session headroom and returns one concrete
provider/account plus canonical snapshot and selection digests. both is a policy,
never an execution engine. The caller remains responsible for durable reservations,
database locks, retries, and launching the selected CLI.
Successful responses also carry eligible_count and eligible_alternative_count,
so callers can persist proof that sole-account reviewer fallback was unavoidable.
Run multiacc-select --version for the selector protocol version (currently
2.0.1). The npm package version is independent and remains available through
claude-multiacc --version.
See the complete selector contract for the request fields,
ranking rules, stable errors, and caller boundary.
How it works
MAC (source of truth) SERVER (mirror)
~/.claude-accounts/ /root/.claude-accounts/
accounts.json <- manifest accounts.json (synced)
acct-01 -> ~/.claude ("adopted") acct-01/server.token (synced, 600, once minted)
acct-02/ (registered) acct-02 -> /root/.claude ("adopted")
server.token / .credentials.json
(or the login lives in the macOS Keychain — see below)
selection.log sync.log health.log /usr/local/bin/claude -> repo shim
repo bin/ first on PATH (rc-file block) /root/claude-multiacc/ (addon repo)Shim selection order (identical file on both machines, bin/claude):
CLAUDE_CONFIG_DIRorCLAUDE_CODE_OAUTH_TOKENalready set → pass straight through.CLAUDE_ACCOUNT=acct-NN→ use that account (even if limit-marked — explicit wins).- Drop every account that cannot serve a request — a dead login (expired refresh token, revoked grant, a run that failed to authenticate) or an org-blocked one (Claude Code disabled for that account). Those can only fail, so they never enter selection.
- Among the accounts that remain valid on this machine (an OAuth login —
.credentials.json, or a macOS Keychain item this session can open — or aserver.tokenthat passes its first inference preflight) and not limit-excluded, pick the one with the most remaining headroom (see below). On equal headroom the pool rotates away from the account it just handed out and samples the rest at random — so quitting a session and starting another one moves you along the pool, while a burst of parallelclaude -pruns still spreads (a strict "oldest first" order would send every member of the burst to the same account).CLAUDE_SHIM_SELECT=randomrestores plain uniform-random selection. - If every account is limit-excluded → the highest-headroom authenticable account
anyway + a warning in
selection.log(degraded beats down: the 100% rule). - If nothing is usable at all → stock passthrough, with the reason in
selection.log(and, on a terminal, one line naming the accounts to fix).
Which account has "the most headroom"? A Max subscription has three limits that a
Fable request consumes at once: the 5-hour session bucket, the weekly all-models
bucket, and the weekly Fable bucket. Anthropic's docs confirm these reset on very
different horizons — the session bucket refills every ~5 hours, but weekly buckets only
refill on the account's fixed weekly reset (days away). So the picker ranks primarily on
weekly headroom (the peak of the durable buckets) and uses the self-healing session
bucket only as a tiebreaker. An account sitting at 85% session / 10% weekly outranks one
at 20% session / 70% weekly: the first only needs a few hours to recover, the second is
burning headroom that's gone until next week. (score = weekly% × 1000 + session%,
lowest wins.) Exclusion still fires on any bucket ≥ 90% — a full session bucket
really does block right now — but that marker expires when the session resets, not days
later.
Unusable accounts are excluded, not rotated into. An account that cannot serve a request is dropped from selection before anything else is considered — it is not eligible as the all-limited fallback either, because a limit-marked account is degraded while this one is down, and handing work to it guarantees a hard failure. Two kinds:
- Dead login (
EXPIRED) — refresh token expired, grant revoked, or a real call that came back "OAuth session expired and could not be refreshed". Fixed byclaude-accounts relogin. - Rejected setup-token (
EXPIRED) — token presence andclaude auth statusdo not prove inference works. Before a portable token carries its first real command on a machine, the shim privately verifies it. A 401 parks that exact account and reselects, so direct, TUI, and--resumecalls do not expose the rejected token's failure. - Org-blocked (
BLOCKED) — the account authenticates fine, but the run comes back "Your organization has disabled Claude subscription access for Claude Code". Handled like any other dead login (claude-accounts reloginre-issues the grant and normally clears it); if it comes back BLOCKED after a re-login, an admin has to enable Claude Code for that account. Telemetry cannot see this — those accounts' usage endpoint answers normally — so only a real call (verify, or a-prun) detects it, and the park deliberately survives credential refreshes (a new access token says nothing about an org policy).
claude-accounts expired lists what is excluded and also labels a token-only account
UNVERIFIED until claude-accounts verify or the shim's first-use preflight proves it.
Exclusion self-heals, but only against the evidence it was based on: a credential
park (dead login) clears as soon as a newer credential lands — a re-login, or a refresh
by another process — or when a usage fetch authenticates; a policy park (org block)
ignores both, because a fresh access token says nothing about whether the org re-enabled
Claude Code, and only a passing real call or a re-login lifts it. Parks the shim
writes from a single failed run additionally carry a soft_until stamp and expire on
their own — the shim's guess never outlives the evidence for it — while parks
claude-accounts writes from proof (an invalid_grant refresh response, a failed real
call) stay until the account works again. If every account is unusable the shim still
fails open into stock claude, and logs why to selection.log.
The shim prints nothing, logs timestamp account cwd (never prompt text) to
selection.log, and execs the real binary — stdin/stdout/exit codes pass through
byte-identically. If anything is missing (no manifest, no accounts, unreadable state,
even an unset HOME) it fails open into plain passthrough.
Limit-aware marking. claude-accounts limits (every 15 min via launchd on the Mac,
cron on the server, plus an opportunistic non-blocking kick from the shim when data is
10 min stale) reads each account's own OAuth usage endpoint — the same data
/usageshows. Every bucket the endpoint returns is tracked separately:session,weekly_all, and per-model scoped buckets —weekly_scoped:Fableis its own bucket. Any bucket ≥ 90% ⇒ the account gets a.limitedmarker (with the bucket's reset time) and leaves the random pool until that bucket resets or a refresh shows it back under threshold.
Bucket parsing is shape-agnostic: buckets are derived from whatever the endpoint
returns (kind, plus scope.model when present), so if Anthropic renames a bucket,
reshapes the payload, or drops the per-model Fable separation entirely, the remaining
buckets keep being tracked and the ≥90% rule keeps working. Unparseable entries are
skipped, an all-legacy payload falls back to five_hour/seven_day, and a payload the
code cannot read at all degrades that one account (fail open), never the run.
Telemetry failures never block work: no fresh data ⇒ account treated as available. The
endpoint rate-limits per account, so the refresher skips accounts fetched in the last 4 min
and backs off on every non-2xx, not just a 429 — honoring Retry-After, and parking
for 6 h on a refusal the server marks x-should-retry: false. limits --force overrides
all of it. The cadence is deliberately unhurried: several machines polling the same
accounts every minute earns a 429 with Retry-After: 3600, and telemetry then goes stale
for an hour at a time — which is exactly when every account starts scoring unknown and
the picker loses its ability to tell them apart.
The usage endpoint needs an OAuth login, not a setup token. A portable
server.token(sk-ant-oat01-…) authenticates inference forever, but the usage endpoint refuses it with403 — OAuth token does not meet scope requirement user:profile: setup tokens are minted without that scope. So an account whose.credentials.jsongrant has lapsed keeps working perfectly while going permanently dark for telemetry, and a pool where that happens to every account ranks everything unknown and picks at random.claude-accounts statussaysRANKING IS BLINDwhen the pool is in that state, and the shim prints an hourly warning on a terminal. The fix is a real sign-in on the machine that polls (claude-accounts login <acct-NN>); sincelimits.jsonis one of the thingssyncpushes, only the source machine needs it — the server and peers inherit the telemetry.
Two freshness windows, on purpose. Ranking trusts telemetry for an hour
(CLAUDE_MULTIACC_STALE_AFTER, default 3600 — matching the Retry-After: 3600 the
endpoint itself hands out, so a healthy pool is not "stale" for 45 minutes of every
hour). The ≥90% exclusion keeps the tighter 15-minute window: ranking chooses between
working accounts, while the cutoff declares one unusable, and an account reading 89% an
hour ago may be well past 90% now. When nothing is in-window at all, a stale weekly
reading is still used if its bucket has not reset yet (a weekly bucket only rises
until then, so the number remains a true lower bound) — logged as ranking=DEGRADED.
Only when even that is unavailable does selection rank the account last, logged as
ranking=BLIND.
If an account's OAuth access token has been expired for a while (idle account,
nothing ran claude under it for hours), the refresher renews it directly via the OAuth
refresh-token grant — the same endpoint and public client id Claude Code itself uses —
and atomically persists the rotated credential back to wherever it came from: that
account's .credentials.json (0600), or its macOS Keychain item.
Where a claude OAuth login actually lives (macOS). Claude Code writes each config dir's login to the login Keychain (service
Claude Code-credentials-<sha256(dir)[:8]>) from any session that can open it — a Terminal window, a launchd agent — and then deletes.credentials.json. Sessions that cannot open the keychain (ssh, tmux from ssh, background jobs) fall back to the plaintext file, and they also cannot READ a Keychain-held login: those accounts showKEYCHAIN LOCKEDthere and are excluded from that session's selection, while working normally from the Mac's own session. So a login made over ssh migrates into the Keychain the first time a GUI-session process refreshes its token — the pool reads both places (lib/keychain.py), and an account that must work from everywhere should carry a portable token (claude-accounts mint). Override:CLAUDE_MULTIACC_KEYCHAIN=0disables the lookup. This is what keeps idle accounts' telemetry fresh so they win selection over busy accounts; without it, stale telemetry ranks neutral and a truly-idle account would lose to a busy-but-fresh one. Refresh failures fail open and back off via<acct>/.oauth-refresh.json(10 min transient, 6 h when the grant looks revoked — the log then says re-login is needed). Overrides:CLAUDE_MULTIACC_TOKEN_URL,CLAUDE_MULTIACC_CLIENT_ID(used by the sandboxed tests; defaults are correct for real use).
Limits the client reports itself. The usage endpoint is not the only source of truth, and it is the one that fails exactly when it matters. Claude Code records every rejection in its session transcript:
{"error":"rate_limit","apiErrorStatus":429,
"quotaLimits":{"status":"rejected","resetsAt":1787333400,"rateLimitType":"five_hour"}}That record is free, offline, instant, and carries the real reset time the API handed
out — so the shim reads it. This is what makes an interactive session take its own
account out of the pool: auto-retry below only ever sees -p runs, so before this a 5-hour
limit hit in a tmux session left no trace at all, and the next claude could walk straight
back into the same dead account.
Transcripts are not account-scoped (<acct>/projects is a shared symlink by design), so
the session → account mapping comes from <acct>/sessions/<pid>.json, which the client
maintains only while a run is alive. The shim spawns a short detached poll before exec
(the pid survives exec, so it knows the filename) that records the session id — with the
session's own start time — in <acct>/.sessions-index; selection then tail-scans those
transcripts.
Everything about the rule is built to under-claim rather than over-claim, because a wrongly parked account is worse than a missed limit:
- One owner per session.
claude --continueresumes the same session id under whichever account the pool hands out next (only--fork-sessionmints a new one), and the transcript is shared. Claiming an id releases it from every other account — and if two accounts end up holding the same id anyway (a crossed race, corrupt state), the ambiguity means neither of them answers for it. - Only from the handover. A rejection recorded before this account took the session over stays with the account that earned it; a record that cannot be dated is not attributed at all.
- Only a private session tree. If
<acct>/sessions— or the account dir itself, or a directory nested inside it — is a symlink into somewhere shared, the runs in it prove nothing about who spent the quota, so the scan stays off for that account entirely. - A rejection whose
resetsAthas already passed is history and excludes nothing; a hostile or unreadable index entry is skipped;CLAUDE_MULTIACC_CLIENT_LIMITS=0turns the whole scan off.
The resulting marker is tagged reason=client-rate-limit and outlives a clean telemetry
pass while its window is still open — first-hand evidence beats a usage payload that
disagrees. Cost is bounded: the three newest sessions per account, a tail read rather than
a whole transcript, and a clean result memoized for 20s
(CLAUDE_MULTIACC_CLIENT_SCAN_TTL), which on a three-account pool of large live
transcripts is ~2 ms per run amortized.
The codex shim does the same from rollout files
($CODEX_HOME/sessions/<Y>/<M>/<D>/rollout-*.jsonl carry
"rate_limits":{"primary":{"used_percent":…,"resets_at":…}}, read newest-first), under the
same ownership rule. Note that the installed layout deliberately shares that tree
(<acct>/sessions -> ~/.codex/sessions, so codex resume finds every session), so on a
default codex pool this scan stays off and the usage endpoint remains codex's only limit
signal. Claude accounts keep a private sessions/ registry, so it is fully active there.
Auto-retry (-p/--print only, default on, CLAUDE_SHIM_RETRY=0 disables): on an
auth- or rate-limit-looking failure the shim retries once on a different account and
marks the failed one — a 10-minute cooldown for a rate limit (it heals on its own),
or an .expired park for an authentication failure / org block (they do not). The
park patterns are deliberately narrow and carry a soft_until stamp, because this grep
also sees the model's own answer: a -p run that merely mentions a 403 must never cost
an account, and if one slips through it returns to the pool by itself. Output is buffered so a retried call
never double-emits. Only engages when stdin is finite (tty / regular file / /dev/null)
and ≥2 accounts are eligible; service-spawned pipes take the plain exec path untouched.
Codex support (OpenAI Codex CLI)
The exact same machinery, instantiated a second time for Codex. Everything documented above about the claude pool holds for the codex pool with these translations:
| Claude side | Codex side |
| --- | --- |
| bin/claude shim, claude-accounts CLI | bin/codex shim, codex-accounts CLI |
| pool ~/.claude-accounts | pool ~/.codex-accounts |
| CLAUDE_CONFIG_DIR per-account dirs | CODEX_HOME per-account dirs |
| .credentials.json / macOS Keychain item (OAuth, machine-local) | auth.json (ChatGPT OAuth, machine-local) |
| claude -p auto-retry | codex exec auto-retry |
| Anthropic OAuth usage and Fable buckets | Codex usage endpoint and per-model buckets |
| CLAUDE_* pool controls | equivalent CODEX_* controls |
Same selection rule (weekly headroom primary, session tiebreak, ≥90% any-bucket
exclusion, equal scores rotate), same marker semantics (.limited cooldowns, .expired parks with
credential/policy scoping and soft expiry), same fail-open guarantees, same sync
safety guards. The codex shim engages the buffered auto-retry only for
codex exec runs with finite stdin, exactly like -p on the claude side.
Codex-specific notes:
- Auth is the Codex device-code sign-in by default:
codex-accounts addprints a URL + one-time code you can open in ANY browser (this machine, your laptop, a phone), so it works identically on a local Mac, over SSH, and on servers — there is no portable setup-token equivalent for Codex, so a server account is signed in ON the server the same way.--browseropts into the localhost browser-callback flow instead (only works when the browser runs on the same machine — the callback goes tolocalhost:1455). Either way the login runs withCODEX_HOMEpointed at the account dir, and identity is verified offline from the id-token before anything is registered. auth.jsonis never synced in either direction, for the same reason.credentials.jsonnever is: the refresh token rotates, and two machines refreshing one grant strand each other. Sync pushes manifest +config.tomlseeds + advisory limit state only.- Windows are classified by length, not by name: any window ≤6h counts as the
self-healing session signal, anything longer as durable/weekly. A hard
limit_reached/allowed:falseverdict marks the account even if no window shows ≥90%, and a reshaped payload is still scanned recursively for window-shaped objects (fail open if nothing parses). - Idle-account telemetry stays fresh the same way: a long-expired access token is
renewed via the OAuth refresh-token grant (
auth.openai.com/oauth/token, the CLI's own public client id) and the rotated credential is persisted 0600. Overrides for tests:CODEX_MULTIACC_TOKEN_URL,CODEX_MULTIACC_CLIENT_ID,CODEX_MULTIACC_USAGE_URL. - Earned usage resets are redeemed automatically at 95% used (5% remaining) or
when the backend reports the limit finished. The limits refresher checks the reset
credits belonging to that same account, uses the soonest-expiring available credit,
and writes a per-account/window idempotency key before redemption so a lost response
or simultaneous fleet poll cannot spend a second reset. Set
CODEX_MULTIACC_AUTO_RESET=0only for emergency rollback/testing. - API-key logins are rejected — ChatGPT subscription accounts only, matching the addon's no-API-keys rule.
codex-accounts add # sign in a new ChatGPT account (device code —
# open the URL in any browser, enter the code)
codex-accounts add --browser # localhost browser-callback flow instead
codex-accounts import <email> --id acct-01 && codex-accounts adopt acct-01
# adopt this machine's existing ~/.codex login
codex-accounts list | status | expired | relogin | verify | limits | health | sync
claude-multiacc codex <cmd> # same commands via the npm wrapperInstall / update / uninstall
npm (recommended)
npm install -g claude-multiacc # postinstall wires up the shim, CLI, and scheduled jobs
# or run it once without installing globally:
npx claude-multiaccclaude-multiacc then wraps everything:
claude-multiacc # install or update the addon (idempotent)
claude-multiacc self-update # npm i -g @latest + re-install
claude-multiacc uninstall [--purge-data]
claude-multiacc list | status | add <email> | ... # passthrough to claude-accountsAutomatic updates: a globally-installed package re-runs install.sh from its own
postinstall, so npm i -g claude-multiacc@latest fully updates the shim, CLI, and jobs.
A daily job (launchd on macOS, cron on Linux, 04:07) runs claude-accounts self-update
to pull the latest release on its own — npm i -g @latest for npm installs, git pull +
reinstall for git checkouts; set CLAUDE_MULTIACC_AUTOUPDATE=0 before install to
opt out. update-notifier also prints a one-line nudge when a newer version is on npm.
(Note: newer npm versions gate install scripts; if postinstall is blocked, just run
claude-multiacc install once.)
New versions publish to npm automatically from main via GitHub Actions
(.github/workflows/publish.yml, OIDC Trusted Publishing — no long-lived token after the
first bootstrap publish; scripts/auto-version.mjs bumps the patch above the last release).
From a git checkout
git clone <repo> && cd claude-multiacc && ./install.sh # macOS or Linux, idempotent
./install.sh --server [email protected] # override sync target
git pull && ./install.sh # update (data untouched)
./install.sh --uninstall # restores stock behavior, keeps account data
./install.sh --uninstall --purge-data # also deletes ~/.claude-accountsself-update auto-detects which of these you used (npm global vs git checkout).
Servers / machines without repo access: install via npm — the registry is
public, so the daily self-update needs no credentials at all (no rsync, no keys).
A git checkout self-updates too, but only if git pull can authenticate; for a
private repo on a server that means a read-only deploy key (the 138 server runs
this way: core.sshCommand pinned to its deploy key). A plain copied tree is the
one layout that can NOT self-update — don't ship the addon that way.
What install does (all reversible, nothing else):
- macOS: marked PATH block at the END of
~/.zshenv,~/.zprofile,~/.zshrc(+ bash rc files if present) — end-of-file placement matters because those files re-prepend~/.local/bin; launchd agentscom.claude-multiacc.limits+.codex-limits(5m),.health+.codex-health(weekly Mon morning), and.update(daily 04:07). Notes when this Mac keeps Claude Code logins in the Keychain (the pool reads them; ssh sessions cannot — mint portable tokens for accounts that must work from everywhere). - Linux (root): PATH block in
~/.bashrc+/etc/profile.d/claude-multiacc.sh, shim symlinks at/usr/local/bin/claudeand/usr/local/bin/codex(shadow via PATH order — on the systemd default PATH too; the original binaries are untouched), cron entries for limits/health (both providers) + the daily auto-update. - Both:
~/.claude-accounts/skeleton +accounts.jsonmanifest.
Account lifecycle, synchronization, fleet integration, environment switches, verification, troubleshooting, and security guidance continue in Account and fleet operations.
