Skip to content

Multi-Account Rotation

Two or more personal Claude subscriptions can be wired into Agentic and rotated transparently. The user still experiences ONE account with more capacity — no UI surface exposes the switching, and existing single-account installations behave exactly as before (no accounts.toml = no rotation = today's flow, byte-for-byte).

Why

A single Claude Max account hits the 5-hour and weekly limits during heavy autonomous-runner sessions. Linking a second subscription doubles available capacity without changing the user experience: when one account is rate-limited the runner (and chat) silently fail over to the next, and resumes on the original account once its limit window resets.

Setup

1. Seed each account's credential directory

Each Claude account needs its own CLAUDE_CONFIG_DIR — a directory where .claude.json, projects/, sessions/, and backups/ live. You seed it by running claude /login once with that directory set:

mkdir -p ~/.config/craft-easy-agentic/account-A/.claude
CLAUDE_CONFIG_DIR=~/.config/craft-easy-agentic/account-A/.claude claude /login
# follow the OAuth/login prompts in your browser

mkdir -p ~/.config/craft-easy-agentic/account-B/.claude
CLAUDE_CONFIG_DIR=~/.config/craft-easy-agentic/account-B/.claude claude /login
# log in with the second Anthropic account

After login each directory contains .credentials.json plus the regular Claude state.

2. Register the accounts in accounts.toml

Create ~/.config/craft-easy-agentic/accounts.toml:

[[accounts]]
name = "primary"
claude_config_dir = "/home/patrik/.config/craft-easy-agentic/account-A/.claude"

[[accounts]]
name = "secondary"
claude_config_dir = "/home/patrik/.config/craft-easy-agentic/account-B/.claude"
Field Notes
name Free-form label used in logs. Must be unique.
claude_config_dir Absolute path to the directory that holds the account's .claude.json and .credentials.json.
home Deprecated — still accepted for backwards compatibility but logs a warning at startup. See Migration below.

Restart the Agentic service after editing the file:

systemctl --user restart craft-easy-agentic

The startup log shows how many accounts were registered and warns about any deprecated home fields it found.


How rotation works

Acquire / release per spawn

Every Claude spawn (chat turn, runner task) goes through the AccountResolver:

  1. Acquire — picks the account with the lowest in_use_count that is not currently rate-limited. Tie-break: order in accounts.toml. The account's in_use_count is incremented and a handle returned.
  2. Spawn — the subprocess is started with CLAUDE_CONFIG_DIR=<account's dir> set. Real HOME is unchanged so other CLIs (gh, az, kubectl, git push over SSH) still see the user's regular credentials.
  3. Release — when the subprocess exits, in_use_count is decremented.

State is persisted to ~/.local/share/craft-easy-agentic/account-state.json and protected with a flock so the bash runner subcommands and the in-process chat thread share state without races.

Rate-limit handling

When a Claude spawn exits non-zero and the output matches an Anthropic rate-limit pattern, the runner:

  1. Marks only that account as limited (limited_until_unix + limit_reason written to account-state.json).
  2. The inner retry loop in run_task calls account acquire again — picks the next available account and retries the same task without bumping the regular attempt counter.
  3. If all accounts are rate-limited the acquire returns exit-code 2 → the runner falls back to the classic pause-file flow (writes .pause-backlog + .pause-backlog-resume-at set to the earliest reset across accounts) → dashboard shows the rate-limit pill with countdown.

When the reset timestamp passes, the account is automatically marked available again on the next acquire.

Chat session continuity across accounts

Claude's ~/.claude/sessions/ directory is CLAUDE_CONFIG_DIR-tied — --resume <session-id> only works on the account that originally adopted the ID. If the chat hits a rate-limit mid-conversation and rotates to a different account:

  1. SessionMeta.claude_sessions: BTreeMap<account_name, session_id> records the per-account binding.
  2. On the first turn under the new account, render_history_for_replay builds a markdown transcript of prior user/assistant turns and prepends it to the next prompt.
  3. --session-id <new-uuid> is used so the new account adopts a fresh ID.
  4. On subsequent turns under the same account, normal --resume continues working.

The user sees no break in the conversation — the switch is invisible.


CLI

craft-easy-agentic account exposes the resolver to bash callers (the runner uses this internally). Useful for diagnostics:

# Show current state of all accounts
craft-easy-agentic account snapshot

# Force-acquire (returns JSON with claude_config_dir, name, etc.)
craft-easy-agentic account acquire

# Release a previously acquired account
craft-easy-agentic account release --name primary

# Mark an account rate-limited until a unix timestamp
craft-easy-agentic account mark-limited --name primary \
    --until-unix 1746090000 --reason rate_limit_5h

# Clean up stale in_use_count after a crash
craft-easy-agentic account housekeep

Distinct exit codes let bash branch without parsing the JSON: 0 = ok, 2 = no available account, 1 = error.


State file: account-state.json

{
  "accounts": {
    "primary": {
      "in_use_count": 1,
      "limited_until_unix": null,
      "limit_reason": null
    },
    "secondary": {
      "in_use_count": 0,
      "limited_until_unix": 1746117600,
      "limit_reason": "rate_limit_5h"
    }
  }
}
  • flock-protected so concurrent acquire/release calls from bash and Rust never race.
  • housekeep() runs at server startup to reset in_use_count (otherwise crashes leave it stuck at >0).
  • Safe to delete manually if it gets corrupted — Agentic recreates it from accounts.toml.

Per-spawn isolation: CLAUDE_CONFIG_DIR, not HOME

Earlier iterations of multi-account routing overrode HOME per spawn. That worked for Claude itself but broke every other HOME-tied tool — gh, az, kubectl, ssh-agent all suddenly saw an empty config and looked logged out inside the chat panel.

The fix (CA-T013) is to override only CLAUDE_CONFIG_DIR, which the Claude CLI respects for its own state but no other tool reads. Real HOME is left alone, so everything else inside chat- and task-spawns continues to see the user's regular credentials.

Migration from home to claude_config_dir

If your accounts.toml still uses the legacy home field, Agentic:

  1. Logs a deprecation warning at startup.
  2. The first time the rotation picks up that account, runs a one-shot idempotent migration: <home>/.claude.json<home>/.claude/.claude.json.
  3. Continues to work — the legacy field is mapped internally to claude_config_dir = <home>/.claude.

To silence the warning, edit accounts.toml:

# old
[[accounts]]
name = "primary"
home = "/home/patrik/.config/craft-easy-agentic/account-A"

# new
[[accounts]]
name = "primary"
claude_config_dir = "/home/patrik/.config/craft-easy-agentic/account-A/.claude"

Troubleshooting

"Permission denied" reading .credentials.json

The credential file is owned by the user that ran claude /login. If the systemd service runs as a different user, run the login while logged in as the service user.

Account never rotates back from "limited"

Check account-state.jsonlimited_until_unix should be a future unix timestamp. If it's stuck in the past, run craft-easy-agentic account housekeep to clear stale entries.

Chat replays history every time it opens

Replay only happens when the chat picks up an account that doesn't yet have a session ID for the current SessionMeta. If you see replays mid-conversation, look in the chat session NDJSON — the same account should keep the same session ID across turns.

Single-account installations

If accounts.toml doesn't exist (or has zero entries), Agentic uses the user's default Claude config (the directory claude would normally use without overrides). Behaviour is identical to pre-CA-T011 builds.