Files
AgentHub/docs/superpowers/specs/2026-09-14-ai-router-profiles-design.md
T
nntrivi2001 0a86dfb8c5 feat(settings): add AI Router profiles and attach one per lane
A new **AI Router** tab in Settings, next to Codex, stores named router
endpoints (9Router, OpenRouter, a self-hosted gateway) as profiles: a base
URL, an API key, a default model and an optional per-tier override. The tab
only stores them; a lane CHOOSES one in the Add-lane modal, via a Native /
AI Router radio, and that choice is written into the lane's own config.

The two CLIs are configured differently, and the difference is theirs:

  - a claude lane gets eight `env` keys merged into its own
    `.claude/settings.local.json` (`.local` because the value holds a key);
  - a codex lane gets `$CODEX_HOME/<name>.config.toml` — the layer
    `codex -p <name>` loads — because Codex has NO project-level config.
    Its base `config.toml` is never touched.

Native is a built-in profile rather than a code branch, so "go back to
native" is the same write path. It blanks the two connection variables
instead of removing them (an absent key at project scope lets a USER-level
router apply) and removes the model names instead of writing them (writing
them would override the user's own global model choice).

Key material never leaves the server: profiles live one file each under
`<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers`
reports only `hasKey`, and there is deliberately no route returning one
profile in full.

Three things this had to get right, each found by review and verified
against the real tools rather than assumed:

  - `wire_api = "chat"` is rejected outright by codex-cli 0.149 ("no longer
    supported"), so every codex lane on a router would have failed to start.
    The layer writes "responses", and writes `env_key` only when the profile
    actually has a key — naming an unset variable is itself a startup error.
  - The key must never be TYPED into a pane. `tmux.sendCommand` used
    `send-keys` with a `KEY='…' cmd` prefix, which the dashboard streams to
    every attached browser and bash appends to history; it now takes over the
    idle pane with `respawn-pane -k -e`, passing env through argv the way
    `newSession` always has. Verified on tmux 3.4: multi-element argv arrives
    intact and the pane renders no trace of the value.
  - Attaching COPIES a profile, so editing or deleting one in Settings has to
    reach the lanes already using it. An edit re-applies it (a rotated key
    would otherwise never reach the lane still holding the revoked one); a
    delete resets those lanes to native and removes the Codex layer file.
    Both report per-lane failures, which the tab surfaces.

`.claude/settings.local.json` is added to the lane's git exclude, for both
native and router lanes: `agenthub lanes reset` runs `git clean -fd` without
`-x`, so an un-excluded settings file is deleted on reset — and untracked
meant the key was committable.

DB: `lanes.router_profile`, nullable, additive. Written only by
`setRouterProfile` beside the disk write, never through `PATCHABLE`.
2026-09-14 16:04:57 +07:00

5.5 KiB

AI Router profiles — design

Goal

Configure named AI Router endpoints (9Router, OpenRouter, …) once in Settings, then pick one when creating a lane so that lane's Claude Code or Codex session talks to the router instead of the vendor's own API — without hand-editing a settings file per project, and without a second copy of the whole settings file drifting out of sync with the first.

What the reference setup showed

The user today keeps two whole files, ~/.claude/settings.json (native) and ~/.claude/settings2.json (9Router), and swaps them. Diffing the two:

  • They differ in exactly one place that matters — the env block, 8 keys: ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_HAIKU_MODEL, ANTHROPIC_SMALL_FAST_MODEL, CLAUDE_CODE_SUBAGENT_MODEL.
  • Every other difference (hooks, enabledPlugins, statusLine, effortLevel, …) is drift between two hand-maintained copies. Removing that drift is half the point of this feature.
  • "Native" is not the absence of the connection variables: the native file sets ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN to empty strings and carries real claude-* model names.
  • The 9Router file sets all six model slots to one symbolic value (Highest), which the router resolves. Model values are therefore free text, and "one value for every tier" is the common case, not the exception.

Decisions (from brainstorming)

  1. The Settings tab only stores profiles. It does not attach anything. Attaching happens when a lane is created.
  2. Native is a built-in profile, not a code branch. Its baseUrl/apiKey are empty and its six model slots hold the native claude-* names. It cannot be edited or deleted. Switching native ↔ router is therefore one write path with no special case.
  3. A profile carries six model slots plus an "apply to all" convenience field, mirroring exactly the env keys Claude Code reads.
  4. The API key never leaves the server. Profiles are stored one JSON file each under <CLAUDE_HOME>/agenthub-routers/ (dir 0700, file 0600), following cc-profiles.js. GET returns metadata plus hasKey: true, never the key.
  5. A lane's router is fixed at creation, like lanes.tool: a new nullable lanes.router_profile column, deliberately absent from PATCHABLE.
  6. AgentHub never reads ~/.9router/db.json. That is another application's private store; parsing it would couple this dashboard to a format its owner is free to change. The user pastes a base URL and a key, as they do today.

Attaching, per tool

The two tools differ because the tools themselves differ, not by choice.

Claude lane — write the profile's eight env keys into <lane.cwd>/.claude/settings.local.json, merging into any env already there and leaving every other key in the file untouched. settings.local.json rather than settings.json because the value written includes an API key and that file is the one Claude Code's own convention keeps out of git.

Codex lane — Codex has no project-level config: codex doctor reports one loaded file, ~/.codex/config.toml. It does have -p, --profile <name>, documented as "Layer $CODEX_HOME/<name>.config.toml on top of the base user config". So each router profile is written once to ~/.codex/<name>.config.toml (model_provider plus a [model_providers.<id>] table with base_url, env_key, wire_api), and the lane's spawn adds -p <name>. The base ~/.codex/config.toml is never modified. Codex reads the key from the environment variable named by env_key, so the spawn also injects that variable into the tmux pane — pty-run.js already passes an env map to tmux new-session -e.

Surface

  • server/lib/ai-routers.js — store: list/get/create/update/remove, name validation, 0700/0600, key never returned by the list shape.
  • server/lib/router-apply.js — the two writers above, taking a lane row and a profile. Target directories come from the lane row in the DB, never from a request body — the same narrowing every other lane write path uses.
  • server/routes/ai-routers.js → /api/ai-routers: GET /, POST /, PATCH /:name, DELETE /:name, reusing cc-profiles.js's error-code→status table.
  • POST /api/lanes/:id/router/apply — a fourth member of the auto-setup set the Add-lane modal already fires (profileInit, agentsInstall, mcpSync).
  • client/src/pages/AiRouterConfig.tsx + one line in SettingsHub.tsx's TOOL_TABS, placed after Codex.
  • AddLaneModal: a Native / AI Router radio, and a profile select shown only when AI Router is chosen.
  • DB: ALTER TABLE lanes ADD COLUMN router_profile TEXT, nullable.

Out of scope

Changing an existing lane's router (create-time only, exactly like tool), attaching at user/global scope, editing Codex's base config.toml, and reading any router vendor's own configuration store.

Verification

Server tests against a temporary CLAUDE_HOME/CODEX_HOME: store CRUD and its permission bits, the key never appearing in a list response, the settings.local.json merge preserving unrelated keys, and the generated <name>.config.toml. Client tests: the tab renders and creates a profile, and the Add-lane radio sends the chosen profile only when AI Router is selected.

The load-bearing assumption — a project-level settings.local.json env block overrides the user-level one per key — is confirmed by the user from their own earlier testing.