A PreModelSwitch hook that blocks a cache-killing model swap

Claude CodeAI CodingCLIDeveloper Tools

August 28, 2026

Light chalk on dark asphalt with a barred gate across a model-swap arrow and the words cache-killing model swap

A PreModelSwitch hook that denies a warm swap is the policy gate on /model before the session model changes. The built-in confirm still asks. Most people hit enter. A UserPromptSubmit router already classified the prompt as cheap Sonnet work, wrote tomorrow's default, and never saw the slash command that was about to fire. Next turn the whole history comes back uncached, billed as a write, and /cost drops off warm.

What you need first#

This is a 2.1.251 feature. The Claude Code changelog dated August 28, 2026 is the ship note. Older CLIs will not list the event, and a hook key they do not know is a silent no-op.

  • Claude Code 2.1.251 or later (claude --version)
  • jq on PATH (the official hooks examples parse stdin with it)
  • A session that has already produced one response (context_tokens is 0 before that, so a fresh tab is the wrong demo)
  • A settings file Claude Code will actually read (user ~/.claude/settings.json, or the repo .claude/settings.json if you want it on web sessions too)

The published hooks reference still omits PreModelSwitch from the lifecycle table. The payload below is what 2.1.251 actually sends, recovered from the CLI's own /hooks help and input schema, not from that page. Do not copy a guessed JSON blob from a model.

Install the deny hook#

Dark HUD pipeline with a /model chip stopped at a PreModelSwitch gate, a lit WARM chip, a DENY bar, and a still-glowing CACHE orb
Deny while WARM is lit. The cache orb stays on.

Four steps. List the event, write a deny script that keys off prompt_cache_warm, register it, then prove /cost still says warm. Skip the confirm pathPATHThe shell environment variable listing directories searched in order when a command name is typed, so every lookup walks each entry until one matches.See also WSLENV, appendWindowsPath, command lookup. Confirm is what you already click through.

1. Confirm PreModelSwitch is on the box#

Open Claude Code and run /hooks. You want PreModelSwitch in the event list, not a blank row and not a sibling named something older. The CLI's own summary names /model, the picker, and set_model as the switch that fires it.

terminal
claude --version
# 2.1.251 (Claude Code)

If /hooks has no PreModelSwitch row, stop. Update, then come back. A settings key the binary does not know will not save you.

2. Deny when prompt_cache_warm is true#

Stdin is JSON. The load-bearing fields are prompt_cache_warm, from_model, to_model, requested_model, source, context_tokens, cache_ttl, estimated_cache_write_usd, and pricing. prompt_cache_warm is the boolean the CLI already uses for the confirm dialog. A switch then forfeits that cache.

source on this event is only command, picker, or sdk. /model sonnet is command. The interactive picker is picker. An SDK or Remote Control set_model is sdk. That split matters later.

Save this as .claude/hooks/block-warm-model-switch.sh and chmod +x it. Exit 0 with JSON is the PreToolUse-shaped decision. Exit 2 also blocks and prints stderr. Empty exit 0 lets the switch through.

.claude/hooks/block-warm-model-switch.sh
#!/usr/bin/env bash
set -euo pipefail
input=$(cat)
warm=$(printf '%s' "$input" | jq -r '.prompt_cache_warm')
from=$(printf '%s' "$input" | jq -r '.from_model')
to=$(printf '%s' "$input" | jq -r '.to_model')
tokens=$(printf '%s' "$input" | jq -r '.context_tokens')
cost=$(printf '%s' "$input" | jq -r '.estimated_cache_write_usd')

if [ "$warm" = "true" ]; then
  reason="Prompt cache is still warm on ${from}. Switching to ${to} would re-cache ${tokens} tokens (est. ${cost} USD). Stay on ${from}."
  jq -n --arg reason "$reason" '{
    hookSpecificOutput: {
      hookEventName: "PreModelSwitch",
      permissionDecision: "deny",
      permissionDecisionReason: $reason
    }
  }'
  exit 0
fi
exit 0

permissionDecision values are allow, deny, and ask, same as PreToolUse. ask is the confirm you already have. deny is the policy. Do not add other keys. The 2.1.248 changelog says a stdout {…} that is not valid JSON is now a hook error with the parse message, not silent plain text.

3. Register the hook on PreModelSwitch#

The matcher filters to_model, the destination id, not from_model. Leave it empty to fire on every swap. Do not set if. That filter only runs on tool events. On PreModelSwitch an if means the handler never spawns.

