The other half of the v2.1.251 pair
You already know PreModelSwitch as the gate. This is what runs after the switch lands. Like its partner, it needs Claude Code v2.1.251 or later. The two share most of a payload and the same matcher rule. They fire at different moments, and only one of them can refuse.
More switches than the gate sees
PreModelSwitch only sees switches you or your client asked for. PostModelSwitch fires after those, and after three kinds the gate never sees: automatic fallback that changes the session model, the model-setting transitions of entering and leaving plan mode, and a session restoring its saved model on resume. It does not fire when a fallback-chain model serves one turn without becoming the session’s model. source adds auto and resume to the values PreModelSwitch uses. requested_model is null for auto and holds the restored setting for resume.
The rest of the payload is the PreModelSwitch set: from_model, to_model, context_tokens, prompt_cache_warm, cache_ttl, estimated_cache_write_usd, and pricing. The matcher follows the same canonical-name rule, and the hooks reference publishes neither the mapping nor the rate behind pricing: "default".
Context arrives with the next request
Here is the part that trips people up: your hook runs after the model has changed, but the text you return does not land in the conversation right away. The JSON answer is hookSpecificOutput with hookEventName: "PostModelSwitch" and additionalContext, and plain stdout on exit 0 is context too. Either way the text is queued for the next request Claude Code sends, not injected on the spot. If the hook is still running five seconds after that prompt goes out, the request goes without it and the output attaches to the request after. If several switches happen before any request, only the output for the last target is delivered. The default timeout is 30 seconds.
The model has already changed by the time the hook runs. Exit 2 cannot undo that, and its stderr goes to you only. Exit 1 cannot either, with valid JSON still applied.
You switch models twice in a row before typing anything, then send a prompt. Your hook wrote a log line both times.
Only the context for the second target arrives. The first hook’s output was dropped when the second switch happened, and the log shows two runs and one delivery. Nothing else reports the drop.
Write the context for the model the session ends up on, and read to_model rather than assuming the run you are in is the one that will be delivered.