Plain English:
cli-tap-outlets one OpenClawclaude-cli/*model lane use multiple Claude subscription accounts. It wraps theclaudeCLI, selects the active OAuth token, and switches to the next configured account when the current one hits a Claude session limit.
Claude CLI is built around one active Claude auth identity at a time. This repo
adds the missing multi-account layer for OpenClaw: a profile pool, safe token
selection, real session-limit detection, cooldown tracking, and account failover
for the bundled claude-cli backend.
The name: in combat sports, you tap out when a submission hold sinks in — and your fresh tag-team partner jumps in to keep the fight going.
cli-tap-outdoes exactly that for Claude auth: when a rate limit chokes out the active account mid-stream, the router taps it out, benches it until its window resets, and tags in the next account. The match never stops.
Battle-tested on OpenClaw >= 2026.6.x with Claude Code stream-json live sessions.
| If you need... | cli-tap-out gives you... |
|---|---|
| Multiple Claude accounts working through Claude CLI | One claude-cli/* lane backed by several OAuth-token profiles |
| Automatic account switching on Claude session limits | Live rate-limit detection, profile cooldowns, and next-account rotation |
| OpenClaw compatibility | A command override for the existing claude-cli backend, not a forked backend |
| Auditable behavior | JSON state fields showing the active profile, cooldown source, and reset time |
This is not a generic model router, Anthropic API-key load balancer, or replacement for Claude CLI. It is specifically the glue that makes multiple Claude accounts usable from OpenClaw's Claude CLI backend.
- Profile → token resolution. Reads
claude-profiles.json, picks the active profile, exports that profile's OAuth token asCLAUDE_CODE_OAUTH_TOKEN, then runsclaudewith all original args. - Rate-limit detection (print/stream-json mode).
clauderuns in the background with stdout on a FIFO; a python filter forwards events untouched and watches for real rate limits ("you've hit your session limit",rate_limit_eventwithstatus:"rejected", HTTP 429/529). On detection it SIGTERMs the claude process (critical — in live sessions claude outlives the turn and a stranded process = stuck typing indicator forever). - Rotation. Marks the limited profile with a cooldown, atomically flips
the
activefield in the profiles JSON (flock-protected), and picks the next profile whose token env var is set and not cooling down. The cooldown uses the real reset time when available: the rejectedrate_limit_eventcarriesrate_limit_info.resetsAt(unix timestamp of the session-window reset), so the profile benches until exactly then (cooldown_source: "resetsAt"in the state file). Sanity bounds apply (must be in the future, ≤7 days out); missing/garbage values fall back to a flat cooldown (default 2h,cooldown_source: "flat_fallback"). Healthy turns write nothing — reset capture is failure-only by design. - Friendly result. Emits a synthetic assistant+result stream-json pair
("Claude hit a session limit 🧱, so I switched from X to Y 🔁. Please resend
⚡") so the gateway gets a clean turn end instead of an error.
Exception — all profiles exhausted: when rotation finds no usable
profile (every account cooling down), the router surfaces a REAL error
instead (is_error result marked
router_all_profiles_exhausted: true+ non-zero exit). Masking that case as a success would block OpenClaw's native model fallback; a real provider failure lets OpenClaw hand the turn to the next model inagents.defaults.model.fallbacks(defaults/auto sessions — user-pinned sessions surface the error visibly by OpenClaw's design). The source-chat note still fires so a human sees why. - Source-chat notify. Group-topic sessions never show final text upstream
(openclaw#76424), so on rotation the router also fire-and-forgets the same
message via
openclaw message sendinto the source chat (topic-aware), using theOPENCLAW_MCP_*env vars the gateway already injects. Only fires for group /message_tool_onlysessions to avoid DM duplicates.
Interactive (non -p/--print) invocations skip all of this and just
exec claude with the token exported.
| File | Purpose |
|---|---|
claude-auth-router.sh |
The router. Install to e.g. ~/scripts/ (or /root/scripts/ on a root Linux host). |
claude-profiles.example.json |
Template for ~/.openclaw/claude-profiles.json. |
openclaw-config-snippet.json5 |
The one config change: cliBackends.claude-cli.command. |
- bash 3.2+ (stock macOS bash works — no bash-4isms), python3 (stdlib only — no pip installs)
timeout(coreutils) is optional: the router auto-falls back togtimeout(brew coreutils) or a bare background send for the chat notifyclaudeCLI on PATH for the gateway useropenclawCLI on PATH (only needed for the source-chat notify; everything else works without it)
This router is for multi-account setups. If the host has a single Claude
login there is nothing to rotate — run claude directly and skip the kit.
Paths below use ~ — that's /root on a root Linux VPS, /Users/<you> on a
Mac. The profiles path defaults to ~/.openclaw/claude-profiles.json for
whatever user runs the gateway; override with CLAUDE_PROFILES_FILE if needed.
-
Script:
install -m 0755 claude-auth-router.sh ~/scripts/claude-auth-router.sh -
Profiles file: copy
claude-profiles.example.jsonto~/.openclaw/claude-profiles.json, rename profiles/labels to taste. Rules:env_varnames must be shell-safe ([A-Za-z_][A-Za-z0-9_]*, no hyphens). A profile whose env var is unset/empty is skipped by rotation — handy for parking a disabled account. -
Active profile: the JSON
activefield inclaude-profiles.jsonis the single canonical switch. Set it in the profiles file:{ "active": "primary", "profiles": { ... } }The router rewrites it on rotation. To switch manually, edit the field:
python3 -c 'import json,os;p=os.path.expanduser("~/.openclaw/claude-profiles.json");d=json.load(open(p));d["active"]="primary";json.dump(d,open(p,"w"),indent=2)'(Earlier versions used a standalone
claude-auth-activefile; it is retired and the router no longer reads or creates it.) -
Tokens: generate one long-lived token per Claude account with
claude setup-token(log in as that account, run it, log back into your main account — the token keeps working), then put them in the gateway's environment —~/.openclaw/.env(mode 600), plus the systemd unit env on Linux:ANTHROPIC_OAUTH_TOKEN1=sk-ant-oat01-... ANTHROPIC_OAUTH_TOKEN2=sk-ant-oat01-...
Never put tokens in the profiles JSON or the script.
-
Config: merge
openclaw-config-snippet.json5intoopenclaw.json(agents.defaults.cliBackends.claude-cli.command→ the script path). Validate withopenclaw config validate, then restart the gateway. -
Smoke test (as the gateway user, with the env loaded):
~/scripts/claude-auth-router.sh -p 'Reply exactly: ROUTER_OK' \ --output-format json --model claude-haiku-4-5
Then one real turn through OpenClaw on a
claude-cli/*model.
The router is Keychain-agnostic and works on a Mac as-is:
- macOS Claude CLI stores its interactive-login OAuth creds in the Keychain,
but
CLAUDE_CODE_OAUTH_TOKEN(which the router exports from the selected profile) takes precedence over stored creds. Router-managed runs never read or write the Keychain; your normalclaudelogin stays untouched for ambient use. claude setup-tokentokens are what go in the profile env vars — the Keychain is irrelevant to them.- Stock bash 3.2 is fine (no bash-4isms); locking is Python
fcntl.flock. timeoutdoesn't exist on stock macOS. The router auto-detects and usesgtimeout(frombrew install coreutils) or falls back to a bare background send — the chat notify degrades gracefully, turns are never affected.- Use absolute paths in
openclaw.json(/Users/<you>/scripts/...), not~.
| Var | Default | Meaning |
|---|---|---|
CLAUDE_PROFILES_FILE |
~/.openclaw/claude-profiles.json |
Profiles path (registry + active switch); ~ = the gateway user's home |
CLAUDE_AUTH_ROUTER_COOLDOWN_SECONDS |
7200 |
Fallback bench time for a limited profile, used only when the event's resetsAt is missing or fails sanity bounds. Claude limits run on ~5h windows; 2h keeps churn rare. |
CLAUDE_AUTH_ROUTER_ROTATE_ON_RATE_LIMIT |
1 |
Set 0 to disable rotation (friendly message only) |
CLAUDE_AUTH_ROUTER_ERROR_ON_EXHAUSTED |
1 |
When all profiles are limited, surface a real error so OpenClaw model fallback can fire. Set 0 for the old always-friendly synthetic success. |
CLAUDE_AUTH_ROUTER_RATE_LIMIT_MESSAGE |
(built-in) | Override the user-facing limit message |
Pinning: claude-auth-router.sh --auth-profile <name> ... forces a profile and
disables rotation for that run (used by foreman-style dispatch tooling).
- Do NOT treat
overageStatus:"rejected"as a rate limit. Subscription orgs emitrate_limit_eventwithoverageStatus:"rejected"(org_level_disabled) on every turn. Only the primaryrate_limit_info.status == "rejected"field means the request was actually rejected. Getting this wrong kills every turn at start (stuck typing indicator + false rotations). The shipped detector is already correct. - The FIFO + PID-kill design is load-bearing. A plain
claude | filterpipeline can't kill claude when the filter finds a limit; in live sessions claude keeps running (stdin stays open), bash blocks on it, and the gateway never gets a result event. Don't "simplify" this back to a pipe. <&0on the background claude is load-bearing too. Live sessions stream stdin over it; a background job otherwise gets /dev/null stdin.- Live sessions pick up script changes only on respawn. Sessions spawned
before an edit keep the old process — abort once (or
/reset) so the next message respawns through the new script. - Auth-profile switch invalidates stored CLI sessions (OpenClaw drops claude session reuse when the auth identity changes). Expected: after a rotation the next turn starts a fresh claude session seeded by OpenClaw.
- Notify lane is best-effort by design:
timeout 15 ... & disown. A missed notification is fine; a rotation blocked on a hung send is not.
This is deliberately not an OpenClaw plugin. The plugin CLI-backend SDK
(prepareExecution, defaultAuthProfileId, authEpochMode) covers token
injection, but has no hook for live mid-stream detection with process control,
which is where all the value is. A command override on the bundled claude-cli
backend is the supported, upgrade-stable integration point
(agents.defaults.cliBackends.<id>.command is merged over plugin defaults).
Long-term, rate-limit-driven auth-profile rotation belongs upstream.