# Claude Code hook: halt the session when the model is downgraded mid-session (e.g. Fable 5 -> Opus 4.8)

> Source: <https://gist.github.com/jimmc414/6e7d61bfe2af477b30b444802f64dbf2>
> Published: 2026-07-21 05:39:46+00:00

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 pauses and asks instead of switching silently.- An
`availableModels`

allowlist that excludes the fallback target, which makes the flagged request refuse rather than downgrade.
