Skip to content

Instantly share code, notes, and snippets.

@jimmc414
Created July 21, 2026 05:39
Show Gist options
  • Select an option

  • Save jimmc414/6e7d61bfe2af477b30b444802f64dbf2 to your computer and use it in GitHub Desktop.

Select an option

Save jimmc414/6e7d61bfe2af477b30b444802f64dbf2 to your computer and use it in GitHub Desktop.
Claude Code hook: halt the session when the model is downgraded mid-session (e.g. Fable 5 -> Opus 4.8)

Claude Code model-downgrade guard

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.

Install

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.

Sample output

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.

Implementation notes

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.

Related native controls

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 availableModels allowlist that excludes the fallback target, which makes the flagged request refuse rather than downgrade.
#!/usr/bin/env bash
# Halts the session if the model running it is not the expected one.
# No hook event carries the live model, so read the last real assistant
# entry from the transcript instead. Synthetic entries have no true model.
set -uo pipefail
EXPECT="${MODEL_GUARD_EXPECT:-claude-fable-5}"
input=$(cat)
transcript=$(printf '%s' "$input" | jq -r '.transcript_path // empty')
[[ -f "$transcript" ]] || exit 0
# Scan only the tail: transcripts reach tens of MB and this runs per event.
# The first line of a tail slice is usually truncated, so drop it, and fall
# back to a full scan if the slice yielded no model.
read_model() {
jq -Rr 'fromjson? | select(.type=="assistant") | .message.model // empty' \
| grep -v '^<' | tail -1
}
actual=$(tail -c 262144 "$transcript" | tail -n +2 | read_model)
[[ -z "$actual" ]] && actual=$(read_model < "$transcript")
[[ -z "$actual" || "$actual" == "$EXPECT" ]] && exit 0
# Claude Code logs its own notice when it forces the switch. Quote it when
# present so the stop message says why, not just what.
notice=$(tail -c 262144 "$transcript" | tail -n +2 \
| jq -Rr 'fromjson? | select(.type=="system" and .subtype=="model_consent_fallback") | .content // empty' \
| tail -1)
jq -n --arg a "$actual" --arg e "$EXPECT" --arg n "$notice" \
'{continue: false,
stopReason: ("Model guard: session is running on \($a), expected \($e)."
+ (if $n == "" then "" else " Claude Code reported: \"" + $n + "\"" end)
+ " Run /model to switch back, then resume.")}'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment