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.sh
Register 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 s and asks instead of switching silently.- An
availableModels
allowlist that excludes the fallback target, which makes the flagged request refuse rather than downgrade.