Skip to content

shoemoney/aigate

Repository files navigation

🛡️ aigate

The compliant AI credential vault & account selector

(a secure proxy / load balancer is the roadmap — today aigate is NOT a proxy: it never sits in Anthropic's request path, it picks the best account/key and records usage.)

One self-hosted place that holds every AI credential you own, hands them out the compliant way, balances by real rate-limit headroom, and shows you live what's using what — so nothing runs away at 2am.

status node db deps compliant license PRs


💛 Shoutout to Theo

This one's for Theo Browne@t3dotgg.

If you found this repo through his channel: welcome in. 🎬 I build the way I build because of people who make it fun to watch someone care about their craft — and Theo is at the top of that list. The bias for shipping, the "just self-host it," the allergy to over-engineered nonsense — a lot of that rubbed off from years of his videos. Thank you, genuinely. I owe you a ton. 🙏

🌐 t3.gg · 💬 t3.chat · ▶️ YouTube · 🐦 @theo · ⚡ create.t3.gg · 📦 UploadThing


Note

Status: v1 shipped, fleet-verified. The daemon — encrypted vault, headroom-aware Claude account selector, server-side usage poller, provider-key registry, WebSocket dashboard, full audit — is real and running on live machines. The secure proxy for API-key providers and latching budget breaker are the roadmap → see VISION.md. Nothing below is vaporware; the ⬜ rows just aren't built yet.

Built by someone who woke up to a $500 OpenRouter bill from a rogue loop and had no idea which of 35 machines did it. aigate is the tool that would've caught it at 2am. 😴💸


🧭 Contents

🤔 Why ⚖️ Compliance 🧠 Concepts
🏗️ Architecture 🔁 How it works ✨ Features
🚀 Quick start 🔌 Wire up a box 🧪 Prove it switches
📡 API ⚙️ Config 🗺️ Roadmap

🤔 Why (not just a proxy?)

Multiple Claude Max subscriptions are not against ToS — Anthropic's Claude Code team said so on the record and told The New Stack they "will not be canceling accounts." What gets accounts banned is relaying subscription tokens through a harness that spoofs the official client. aigate stays firmly on the accepted side of that line for Claude, and uses a normal secure proxy only where it's safe:

Provider type aigate mode In Anthropic's request path? Safe?
Claude subscriptions (OAuth) 🎯 Selector — runs the official claude binary with the best account's token never ✅ accepted architecture
API-key providers (OpenRouter, OpenAI, Gemini…) 🔀 Secure proxy — injects the real key, meters spend (roadmap) ✅ (standard) ✅ normal for API keys

Clients only ever hold an aigate token — never a raw provider key. 🔐


⚖️ Compliance (the whole point)

Most "multi-account Claude" tools get this wrong and get people banned. aigate is built specifically to get it right. Here's the line, in Anthropic's own words, and where every tool falls.

📋 Full policy analysis + every primary-source receipt → COMPLIANCE.md.

Anthropic's Claude Code legal & compliance page is unusually explicit. The banned pattern is routing requests through Free/Pro/Max plan credentials on behalf of users via a harness that spoofs the official client — in Anthropic's own words (Thariq Shihipar, Claude Code @ Anthropic — Jan 9, 2026): "we tightened our safeguards against spoofing the Claude Code harness … third-party harnesses using Claude subscriptions … are prohibited by our Terms of Service." Running your own multiple accounts through the official client is a different thing entirely — he confirmed on Feb 19, 2026 "Nothing is changing about how you can use the Agent SDK and MAX subscriptions," and Anthropic itself acknowledges CLAUDE_CONFIG_DIR as the sanctioned multi-account isolation mechanism. aigate never spoofs the harness — it runs the real claude binary — so it stays on the accepted side.

flowchart TB
  subgraph banned["❌ BANNED — the relay pattern"]
    direction LR
    B1["your app"] --> B2["🚨 proxy impersonates<br/>the official client"] --> B3["Anthropic"]
    note1["ccflare · claude-relay-service · teamclaude<br/>relay OAuth tokens, spoof headers,<br/>sit IN the request path"]
  end
  subgraph ok["✅ ACCEPTED — the selector pattern (aigate)"]
    direction LR
    A1["aigate<br/>pick account w/ headroom"] -.only picks.-> A2["official claude binary"] --> A3["Anthropic<br/><i>direct · real telemetry</i>"]
    note2["no proxy · own accounts only ·<br/>CLAUDE_CODE_OAUTH_TOKEN (sanctioned)"]
  end
Loading

Three tests aigate passes:

Test Banned tools aigate
Who makes the request? a proxy spoofing the client the official claude binary
Whose accounts? routes on behalf of other users / resells only your own
Evading limits? sticky sessions designed to dodge caps picks headroom for ordinary individual use

Warning

The one soft spot — respect it. Keep aigate single-tenant and each account's usage within ordinary individual bounds. Do not shard one heavy 24/7 workload across N accounts to beat the weekly cap — that's limit evasion, bannable even with the official binary. Prefer an API-key fallback (Console pay-go) over over-draining a subscription. Never ship a token to a machine used by a different person.

📎 Receipts — read the primary sources yourself

Don't take my word for it. Here's the line, from Anthropic directly:

Source What it establishes
⚖️ Anthropic — Claude Code Legal & Compliance The authoritative policy: OAuth is for "ordinary use of Claude Code and other native Anthropic applications"; developers routing subscription creds on behalf of their users must use API keys.
🐦 Thariq Shihipar (@trq212), Claude Code @ Anthropic — Jan 9, 2026 Names the banned pattern: harnesses that spoof the Claude Code client using subscription tokens. aigate runs the real binary — it doesn't spoof.
🐦 Thariq Shihipar — Feb 19, 2026 "Nothing is changing about how you can use the Agent SDK and MAX subscriptions."
🧩 anthropics/claude-code #261 · #33430 Anthropic's own repo acknowledging CLAUDE_CONFIG_DIR for per-account isolation — the exact mechanism aigate uses.

The through-line: who makes the request matters. A harness spoofing the client = banned. The official claude binary, your own accounts = accepted. aigate is architected to always be the second thing.


🧠 Core concepts

mindmap
  root(("🛡️ aigate"))
    🔐 Vault
      AES-256-GCM at rest
      Claude OAuth tokens
      provider API keys
      clients hold aigate token only
    ⚖️ Selection
      most headroom first
      auto-skip ≥95%
      auto-recover after reset
      🔁 exclude + retry on over-limit
    📈 Usage poller
      reads Anthropic rate-limit headers
      every 10 min · no proxy
      per-account 5h + 7d %
    🩺 Self-heal
      /health DB-backed probe
      watchdog exits→restart
      Docker HEALTHCHECK + autoheal
    🧾 Audit
      every handout - IP + host
      every prompt - via hooks
    📊 Live
      WebSocket dashboard
      🚨 runaway detection
      🔑 add/remove provider keys
    🛑 Guards _(roadmap)_
      per model x key caps
      latching breaker
Loading

🏗️ Architecture

No proxy in Anthropic's path. The daemon only picks the account, records activity, and polls real usage. The official client talks to Anthropic directly.

flowchart LR
  subgraph box["🖥️ each box (official claude)"]
    R["🎯 cc wrapper (aigate-run)<br/>pick best account · retry on limit"]
    C(["claude ↔ Anthropic<br/><i>direct, your token</i>"])
    S["🛡 statusline badge<br/>account · wk %"]
    R --> C
  end
  D[["🛡️ aigate daemon<br/>SQLite vault + selector + poller"]]
  W(["📊 dashboard"])
  AN(["🤖 Anthropic<br/>rate-limit headers"])
  R -- "GET /api/select" --> D
  D -- "poll every 10m" --> AN
  AN -- "5h% / 7d% utilization" --> D
  D == "WebSocket push" ==> W
Loading

🔁 How it works

sequenceDiagram
  autonumber
  participant Box as 🖥️ box
  participant AG as 🛡️ aigate
  participant AN as 🤖 Anthropic
  Note over AG,AN: every 10 min, per account
  AG->>AN: tiny call w/ account's token
  AN-->>AG: anthropic-ratelimit-unified-{5h,7d}-utilization
  AG->>AG: write real usage → auto-skip ≥95%
  Note over Box,AN: on demand
  Box->>AG: GET /api/select?host=pi-17
  AG->>AG: ORDER BY max(5h%,7d%) ASC, skip disabled/over-cutoff
  AG-->>Box: { account, setup_token }  📝 (logs access + IP)
  Box->>AN: run OFFICIAL claude w/ token 🔑
Loading

✨ Features (v1)

Feature Notes
🔐 Encrypted vault AES-256-GCM at rest — Claude OAuth tokens and provider API keys; the list endpoints never return secrets (only has_token / key_hint), while the selector/fetch routes hand the decrypted value to an authenticated, audited caller
⚖️ Headroom-aware selection hands out the account with the most headroom (lowest of max(5h%,7d%)), skips anything ≥ cutoff
📈 Server-side usage poller reads each account's real rate-limit headroom straight from Anthropic every 10 min → auto-skip maxed, auto-recover after reset, zero manual seeding
🔑 Provider-key registry encrypted store + /api/keys for a 59-provider catalog (OpenAI / OpenRouter / Gemini / Groq / Together / fal / ElevenLabs / …); sanitized intake (trims + un-quotes pastes, 400s export/NAME= blobs, provider lowercased) + collision-proof first8…last4 hints; GET /api/providers feeds a dashboard add-key form with per-provider key-format hints
🔁 Over-limit detect + retry headless cc -p classifies claude's failure — a real per-account usage limitTTL-parks that account (/api/events/limit15m default, real usage untouched) and retries the next-best via /api/select?exclude= (up to 3); a transient 529/overloadwaits 10s and retries the SAME account (no park — 529 is global load-shedding; parking/hopping just drains the pool)
🩺 Self-healing daemon unauthenticated /health (DB-backed; selectable uses the exact selection query, so parked accounts don't mask an outage) + internal watchdog (exits→restart on a wedged DB) + Docker HEALTHCHECK wired to autoheal — three recovery layers
🐤 Boot canary + daily backups wrong AIGATE_ENCRYPTION_KEY = loud FATAL at boot (not a decrypt blow-up mid-request); daily VACUUM INTO snapshot → data/backups/ w/ 14-day retention (ciphertext only — .env never copied)
🧾 Full audit trail every handout, key-fetch, and mutation (account add/overwrite/delete/disable/enable · key add/delete · limit-park) logged with timestamp + IP + host; every prompt is secret-scrubbed (sk-/ghp/AIza-shaped tokens redacted) before storage then capped at 400 chars; all readable at /api/access; auto-pruned after 30 days (daily, piggybacked on the backup pass)
📊 Live dashboard account cards w/ usage bars (🚨 runaway, 🔑 re-auth), provider-key manager, streaming activity feed, per-host/device stats — WS auth rides a bearer.<token> subprotocol, never the URL
🎯 No-proxy Claude mode official binary + cc wrapper — won't flag accounts
🧪 Tested unit + HTTP tests (node --test, boots the real server on a throwaway DB) and a fleet switching test on a real Pi — see docs/TESTING.md
🐳 1 runtime dep ws. SQLite is Node's built-in node:sqlite. Buildless. Docker-ready.

🚀 Quick start

git clone https://github.com/shoemoney/aigate && cd aigate
cp .env.example .env
#   → set AIGATE_TOKEN  (any long random string)
#   → set AIGATE_ENCRYPTION_KEY=$(openssl rand -hex 32)
npm install          # installs ws
npm start            # → http://localhost:20200

🐳 Docker: docker compose up -d

Add a Claude account (mint the token with claude setup-token while logged into that account):

curl -X POST http://localhost:20200/api/accounts \
  -H "Authorization: Bearer $AIGATE_TOKEN" -H 'content-type: application/json' \
  -d '{"account":"max_1","setup_token":"sk-ant-oat01-…","label":"personal"}'
💡 The setup-token gotcha that trips everyone

claude setup-token shows two screens. The browser "Authentication Code" page (code#state, "Paste this into Claude Code") is not the token — it goes back into the waiting terminal, which then prints the real sk-ant-oat01-…. That line is what aigate stores.

Add a provider key:

curl -X POST http://localhost:20200/api/keys \
  -H "Authorization: Bearer $AIGATE_TOKEN" -H 'content-type: application/json' \
  -d '{"provider":"openrouter","key":"sk-or-v1-…","label":"prod"}'

…or just open the dashboardProvider API keys → pick from the 59-provider dropdown, paste, Add key. 🖥️ (Paste hygiene is handled server-side: quotes get stripped, and an accidental export NAME=… blob is rejected with a 400 instead of vaulting garbage.)


🔌 Wire up a box (client side)

One installer sets up the cc command. It routes the official claude through aigate's selector, unsets stray ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL, preflight-warns on shadow logins + BASE_URL hijacks, and (in headless -p mode) detects over-limit and retries the next account — with clean stdout (all banners on stderr, so piping cc -p output stays pure).

AIGATE_URL='https://aigate.example.com' AIGATE_TOKEN='' bash clients/install.sh
cc -p 'hi'          # → Claude replies, on the account with the most headroom

The installer writes ~/.claude/aigate/{aigate-run.sh,hydrate.sh,env} + ~/.local/bin/cc and auto-detects the claude binary.

Important

The live copies are ~/.claude/aigate/* — editing clients/*.sh in the repo changes nothing on a box until you re-run clients/install.sh there. Ship client-script changes by re-running the installer on each machine.

File Role
install.sh sets up cc + ~/.claude/aigate/ + env; auto-detects claude; wires MCP-key hydration into the shell
aigate-run.sh the cc wrapper — select → set token → unset stray ANTHROPIC_* (incl. BASE_URL) → run claude; retry-on-limit in -p mode (real limit → 15m park + next account; transient 529 → wait 10s, retry the SAME account, no park) w/ clean stdout; preflight-warns shadow logins + BASE_URL hijacks
hydrate.sh MCP-key hydration — vault → ~/.claude/aigate/mcp-keys.env so ${BRAVE_API_KEY}-style MCP configs resolve at launch; merges partial fetches (a blip never wipes cached keys); cc foreground-freshens when missing/stale (>12h) so this launch gets keys
prompt-hook.sh Claude Code UserPromptSubmit hook → logs the prompt to aigate
statusline-feed.sh statusline badge (account · wk %) → also feeds real usage back
test-switching.sh end-to-end switching test (below)

🔑 /add-key — teach every Claude to use the key vault

The repo ships a Claude Code skill at .claude/skills/add-key/. Any Claude working in this repo (or with the skill synced into ~/.claude/skills/) can type /add-key to vault a provider key and fetch it back to use it — no hardcoded secrets:

/add-key        → store an OpenAI/fal/Gemini/… key, list what's vaulted,
                  or pull a key at runtime (GET /api/keys/:provider)

It knows the auth flow (source ~/.claude/aigate/env), the 59-provider catalog, and the add / list / fetch / rotate routes. Distribute it fleet-wide by dropping it in ~/.claude/skills/ on each box — every Claude then knows how to reach the vault.

Tip

cc is a shell command (~/.local/bin/cc). On Linux it shadows the C compiler cc when ~/.local/bin precedes /usr/bin — rename it if you compile with cc. In headless -p mode it auto-adds --dangerously-skip-permissions so it never hangs on the trust prompt.

"Unable to connect to API"? A stale ANTHROPIC_BASE_URL silently hijacks every request. cc unsets the env var and preflight-warns when ~/.claude/settings*.json carries one — strip the key where it points.


🧪 Prove it actually switches

Don't trust a router you haven't watched. test-switching.sh flips each account disabled and asserts cc -p follows — with a real Claude PONG every time:

bash clients/test-switching.sh <accountWithMoreHeadroom> <otherAccount>
Verified run — Pi twojeffsaigate.shoemoney.ai (2026-07-08)
State Expected Picked cc -p
both enabled shoemoney (1% vs 19%) shoemoney PONG
shoemoney disabled personal personal PONG
personal disabled shoemoney shoemoney PONG
both enabled shoemoney shoemoney PONG

Every switch landed in access_log (select/ok) + request_log. Full test matrix in docs/TESTING.md.


📡 API reference

All endpoints require Authorization: Bearer $AIGATE_TOKEN except /health (so supervisors can probe it).

Method Path Purpose
GET /health · /healthz 🩺 unauthenticated DB-backed liveness — {ok, uptime_s, accounts, selectable} plus observability numbers poll_age_s, backup_age_s, poll_ok, poll_failed + a parked / reauth / disabled tally (all numbers, no secrets; autoheal reads only the status) (generic 503 if the DB is wedged)
GET /api/select?host=&exclude=a,b 🎯 best account + token (logs access w/ IP); exclude skips accounts on retry
GET / POST /api/accounts list (usage, no tokens) / add {account, setup_token, label}
DELETE /api/accounts/:name remove
POST /api/accounts/:name/disabled {disabled: true/false}
POST /api/accounts/:name/refresh 🔄 live re-poll ONE account's real headroom right now (not the 10-min cache) → {five, seven, alive, maxed}; 404 on unknown account
POST /api/events/usage 📈 set an account's 5h/7d % — the client statusline-feed path (the server-side poller writes usage straight to the DB); 404 on unknown account
POST /api/events/limit 🔁 {account, minutes?}TTL-park an over-limit account (default 15m, minutes clamped 1–360; real usage untouched, auto-unparks when the TTL passes); 404 on unknown account
POST /api/events/prompt 🧾 log a prompt {account, host, cwd, model, prompt}
GET /api/providers 📇 the 59-provider catalog (id, name, key prefix, base URL)
GET / POST /api/keys list (no secrets, first8…last4 hints, stale flag) / add {provider, key, label}sanitized: trims + un-quotes, 400 on export/NAME= pastes, provider lowercased, non-fatal warning for uncataloged providers or a key that doesn't match the catalog prefix
POST /api/keys/import 📥 bulk import [{provider,key,label}] (or {keys:[…]}) — one result row per key so a bad entry doesn't sink the batch (max 200)
GET /api/keys/:provider?exclude= 🔑 newest working key for a provider (audited; name normalized — BRAVE finds brave); exclude=<hint> skips a just-failed key and serves the next
POST /api/keys/:id/refresh 💓 liveness probe ONE key (oaiCompat providers: GET <base>/models) → flips status working/dead; 200 {checked:false} for providers with no probe
DELETE /api/keys/:id remove a provider key
GET /api/metrics 📈 Prometheus text (bearer-gated) — aigate_selectable, aigate_accounts_*, aigate_poll_ok/failed, aigate_provider_keys_working/dead, …
GET /api/logs?limit= · /api/stats prompt log · dashboard rollups
GET /api/access?limit= 🧾 audit trail — every handout, mutation & key-fetch (account · host · IP · action · result; no secrets) for post-incident review; limit default 100, capped 1000
GET /api/capabilities 🧭 read-only registry slice — per-provider key counts + Claude selectability (accounts + how many are pickable) + server version; a machine-readable "what can I reach?" for agents (never secrets)
WS /ws 📡 live event stream — auth via the bearer.<token> WebSocket subprotocol (token never lands in URL/access logs; a ?token= query param is ignored — header/subprotocol only)

⚙️ Config

Env var Default Purpose
AIGATE_TOKEN (required) bearer gating API + dashboard
AIGATE_ENCRYPTION_KEY (required) 32-byte hex (AES-256-GCM). openssl rand -hex 32
PORT / HOST 20200 / 0.0.0.0 bind
AIGATE_DB ./data/aigate.db SQLite path
AIGATE_HEADROOM_CUTOFF 95 skip accounts whose worst-window % ≥ this
AIGATE_POLL_MS 600000 usage-poll interval (ms); 0 disables the poller
AIGATE_WATCHDOG_MS 30000 🩺 self-heal watchdog — pings the DB; exits→restart if wedged. 0 disables
AIGATE_ALLOW_CIDR (empty = all) 🌐 network gate — CIDRs + single IPs. Loopback always OK.
AIGATE_TRUST_PROXY 0 trust X-Forwarded-For for client IP — set 1 only behind a proxy you control (else the gate/audit see the proxy IP)

🗺️ Roadmap

flowchart TD
  v1["✅ <b>v1</b> — selector · vault · <b>usage poller</b> · <b>key registry</b> · dashboard · per-host audit"] --> r2
  r2["🔨 <b>Round 2</b> — API-key <b>secure proxy</b> + per-model<br/>latching <b>budget breaker</b> + Redis hot layer"] --> r3
  r3["⬜ <b>Round 3</b> — universal key registry +<br/>quota-aware, cost-first routing"] --> r4
  r4["⬜ <b>Round 4</b> — 📨 inbox discovery + 🤖 agent capability registry"]
Loading
Ring Ships Kills the pain of…
v1 headroom selector · over-limit retry (TTL parks) · encrypted vault (boot canary + daily backups) · 10-min usage poller · 59-provider key registry + dashboard add-key UI · self-heal (/health + watchdog + autoheal) · WS dashboard "which of my 35 boxes is that?" + manual usage babysitting + re-login churn
🔨 R2 secure proxy for API providers · per-model×key latching budget breaker · Redis the $500 nano-banana loop
R3 universal keys(provider) registry · cost-first routing (included quota → prepaid → paid) paying twice for quota you already own
R4 inbox account discovery · fuller agent capability registry — the read-only GET /api/capabilities slice (counts + selectability + version) already ships; R4 is the metered, on-demand-handout registry layered on top keys too annoying to use → agents just use them

🔨 = the one "next" pointer. Nothing gets a ✅ until it exists in code and runs on real machines.


🔒 Security

  • 🔑 Credentials AES-256-GCM encrypted at rest; clients hold only the aigate bearer.
  • 🧾 Every handout audited (account · host · IP · timestamp).
  • 🧯 .env is git-ignored; never commit real tokens/keys.
  • ⚖️ Personal, honest, visible. Multiple personal subs via the official client is fine — pooling/reselling for others is not. aigate gives you the visibility to stay honest.

🤝 Contributing

PRs welcome! 💜 Early and opinionated — read VISION.md first so a PR lands in the right ring. Keep the no-relay-for-Claude guardrail sacred.

npm start                     # daemon
node --watch src/server.js    # hot reload
npm test                      # unit + HTTP tests (node --test, no deps)

📜 License

MIT © shoemoney — do whatever, just don't get people's accounts banned. 🛡️


Born from a "damn ADHD, what was that tool called?" moment. 🧠⚡ If it saves you one $500 morning, it paid for itself infinitely (it's free). 😄

Once more, for the person who made building look worth caring about — thank you, Theo. 💛

included quota → prepaid → paid · never the other way around

About

Safe multi-account load balancer for Claude Code — encrypted token vault, usage-aware routing, live dashboard. Official binary + hooks, no relay/proxy.

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages