Skillforge Field notes on shipping with AI tools

Claude Code PreModelSwitch Hook: Block or Confirm /model

claude-codehooks

Claude Code v2.1.251, released 2026-08-28, added two hook events for model switches. PreModelSwitch runs before a requested switch takes effect and can refuse it, force a confirmation prompt, or wave it through. PostModelSwitch runs after the session's model has changed for any reason and can hand Claude model-specific instructions. Together they close a gap: until now /model was the one session-level change no hook could see, and the only guard was the built-in "your cache is still warm, switch anyway?" prompt.

This article covers what each event fires on, the input your hook receives (including a dollar estimate of what the switch will cost), the three decisions a PreModelSwitch hook can return, and three example hooks we ran against the documented input shape. Everything here was verified against the Claude Code hooks reference and prompt caching documentation on 2026-08-30. If hooks are new to you, start with our hooks guide; this piece assumes you know what a matcher and an exit code are.

Why a model switch deserves a hook

Two reasons, one about money and one about policy.

Money first. Each model has its own prompt cache. Switching mid-session means the next request re-sends the entire conversation to the new model with no cache hits, then writes all of it into a fresh cache. On Opus 5 a five-minute cache write is $6.25 per million tokens at list price, so the example in the Claude Code docs, switching a 182,340-token Sonnet 5 session to Opus 5, estimates $1.14 in cache writes before the new model has produced a word. Claude Code already asks you to confirm a /model while the cache is warm (since v2.1.238), but it asks the same way for a 5,000-token session as for a 180,000-token one, and it cannot ask a non-interactive host at all. A hook lets you set your own threshold. Our prompt caching article covers the cache mechanics in detail.

Policy second. Teams retire models, pin projects to one model family so evaluations stay comparable, or want to know when a session quietly moved to a fallback model. Before v2.1.251 there was no deterministic way to enforce, or even observe, any of that.

When each event fires, and the asymmetry that matters

PreModelSwitch runs only for switches somebody requested:

  • /model <name> and the /model picker
  • the Option+P or Alt+P model picker
  • the Model setting in /config
  • turning on fast mode, when that changes the session's model
  • a set_model request, or a model change inside an apply_flag_settings request, from an Agent SDK host or Remote Control

It does not run for switches Claude Code makes on its own: an automatic model fallback, or restoring the saved model when you resume a session. Those reach PostModelSwitch only.

PostModelSwitch runs after any change to the session's model: a requested switch, an automatic fallback, the opusplan setting entering or leaving plan mode, and the model restore on resume. One exception: when a model from a fallback chain serves a single turn, the session's model is unchanged and no hook fires.

So the rule of thumb is: to block, use PreModelSwitch, and know it will only ever see deliberate requests. To observe, use PostModelSwitch, and be ready for switches nobody typed.

What the hook receives

Both events get the common hook fields (session_id, cwd, hook_event_name, and so on) plus these:

FieldWhat it holds
from_modelModel ID the switch changes from
to_modelModel ID the switch changes to. The matcher is compared against this model's canonical name
requested_modelWhat the request actually said: an alias like opus, a full model ID, or null when the request was for the default model
source"command" for /model <name>, the /config setting, or fast mode; "picker" for a model picker; "sdk" for Agent SDK or Remote Control. PostModelSwitch adds "auto" and "resume"
context_tokensTokens the next request re-sends as its prompt: the input, cache read, cache creation, and output tokens of the last main-conversation response, combined. 0 before the first response
prompt_cache_warmWhether the current model's prompt cache is likely still warm, meaning the switch forfeits it
cache_ttlThe cache lifetime this session requests, "5m" or "1h"
estimated_cache_write_usdEstimated cost of writing context_tokens to the cache on to_model at the cache_ttl rate, excluding the next response
pricingHow that estimate was priced: "configured" at your organization's own rates, "catalog" at list price, or "default" when to_model has no known price

This is the documented input for /model opus in a session running Sonnet 5:

{
  "session_id": "abc123",
  "transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
  "cwd": "/Users/...",
  "hook_event_name": "PreModelSwitch",
  "from_model": "claude-sonnet-5",
  "to_model": "claude-opus-5",
  "requested_model": "opus",
  "source": "command",
  "context_tokens": 182340,
  "prompt_cache_warm": true,
  "cache_ttl": "5m",
  "estimated_cache_write_usd": 1.1396,
  "pricing": "catalog"
}

The estimate is exactly what the field name says: 182,340 tokens at $6.25 per million is $1.1396. The docs add that the server may not need to re-cache the whole context, so treat it as an estimate, not a bill.

On PostModelSwitch, requested_model is null when source is "auto", and is the saved model setting when source is "resume".

The three decisions a PreModelSwitch hook can make

