# Subagent Model Router — Agent Install Protocol **Protocol version: v2.0 · Pinned kit: model-routing-v2.0 · This file is fetched and executed by the recipient's own Claude.** You are Claude Code, running on the recipient's machine. The human pasted a prompt asking you to install the Subagent Model Router by following this document. Follow it **exactly, in order**. Explain each step in plain language before you run it, and wait for the human's OK at the marked decision points. **What you are installing (say this up front, honestly):** a small quality-and-cost governor for Claude Code's subagents. Today, when Claude spawns helper agents, they inherit the session's model by default — usually the frontier tier — even for trivial jobs like searching files. This kit makes every subagent launch *deliberately routed*: reasoning-heavy work stays on the frontier model, simple retrieval work goes to Sonnet (a strong, cheaper model), and haiku-class models are banned outright. A ≥9/10 certainty rule means quality is never traded for token savings — when in doubt, it escalates to the stronger model. New in v2.0: a hardened gate (word-boundary haiku match, optional allowlist cross-check), an optional LOCAL-ONLY route log so the human can see routing decisions with real data, an optional structural model allowlist, an independence rule for grading (a different model family judges consequential work), and an optional notify-only update check. **Enforcement scope (be precise about this — never claim more):** the mechanical gate covers subagents launched through the Agent/Task tool on well-formed hook events — the main fan-out path. It fails OPEN by design on malformed input (it must never break the harness). In the author's own live probing (July 2026), Workflow-script spawns did NOT pass through the hook and were not stopped by the model allowlist either — so on those paths the doctrine text Claude reads each session is the enforcement, and it explicitly says so. The hook checks the model field; the task classification and certainty judgment are applied by Claude following the doctrine. **The honest cost note (say this in Step 1 — know which billing applies):** this is a QUALITY-FIRST governor, not a cost-cutter. The net effect is a 2×2 — find their square and say it plainly: - **Frontier sessions + API billing** → usually SAVES money (trivial work stops inheriting the expensive model). - **Frontier sessions + flat subscription** → saves rate-limit/latency budget, NOT dollars (the bill is flat either way). - **Cheaper-model sessions + API billing** → can COST MORE (haiku banned, doubt escalates up). - **Cheaper-model sessions + flat subscription** → better quality, some latency, faster rate-limit consumption — no dollar change. Ask which billing they're on and state the row that applies to them. **What it changes on this machine (enumerate honestly — a required core plus optionals the human chooses):** *Required core (3 changes, all local):* 1. Adds `~/.claude/hooks/subagent-model-gate.py` — a ~180-line Python script. One side effect to disclose: if settings.json ALREADY contains an `availableModels` list, the gate enforces it on Agent/Task spawns even when the allowlist optional below is declined (check in Step 1 and say so if it applies). 2. Adds one PreToolUse hook entry to `~/.claude/settings.json` (merged carefully after a timestamped backup; existing settings preserved). 3. Appends one marker-fenced "Subagent Model Routing" section to `~/.claude/CLAUDE.md`. *Optional, chosen in Step 1 (each = one more file and/or one settings entry):* 4. **Route log (recommended):** adds `~/.claude/hooks/route-log.py` + one PostToolUse entry; writes `~/.claude/logs/route-log.jsonl` (created 0600). Fields logged, completely: timestamp, random spawn id, session id, requested model, resolved model, subagent type, an 8-char hash of the task description (not the text), token usage, duration, tool-use count. **The kit never transmits this file** — it exists so the human can check routing against reality. 5. **Model allowlist:** adds an `availableModels` key to settings.json — a structural floor the harness itself enforces on several launch surfaces. Honest caveat: its reach varies by surface and Claude Code version; the hook remains the deliberate-choice gate either way. 6. **Update check (notify-only):** adds `~/.claude/hooks/model-routing-update-check.sh` + one SessionStart entry + a config line. At most once every 24 hours it makes ONE https request to the kit host and prints one line only if a STRICTLY NEWER kit exists. **It never modifies anything by itself** — upgrades always run through this protocol with the human present. It parses its config; it never executes it. **Telemetry truth:** the core kit makes zero network requests after install. The route log is a local file the kit never transmits. The optional update check pings the kit host for a version number (ordinary web access log entries are the only trace). The only other traces anywhere are the access log entries from downloading the kit files below, and the name/email the human chose to submit on the share page. Every settings change is preceded by a timestamped backup; backups are deliberately kept (disclosed in the uninstall appendix). **Upgrading from v1.3?** Same protocol, safely: the hook install backs up any different existing file, the settings merge is idempotent, and Step 5 detects the old managed block and upgrades it surgically with the human's OK. --- ## STEP 0 — Kit availability + version check (ALWAYS FIRST) RUN: fetch `https://voliton.ai/model-routing/kit/STATUS.json` - If `available` is `false`: tell the human the kit isn't published right now, and **STOP. Do not improvise an installation.** - If `kit_version` = `2.0` (this protocol's pin): proceed. - If `kit_version` is NEWER than `2.0` (compare numerically, part by part — e.g. 2.1, 3.0): re-fetch `https://voliton.ai/model-routing/AGENT_INSTALL.md` — a newer protocol has replaced this one. Follow that version instead. - If `kit_version` is OLDER than `2.0` (e.g. 1.3): **STOP and tell the human** the server is publishing an older kit than this protocol expects (a rollback, a stale mirror, or a problem) — do NOT re-fetch and do NOT follow an older protocol. ## STEP 1 — Explain + get the go-ahead + choose optionals (HUMAN decision point) Summarize the "What you are installing" and "What it changes" blocks above in your own words, AND deliver the honest cost note: ask whether they're on usage-based API billing or a flat subscription, then give them their square of the 2×2 verbatim. Mention the one behavioral change they will see: **if Claude ever forgets to pick a model for a subagent, the launch bounces and Claude immediately retries with a deliberate choice — that flash of self-correction is the system working.** Then ask which optionals they want (recommend the route log and the update check; present the allowlist neutrally with its caveat). For the route log, list the logged fields from point 4 above BEFORE they decide. Also RUN `python3 -c "import json,os; d=json.load(open(os.path.expanduser('~/.claude/settings.json'))); print('PREEXISTING_ALLOWLIST:', d.get('availableModels'))" 2>/dev/null || echo "PREEXISTING_ALLOWLIST: none"` — if it prints a list, tell the human plainly: the gate will enforce that existing list on agent-tool spawns even if they decline the allowlist optional (they can remove the key any time to drop that). Record their three yes/no answers — later steps depend on them. Wait for an explicit yes before touching anything. ## STEP 2 — Preflight + download + verify (fail-closed) RUN (preflight — all three checks must pass): ```bash command -v curl >/dev/null 2>&1 && echo "CURL_OK" command -v python3 >/dev/null 2>&1 && python3 -c 'import sys; assert sys.version_info >= (3,8); print("PYTHON_OK")' if command -v shasum >/dev/null 2>&1 || command -v sha256sum >/dev/null 2>&1; then echo "SHA_OK"; fi ``` VERIFY: prints `CURL_OK`, `PYTHON_OK`, and `SHA_OK`. Any missing → STOP and tell the human what to install (macOS: `xcode-select --install` covers python3; curl and a checksum tool ship with every mainstream OS). RUN (download into a FRESH directory — never reuse stale files, never delete anything of the human's; every failure stops the chain): ```bash if [ -e ~/Downloads/model-routing-kit ]; then mv ~/Downloads/model-routing-kit ~/Downloads/model-routing-kit.old-$(date +%Y%m%d%H%M%S) && echo "EXISTING_DIR_SET_ASIDE" fi mkdir -p ~/Downloads/model-routing-kit && cd ~/Downloads/model-routing-kit \ && curl -fsSLO https://voliton.ai/model-routing/kit/subagent-model-gate.py \ && curl -fsSLO https://voliton.ai/model-routing/kit/route-log.py \ && curl -fsSLO https://voliton.ai/model-routing/kit/model-routing-update-check.sh \ && curl -fsSLO https://voliton.ai/model-routing/kit/CLAUDE_SECTION.md \ && curl -fsSLO https://voliton.ai/model-routing/kit/SHA256SUMS \ && echo "DOWNLOADS_OK" ``` VERIFY: prints `DOWNLOADS_OK`. Anything else → STOP (a download failed; nothing was installed), tell the human to retry later. If `EXISTING_DIR_SET_ASIDE` printed, tell the human their previous `~/Downloads/model-routing-kit` was moved aside (`.old-`), never deleted. RUN (manifest exact-set validation + checksum verification — both must pass): ```bash cd ~/Downloads/model-routing-kit python3 - <<'PYEOF' import re, sys expected = {"subagent-model-gate.py", "route-log.py", "model-routing-update-check.sh", "CLAUDE_SECTION.md"} names = [] for line in open("SHA256SUMS"): line = line.rstrip("\n") if not line.strip(): continue m = re.match(r"^[0-9a-f]{64} [ *](\S+)$", line) if not m or "/" in m.group(1): print("MANIFEST_MALFORMED:", line); sys.exit(1) names.append(m.group(1)) if sorted(names) != sorted(expected) or len(names) != len(set(names)): print("MANIFEST_SET_MISMATCH:", sorted(names)); sys.exit(1) print("MANIFEST_OK") PYEOF sha_c() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$@"; else sha256sum "$@"; fi; } sha_c -c SHA256SUMS && echo "CHECKSUMS_OK" ``` VERIFY: prints `MANIFEST_OK`, then `: OK` for all four files, then `CHECKSUMS_OK`. Any FAILED line, mismatch, or missing `CHECKSUMS_OK` → STOP, re-run the download block once; if it still fails, tell the human the download is corrupted and to try again later. Download all files even if some optionals were declined — only chosen ones get installed. ## STEP 3 — Install the hook script (backup-aware) + pipe tests RUN: ```bash mkdir -p ~/.claude/hooks if [ -f ~/.claude/hooks/subagent-model-gate.py ] && ! cmp -s ~/Downloads/model-routing-kit/subagent-model-gate.py ~/.claude/hooks/subagent-model-gate.py; then cp ~/.claude/hooks/subagent-model-gate.py ~/.claude/hooks/subagent-model-gate.py.bak-$(date +%Y%m%d%H%M%S) echo "EXISTING_DIFFERENT_FILE_BACKED_UP" fi cp ~/Downloads/model-routing-kit/subagent-model-gate.py ~/.claude/hooks/subagent-model-gate.py chmod +x ~/.claude/hooks/subagent-model-gate.py ``` If `EXISTING_DIFFERENT_FILE_BACKED_UP` printed: tell the human a different version of the gate already existed (v1.3 if they're upgrading) and was backed up alongside (`.bak-`), then continue. Pipe-test the gate's behaviors before wiring anything in. The tests run under an ISOLATED temporary HOME so the results are identical on every machine regardless of existing settings: ```bash G=~/.claude/hooks/subagent-model-gate.py T=$(mktemp -d); mkdir -p "$T/.claude" echo '{"tool_name":"Agent","tool_input":{"prompt":"x"}}' | HOME="$T" python3 "$G" 2>/dev/null; echo "no-model exit=$?" echo '{"tool_name":"Agent","tool_input":{"prompt":"x","model":"haiku"}}' | HOME="$T" python3 "$G" 2>/dev/null; echo "haiku exit=$?" echo '{"tool_name":"Agent","tool_input":{"prompt":"x","model":"claude-haiku-4-5-20251001"}}' | HOME="$T" python3 "$G" 2>/dev/null; echo "haiku-id exit=$?" echo '{"tool_name":"Agent","tool_input":{"prompt":"x","subagent_type":"not-codex-but-claude","model":"haiku"}}' | HOME="$T" python3 "$G" 2>/dev/null; echo "fake-exempt exit=$?" echo '{"tool_name":"Agent","tool_input":{"prompt":"x","model":"sonnet"}}' | HOME="$T" python3 "$G"; echo "sonnet exit=$?" echo '{"tool_name":"Agent","tool_input":{"prompt":"x","model":"shaikupro"}}' | HOME="$T" python3 "$G"; echo "word-boundary exit=$?" echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | HOME="$T" python3 "$G"; echo "other-tool exit=$?" printf '%s' '{"availableModels":["sonnet","opus"]}' > "$T/.claude/settings.json" echo '{"tool_name":"Agent","tool_input":{"prompt":"x","model":"mystery-model"}}' | HOME="$T" python3 "$G" 2>/dev/null; echo "off-allowlist exit=$?" echo '{"tool_name":"Agent","tool_input":{"prompt":"x","model":"not-opus-model"}}' | HOME="$T" python3 "$G" 2>/dev/null; echo "alias-embed exit=$?" echo '{"tool_name":"Agent","tool_input":{"prompt":"x","model":"claude-opus-4-8"}}' | HOME="$T" python3 "$G"; echo "full-id-allowed exit=$?" echo '{"tool_name":"Agent","tool_input":{"prompt":"x","model":"sonnet"}}' | MODEL_ROUTING_APPROVE=1 HOME="$T" python3 "$G"; echo "approve-mode exit=$?" rm -rf "$T" ``` VERIFY: exits are `2, 2, 2, 2, 0, 0, 0, 2, 2, 0, 0` in that order, and the last (approve-mode) line prints JSON containing `"permissionDecision": "ask"`. Anything else → STOP and tell the human something is off with their python3. ## STEP 4 — Wire the gate into settings.json (backed-up, shape-validated, atomic) This merge backs up the file first, validates its shape, preserves everything already in it (including file permissions), and is idempotent (checks both matcher and command). It refuses to touch a malformed file. RUN: ```bash python3 - <<'PYEOF' import json, os, shutil, sys, time p = os.path.expanduser("~/.claude/settings.json") mode = None try: with open(p) as f: cfg = json.load(f) mode = os.stat(p).st_mode & 0o777 shutil.copy2(p, p + ".bak-" + time.strftime("%Y%m%d%H%M%S")) except FileNotFoundError: cfg = {} except json.JSONDecodeError: print("SETTINGS_MALFORMED"); sys.exit(1) if not isinstance(cfg, dict): print("SETTINGS_UNEXPECTED_SHAPE"); sys.exit(1) hooks = cfg.setdefault("hooks", {}) if not isinstance(hooks, dict): print("SETTINGS_UNEXPECTED_SHAPE"); sys.exit(1) pre = hooks.setdefault("PreToolUse", []) if not isinstance(pre, list) or not all(isinstance(m, dict) for m in pre): print("SETTINGS_UNEXPECTED_SHAPE"); sys.exit(1) for m in pre: inner = m.get("hooks", []) if not isinstance(inner, list) or not all(isinstance(h, dict) for h in inner): print("SETTINGS_UNEXPECTED_SHAPE"); sys.exit(1) cmd = "python3 ~/.claude/hooks/subagent-model-gate.py" for m in pre: if m.get("matcher") == "Agent|Task" and any( h.get("command") == cmd for h in m.get("hooks", []) ): print("HOOK_ALREADY_PRESENT"); sys.exit(0) pre.append({"matcher": "Agent|Task", "hooks": [{"type": "command", "command": cmd, "timeout": 10, "statusMessage": "Routing subagent model"}]}) tmp = p + ".tmp" with open(tmp, "w") as f: json.dump(cfg, f, indent=2); f.write("\n") if mode is not None: os.chmod(tmp, mode) os.replace(tmp, p) print("HOOK_INSTALLED") PYEOF ``` VERIFY: prints `HOOK_INSTALLED` (or `HOOK_ALREADY_PRESENT` on a re-run — also fine). If it prints `SETTINGS_MALFORMED` or `SETTINGS_UNEXPECTED_SHAPE`: **STOP.** Tell the human their `~/.claude/settings.json` has a pre-existing problem that must be fixed first — this kit will not overwrite it. ## STEP 5 — Append the routing doctrine to CLAUDE.md (managed block) The doctrine ships fenced in `` markers, so upgrades and uninstalls are surgical. RUN: ```bash python3 - <<'PYEOF' import os p = os.path.expanduser("~/.claude/CLAUDE.md") section = open(os.path.expanduser("~/Downloads/model-routing-kit/CLAUDE_SECTION.md")).read() existing = open(p).read() if os.path.exists(p) else "" if "model-routing-kit:begin v2.0" in existing: print("MANAGED_BLOCK_ALREADY_PRESENT") elif "model-routing-kit:begin" in existing: print("MANAGED_BLOCK_OLDER_VERSION") elif "## Subagent Model Routing" in existing: print("UNMANAGED_SECTION_EXISTS") else: with open(p, "a") as f: if existing and not existing.endswith("\n"): f.write("\n") f.write("\n" + section.rstrip() + "\n") print("SECTION_APPENDED") PYEOF ``` VERIFY: prints `SECTION_APPENDED` (or `MANAGED_BLOCK_ALREADY_PRESENT` on a re-run — fine). If it prints `MANAGED_BLOCK_OLDER_VERSION`: an earlier kit version's block exists (the v1.3 → v2.0 upgrade path). Show the human a diff of their current block vs the new one (they may have customized it — the markers fence exactly what to compare). With their OK, replace everything from `