.claude/settings.json
{
  "hooks": {
    "PreModelSwitch": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-warm-model-switch.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

Merge the PreModelSwitch key next to any existing hook events. Do not replace a PreToolUse block you already use for git push ask rules. Cloud sessions skip ~/.claude/settings.json. Put the same block in the repo file if the web session has to obey it too.

Reload /hooks and open PreModelSwitch. You should see the script path, command type, and the settings file that loaded it. That list is the checkpointcheckpointA saved snapshot of a model's weights at a specific point in training or fine-tuning, the exact file that gets tested, released, or further modified.See also abliteration, frontier model for this step. A missing row means the JSON did not merge.

4. Fire /model on a warm session#

Stay in a session that has already answered you. Run /cost first. 2.1.251 added a Prompt cache (main) line with hit ratio, misses, tokens re-cached, and warm or cold. You want warm. Prompt caching is keyed by model. /model on a warm cache is a full re-read of the history, even when the text did not change.

Then /model sonnet (or any other id than the one you are on). The spinner says Running PreModelSwitch hooks…. On deny the transcript shows Model switch blocked by a PreModelSwitch hook, plus the reason from the script. The model id in the status line does not change.

Model switch blocked by a PreModelSwitch hook
Prompt cache is still warm on claude-opus-5. Switching to claude-sonnet-5 would re-cache 84211 tokens (est. 0.42 USD). Stay on claude-opus-5.

/cost
Prompt cache (main):  warm (1h TTL, last activity 12s ago)

That /cost line is the proof. If it still says warm and the model did not move, the hook did the job. The numbers in the sample are a shape, not a measurement from this machine. Your context_tokens and estimated_cache_write_usd come from stdin on that turn.

GitHub issue #48087 is the complaint this hook answers. People were told /model mid-session was a cost trick. The next response resent the full history uncached. The built-in warning from 2.1.108 asked. This hook refuses.

When the switch still happens#

Dark HUD hub labeled PreModelSwitch with teal COMMAND PICKER SDK arrows entering and coral AUTO RESUME arrows skipping around it
Command, picker, and sdk enter the hook. Auto and resume walk around it.

This is the part the changelog one-liner does not say out loud. PreModelSwitch is not a wrap around every model change. Three sources enter the hook. Two walk around it. Get this wrong and you will ship a deny script, watch /model block, then lose the cache on a fallback you never typed.

auto and resume are PostModelSwitch sources only. Automatic fallback on Fable 5 and Opus 5 is a model switch, per the prompt-caching docs, and this hook will not see it. A resumed session that restores a stored model also skips PreModelSwitch. PostModelSwitch still fires after the change. Exit 0 stdout there is annotation for the next request, not a rewind.

  • ask not deny. You get A PreModelSwitch hook asked you to confirm. That is the same click you already make. The cache still dies if you accept.
  • Wrong matcher field. The matcher filters to_model. A matcher of opus only runs when the destination is opus, which is the expensive swap you might actually want.
  • if never runs. That filter is tool-event syntax. On PreModelSwitch the process never starts.
  • Plugin load failed. The binary then says plugin hooks could not be loaded, so PreModelSwitch hooks could not be checked. User and project command hooks in settings.json are the path this tutorial uses.
  • Fast mode promote. source is still command. A deny here also blocks the fast-mode change. The CLI tells you fast mode was not enabled because the model changed while PreModelSwitch hooks ran.

A log-only hook that exits 0 with no JSON is the quiet failure. The switch proceeds. /cost goes cold. You will stare at a script that "ran" and a cache that did not.

Model routers that live on UserPromptSubmit still cannot stop /model. Claude Model Router (tzachbon's hook plugin) classifies the prompt on UserPromptSubmit and, if you opt in, writes ~/.claude/settings.json for the next session. Running sessions stay put, which is polite, and also why they never intercept the slash command. PreModelSwitch is the event that sits on the swap itself.

What you have now#

A PreModelSwitch command hook that denies while prompt_cache_warm is true. /model on a warm session prints the block line, the model id does not move, and /cost still says warm.

SessionStart resume hooks in the same 2.1.251 drop gained seconds_since_last_response, prompt_cache_likely_expired, and estimated_cache_write_usd. Those fields warn that the first turn back will re-cache. They do not block a live /model. TTLTTLThe time a cached prompt stays valid before it expires and must be rewritten at full cost. Shortening it turns yesterday's cheap cache reads into today's expensive cache writes.See also cache read, cache write knobs (promptCacheTtl, agent experimental.cacheTtl) are a different post. This one refuses the swap.

If you already print cache tokens on a status line, 2.1.251 also added a prompt_cache object for those scripts, including prompt_cache_warm. Useful. Not a substitute for the deny.

PreModelSwitch questions

Why does /model mid-session re-read the whole conversation?

Each model keeps its own prompt cache. Claude Code's prompt-caching docs say the next request after /model reads the entire history with no cache hits, even when the text is identical. That is a full-price re-write of context_tokens on the new model, not a cheap cache read.

asked on github.com
Does the built-in /model confirm already stop the swap?

No. The confirm only appears while the cache is still warm, and it is a prompt, not a deny. A PreModelSwitch hook that returns permissionDecision ask repeats that confirm. Deny is the policy. Exit code 2 also blocks and prints stderr.

asked on code.claude.com
Will this hook catch an automatic model fallback?

No. PreModelSwitch only sees source command, picker, or sdk. Automatic fallback and resume restoration skip it and fire PostModelSwitch after the model already changed. Fable 5 and Opus 5 safety fallback is a model switch per the prompt-caching docs, and this hook will not refuse it.

asked on code.claude.com
After /clear, why would a warm check still fire?

prompt_cache_warm is the CLI's own guess, not your intuition about an empty transcript. A 2026 report found the /model cache warning still appeared after /clear even though a brand-new process did not warn. Trust the field in stdin, then prove the result with /cost.

asked on github.com
Share

Newsletter

New posts land in your inbox when they publish. No spam, unsubscribe anytime.

Prefer RSS