Claude Code v2.1.251, released 28 August 2026, added two hook events aimed at a single moment that previously had no hook coverage at all: the instant the session's model changes. PreModelSwitch fires before that happens and can stop it. PostModelSwitch fires after, whatever caused it. Between them, they close a real gap for anyone who has ever had a session quietly end up on the wrong model, whether from a fat-fingered /model command, an SDK host swapping models mid-task, or fast mode kicking in somewhere the budget didn't expect it.
This sits alongside a second, related change in the same release: SessionStart resume hooks now also receive the session's staleness and the estimated cost of re-warming its prompt cache, so a hook deciding whether to nudge you about caching has real numbers to work from. That's a smaller addition, but it points at the same theme: Claude Code v2.1.251 gave hooks more visibility into cache and model state, not just tool calls.
What actually fires, and when
Claude Code's own hook lifecycle table describes the two events plainly:
PreModelSwitch: "Before Claude Code applies a model switch that you or a client requested. Can block the switch."PostModelSwitch: "After the session's model changes, including changes Claude Code makes on its own, such as restoring the model when you resume a session."
That second line matters more than it looks. PostModelSwitch isn't scoped to switches a person asked for. It also fires for changes Claude Code makes by itself, the clearest example being a resumed session getting its previous model back. If you want a hook that reliably tracks which model a session is actually using at any given moment, PostModelSwitch is the one to write, not something layered on top of /model or a settings watcher.
A model switch, in this context, is anything that changes the model attached to the running session: you typing /model, toggling fast mode, a host application such as an IDE extension, the Claude Agent SDK, or Claude Desktop requesting a different model, or Claude Code itself restoring a saved model on resume. PreModelSwitch and PostModelSwitch fire around all of these, not only manual ones, which is the whole point of having a hook here instead of just watching for a slash command.
Configuring the hooks
Model-switch hooks use the same three-level settings.json structure every Claude Code hook uses: pick the event, add a matcher to filter which switches it fires for, then list one or more command handlers:
{
"hooks": {
"PreModelSwitch": [
{
"matcher": ".*opus.*",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/confirm-opus-switch.sh",
"timeout": 30
}
]
}
],
"PostModelSwitch": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/log-model-change.sh"
}
]
}
]
}
}
The matcher for both events filters on the canonical name of the model the session is switching to, not the model it's leaving. Claude Code's documented matcher examples for these two events include a bare model ID (claude-opus-5), an alternation of two IDs (claude-opus-4-6|claude-opus-5), and a regular expression (.*opus.*). A matcher on to_model alone is enough for the common case, blocking or logging switches into a specific model or family, without needing to inspect the hook's input to filter further.
What the hook actually receives
Every Claude Code hook gets a set of common fields on stdin: session_id, prompt_id, transcript_path, cwd, permission_mode, hook_event_name, and, inside a subagent, agent_id and agent_type. PreModelSwitch and PostModelSwitch add two fields specific to them:
{
"session_id": "abc123",
"cwd": "/home/user/my-project",
"permission_mode": "auto",
"hook_event_name": "PreModelSwitch",
"from_model": "claude-sonnet-5",
"to_model": "claude-opus-5"
}
from_model and to_model name the model the session is leaving and the one it's headed to. This is a deliberate substitution: Claude Code's documentation is explicit that only SessionStart hooks ever receive a plain model field, and even then not always. PreModelSwitch and PostModelSwitch use from_model/to_model instead, precisely because a single model field can't describe a transition. If you want a hook that reliably knows which model a session is on at any point in its life, chain off PostModelSwitch's to_model, not a SessionStart field that isn't guaranteed to appear.
How blocking actually works
PreModelSwitch is one of the hook events that can block, and the mechanism is the same one every blocking hook event uses: exit code 2. Claude Code's own description is unambiguous about precedence here: "exit 2 blocks whether or not you print JSON: even a JSON permissionDecision of 'allow' can't override it." There's no separate opt-in flag or event-specific decision field you need to get right first. A PreModelSwitch command that exits 2 stops the switch, full stop, and the message shown for the block is the reason from your JSON's blocking decision if you printed one, or your stderr text otherwise.
Timeouts count as a block too, and this is worth knowing before you write a slow hook: on PreModelSwitch specifically, a hook that gets canceled for running past its timeout blocks the switch, the same as an explicit exit 2 would. A PreModelSwitch hook that calls out to a slow external service and occasionally times out will occasionally block model switches it never meant to reject. Keep the timeout tight and the hook fast, or fail open deliberately inside the script rather than letting the platform's own timeout make that decision for you.
PostModelSwitch can't block anything, which makes sense given it fires after the switch has already happened. What it can do instead is unusually visible for a hook: it's one of only four events (alongside UserPromptSubmit, UserPromptExpansion, and SessionStart) where Claude Code takes a hook's plain-text stdout and adds it to the conversation as context Claude can see and act on, rather than writing it only to the debug log the way most hooks' output is handled. A PostModelSwitch hook that prints a short note about what changed, why, or what the new model is good or bad at, actually reaches Claude, not just your logs.
Worked example: confirming a downgrade to a cheaper model
Say a team wants Claude to check in before a session drops to Haiku mid-task, since a downgrade partway through a large refactor can produce lower-quality edits than starting the session on Haiku from the beginning would. A PreModelSwitch hook matching that specific transition, with a short, fast command, is a reasonable way to enforce that without banning Haiku outright:
{
"hooks": {
"PreModelSwitch": [
{
"matcher": "claude-haiku-4-5",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/warn-haiku-downgrade.sh",
"timeout": 10
}
]
}
]
}
}
warn-haiku-downgrade.sh reads from_model and to_model from stdin, and exits 2 with a short stderr message when the switch looks unwanted, for instance mid-session rather than at the very start. Keep the timeout short: at 10 seconds, a hook that hangs blocks the switch by timeout alone, which is the fail-safe behavior here, not a bug to route around.
Worked example: a running log of model changes
A simpler and lower-risk use is a PostModelSwitch hook that just appends a line to a file every time a session's model changes, with no matcher at all so it fires for every switch:
#!/bin/bash
INPUT=$(cat)
FROM=$(echo "$INPUT" | jq -r '.from_model')
TO=$(echo "$INPUT" | jq -r '.to_model')
echo "$(date -u +%FT%TZ) $FROM -> $TO" >> "$CLAUDE_PROJECT_DIR/.claude/model-switch-log.txt"
Because this hook exits 0 and prints nothing to stdout, it has no effect on what Claude sees; it just records history. Swap the plain append for a short stdout message, and the same hook becomes visible to Claude on every switch instead, since PostModelSwitch is one of the events whose stdout gets surfaced as context.
Where this fits with Claude Code's other hooks
Model-switch hooks are narrower than the tool-lifecycle hooks most Claude Code users reach for first, PreToolUse and PostToolUse, which fire on every tool call rather than on the comparatively rare event of a model change. They're closer in spirit to SessionStart, which also fires around session-level state rather than individual actions, and the two are designed to work together: SessionStart's new staleness and re-cache cost fields, also added in v2.1.251, tell a resume hook how expensive rebuilding the cache will be, while PostModelSwitch tells any hook exactly which model a resumed session landed on. A hook that wants to warn about an expensive resume onto an unexpected model needs information from both events, not just one.
They're also a narrower tool than settings like ANTHROPIC_DEFAULT_MODEL or a curated model picker for controlling which models a session can reach in the first place. Those decide what's offered; PreModelSwitch and PostModelSwitch react to what's actually chosen, whoever or whatever chose it. A team that wants to both restrict the model list and enforce a policy on switches within that list needs both: a default or curated picker to shape the options, and a hook to catch anything that still reaches an unwanted model, including switches a host application makes without going through /model at all.
Troubleshooting
The hook never fires. Confirm you're on Claude Code v2.1.251 or later; claude --version shows the running version. Both events are new in that release and don't exist on earlier ones, so a hook configured for them on an older version is silently never invoked.
A PreModelSwitch hook that should allow the switch still blocks it. Check the hook's actual exit code, not just its printed JSON. A permissionDecision of "allow" in your JSON output has no effect if the process itself exits 2; exit code wins outright on blocking events. Also check whether the hook is running long enough to hit its timeout, since a timeout blocks the switch the same way an explicit exit 2 does.
A PostModelSwitch hook's message never reaches Claude. Confirm the hook exits 0 and writes genuinely plain text to stdout, not JSON, since Claude Code only treats stdout starting with { and ending with } as JSON, and only plain-text stdout from this specific set of events (UserPromptSubmit, UserPromptExpansion, SessionStart, PostModelSwitch) is surfaced as context. Anything printed by a different, unrelated hook event stays in the debug log only.
A matcher isn't catching the switch you expect. Remember the matcher filters on to_model, the model the session is switching into, not from_model. A matcher meant to catch "anything leaving Opus" needs to inspect from_model inside the hook body instead, since the matcher itself can't filter on it.
Where to go next
For the settings that decide which models a session can reach before any switch happens, see ANTHROPIC_DEFAULT_MODEL explained and Claude Code's modelPricing setting. For the other side of what v2.1.251 shipped, the security fixes in the same release, see what Claude Code's v2.1.251 security fixes patched. Browse the full Claude Code catalog at getclaudeskills.com/platforms/claude-code.
Verified 30 August 2026 directly against Claude Code's hooks reference at code.claude.com/docs/en/hooks, read with a verbatim-quote-only prompt to confirm the lifecycle table, matcher patterns, common input fields, and exit code 2 behavior sections exactly as published, plus the v2.1.251 changelog entry that introduced both events.
