A hook that halts a Claude Code session when the model serving it changes mid-session — for example when Fable 5 falls back to Opus 4.8 because credits ran out or a safety classifier flagged a request.
Without this, a session can silently continue for tens of minutes on a model you did not choose. In the case that prompted this, a session ran 309 messages on Fable 5, switched at the 310th, and continued for 91 more messages over 30 minutes before anyone noticed.
mkdir -p .claude/hooks
curl -o .claude/hooks/model-guard.sh \
https://gist.githubusercontent.com/jimmc414/6e7d61bfe2af477b30b444802f64dbf2/raw/model-guard.sh
chmod +x .claude/hooks/model-guard.shRegister it in .claude/settings.json (or ~/.claude/settings.json for all
projects):
{
"hooks": {
"UserPromptSubmit": [
{"hooks": [{"type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/model-guard.sh"}]}
],
"PreToolUse": [
{"hooks": [{"type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/model-guard.sh"}]}
]
}
}Requires jq. Set MODEL_GUARD_EXPECT to the model you want to pin; it
defaults to claude-fable-5.
Registering on both events matters: UserPromptSubmit catches a downgrade
before your next turn spends tokens on the wrong model, and PreToolUse
catches one that happens mid-turn, at the first tool call rather than at the
end of the turn.
When it trips, the session halts with:
Model guard: session is running on claude-opus-4-8, expected claude-fable-5.
Claude Code reported: "Switched to Opus 4.8 (1M context) for this session ·
Fable 5 requires usage credits · /model to change" Run /model to switch back,
then resume.
Written for anyone rebuilding this from scratch. These are the non-obvious facts; without them the natural design is subtly wrong.
1. No hook event carries the live model. SessionStart is the only event
with a model field, it fires once at startup, and it is not guaranteed to be
present. There is no $CLAUDE_MODEL environment variable and no "model
changed" event to subscribe to. A guard built on SessionStart looks correct,
passes a casual test, and then silently never fires for the actual problem —
it reports the model you started on and never speaks again.
2. Every hook event does receive transcript_path on stdin. The transcript
is JSONL, and each assistant line carries .message.model naming the model
that actually served it. A mid-session switch appears as that value changing
from one line to the next. This is the signal to build on.
3. Filter <synthetic>. Some assistant entries carry that literal string
as the model and will produce false readings. Use fromjson? so malformed or
partial lines are skipped rather than raising.
4. A forced switch writes its own record, one line before the first assistant message on the new model:
{"type":"system","subtype":"model_consent_fallback","level":"warning",
"content":"Switched to Opus 4.8 (1M context) for this session · Fable 5 requires usage credits · /model to change"}Trigger on .message.model rather than this notice — the model field also
catches a manual /model switch and fallback-chain switches, which produce no
such notice. Use the notice only to quote why in the stop message.
5. To halt, print {"continue": false, "stopReason": "..."} to stdout and
exit 0. Exit 0 silently on any unreadable, missing, or empty transcript. A
false halt that kills a working session is worse than a missed detection, so
only stop when a model has been positively read and differs from expectation.
6. Read only the file tail. Transcripts reach 10MB+; a full scan costs ~108ms per event versus ~20ms for a 256KB tail slice. Drop the first line of the slice, which is usually truncated mid-JSON, and fall back to a full scan if the slice yields no model.
7. Detection is inherently one message late. The switch happens server-side when the request is made, so nothing exists for the hook to read until a turn has already run on the new model. You stop at message 1, not message 0. Don't go hunting for an earlier interception point; there isn't one.
For the safety-classifier fallback specifically, Claude Code ships options that are cleaner than any hook, though neither covers a credit-driven switch:
/config→ turn off "switch models when a message is flagged", so a flagged request pauses and asks instead of switching silently.- An
availableModelsallowlist that excludes the fallback target, which makes the flagged request refuse rather than downgrade.