The simplest form is the one every blocking hook uses: exit with code 2 and write the reason to stderr, or exit 0 with a top-level "decision": "block". Either cancels the switch.

For finer control, exit 0 and print a hookSpecificOutput object with permissionDecision set to one of three values:

DecisionEffect
"allow"The switch proceeds, and Claude Code skips its own warm-cache confirmation
"ask"Claude Code prompts the user to confirm, showing permissionDecisionReason in the prompt
"deny"The switch is cancelled; permissionDecisionReason is shown to the user, or returned as the error to a set_model request

The event does not accept "defer", updatedInput, or additionalContext. Five rules around these decisions are worth memorizing before you write one:

  1. "ask" only works for /model in an interactive session. On every other surface, including -p mode, /config, and SDK set_model requests, Claude Code treats "ask" as a refusal. A cost gate written for the terminal becomes a hard block for an SDK host, so check source if that matters to you.
  2. Precedence across multiple hooks is deny > ask > allow.
  3. systemMessage is shown to the user regardless of the decision. A pure cost-report hook can print {"systemMessage": "..."} and exit 0 without deciding anything.
  4. A timed-out hook blocks the switch. The default timeout is 30 seconds. This is the opposite of PreToolUse, where a timed-out hook lets the tool call continue, so keep model-switch hooks fast and offline.
  5. Only command, http, and mcp_tool hooks run here. prompt and agent hook types do not apply to this event.

An exit code other than 0 or 2 with no JSON decision does not block: Claude Code shows the stderr and applies the switch.

Matchers compare canonical names, with one trap

The matcher is compared against the canonical name of the model the session is switching to, ignoring any [1m] suffix. An alias such as opus, a dated model ID, and a provider-specific ID such as an Amazon Bedrock model ID all resolve to the same canonical name, so a matcher of claude-opus-5 covers every spelling of Opus 5. Write it as an exact name, a |-separated list like claude-opus-4-6|claude-opus-5, or a regular expression like .*opus.*.

The trap: when Claude Code cannot determine a canonical name for the target, which happens with a custom model ID that only your LLM gateway knows, it runs every PreModelSwitch hook regardless of matcher. A hook that blocks should therefore check to_model from its input rather than rely on the matcher alone. The official example does both; the examples below skip the matcher entirely and check the input, which is one less thing to get out of sync.

Example 1: pin a project to one model family

This hook refuses any switch to a model outside the Claude 5 family. Save it as .claude/hooks/pin-model.js:

#!/usr/bin/env node
// .claude/hooks/pin-model.js
// PreModelSwitch: refuse any switch to a model outside the Claude 5 family.
const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
const allowed = /claude-(opus|sonnet|fable)-5/;

if (!allowed.test(input.to_model)) {
  console.error(
    `This project runs on Claude 5 models only. Refused: ${input.to_model}` +
      ` (requested as ${input.requested_model ?? "default"}, via ${input.source}).`
  );
  process.exit(2);
}
process.exit(0);

Register it in .claude/settings.json using the exec form (command plus an args array), which spawns the executable directly with no shell involved. That sidesteps quoting problems and works identically on macOS, Linux, and Windows; ${CLAUDE_PROJECT_DIR} is substituted into each args element as a plain string.

{
  "hooks": {
    "PreModelSwitch": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/pin-model.js"]
          }
        ]
      }
    ]
  }
}

Piping the documented input with to_model set to claude-opus-4-6 exits 2 with This project runs on Claude 5 models only. Refused: claude-opus-4-6 (requested as claude-opus-4-6, via command). on stderr; with claude-opus-5 it exits 0. To confirm the live behavior, run /model claude-opus-4-6 from a session on a different model: Claude Code keeps the current model and reports that a PreModelSwitch hook blocked the switch, with your message as the reason.

If your sessions run through Bedrock, Vertex AI, or a gateway, run the logging hook from example 3 for a day first, so the regular expression here matches the to_model strings your setup actually produces.

Example 2: a cost gate

This one asks for confirmation only when the switch would cost more than fifty cents in cache writes, and lets cheaper switches through silently. Save it as .claude/hooks/switch-cost-gate.js:

#!/usr/bin/env node
// .claude/hooks/switch-cost-gate.js
// PreModelSwitch: confirm switches that would cost more than THRESHOLD_USD
// in cache writes, let cheaper ones through without the built-in prompt.
const THRESHOLD_USD = 0.5;

const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
const usd = input.estimated_cache_write_usd ?? 0;
const ktokens = Math.round((input.context_tokens ?? 0) / 1000);

const out = { hookSpecificOutput: { hookEventName: "PreModelSwitch" } };

if (usd >= THRESHOLD_USD) {
  out.hookSpecificOutput.permissionDecision = "ask";
  out.hookSpecificOutput.permissionDecisionReason =
    `Switching to ${input.to_model} re-sends about ${ktokens}k tokens,` +
    ` roughly $${usd.toFixed(2)} in cache writes (${input.pricing} pricing,` +
    ` ${input.cache_ttl} TTL). Continue?`;
} else {
  out.hookSpecificOutput.permissionDecision = "allow";
}

console.log(JSON.stringify(out));

Register it the same way as example 1, swapping the script path. Fed the documented input above, it prints an "ask" decision with the reason Switching to claude-opus-5 re-sends about 182k tokens, roughly $1.14 in cache writes (catalog pricing, 5m TTL). Continue?. Fed an 8,000-token session with a $0.05 estimate, it prints "allow".

One trade-off to make deliberately: "allow" also skips Claude Code's own warm-cache confirmation, so below the threshold every switch is silent, warm cache or not. If you would rather keep the built-in prompt for the cheap cases, exit 0 with no output in the else branch instead of printing "allow". And remember rule 1 above: from an SDK host, this hook's "ask" is a refusal, so a threshold that feels right in the terminal may be a hard cap for automation. Branch on input.source === "sdk" if you run both.

Example 3: log every switch and flag the automatic ones

PostModelSwitch cannot block, but it sees everything, including the switches PreModelSwitch never does. On exit 0, whatever the hook prints to stdout is delivered to Claude with the next request (JSON output can use additionalContext for the same effect). This hook appends every switch to a log and, when the switch was an automatic fallback rather than a user request, tells Claude to say so. Save it as .claude/hooks/log-model-switch.js:

#!/usr/bin/env node
// .claude/hooks/log-model-switch.js
// PostModelSwitch: append every switch to a log, and tell Claude when the
// switch was not something the user asked for.
const fs = require("fs");
const path = require("path");

const input = JSON.parse(fs.readFileSync(0, "utf8"));
const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const line = [
  new Date().toISOString(),
  input.from_model,
  "->",
  input.to_model,
  `source=${input.source}`,
  `requested=${input.requested_model ?? "null"}`,
  `context_tokens=${input.context_tokens}`,
].join(" ");

fs.appendFileSync(path.join(root, ".claude", "model-switches.log"), line + "\n");

if (input.source === "auto") {
  console.log(
    `The session's model was changed to ${input.to_model} by an automatic` +
      ` fallback, not by the user. Mention this before continuing.`
  );
}
process.exit(0);

Register it under "PostModelSwitch" with the same exec-form pattern. Both hook forms export CLAUDE_PROJECT_DIR as an environment variable on the spawned process, which is what the script reads. Fed a source: "auto" input it writes the log line and prints the notice; fed source: "resume" it logs and stays quiet.

Two delivery details from the docs. If the hook has not finished within five seconds of your next prompt, Claude Code sends that request without the output and attaches it to the following one. And if the model changes several times before the next request, only the output for the last switch's target model is delivered.

Where to put the hook

Hook entries merge across settings levels rather than replacing each other. User, project, and local settings add their own hooks without removing managed ones, and disableAllHooks set outside managed settings cannot turn managed hooks off. So a model policy belongs in managed settings, a team convention in the project's .claude/settings.json, and a personal cost gate in ~/.claude/settings.json. Note that a session started with --restricted loads only managed settings and --settings, so hooks in the other files do not run there; see our restricted mode article.

FAQ

Does PreModelSwitch fire when Claude Code falls back to another model automatically?

No. Automatic fallback and the model restore on resume are switches Claude Code makes on its own, and only PostModelSwitch sees them, with source set to "auto" or "resume". If you need to know about them, log them from PostModelSwitch as in example 3. There is no way to prevent them with a hook.

Does PreModelSwitch fire on /effort?

The hooks reference does not list /effort among the triggers; the event is about the session's model, not its effort level. Changing effort does start a fresh cache and gets the same built-in warm-cache confirmation as a model switch, but as of v2.1.251 that confirmation is not something a hook can control.

Can a hook skip the "cache is still warm" confirmation entirely?

Yes. A PreModelSwitch hook that returns "permissionDecision": "allow" proceeds and skips the confirmation Claude Code shows while the cache is warm. The reverse also works: "ask" forces the confirmation even when the cache has expired and Claude Code would otherwise switch silently.

Can a PostModelSwitch hook undo a switch?

No. By the time it runs the model has already changed, and the event has no decision control. Its output is for side effects and for context delivered to Claude with the next request.

Which version do I need?

Claude Code v2.1.251 or later for both events. Check with claude --version. On older versions the hooks are ignored, and there is no error telling you so, so verify with the /model test described under example 1 after upgrading.