The typed command is not what Claude gets
You typed /review, or picked a prompt from an MCP server, and you might think Claude sees those exact characters. It does not. Claude Code expands the command into the full prompt first. UserPromptExpansion runs on that expansion, before the result reaches Claude, and it fires for direct prompt-type command expansion only.
One matcher, and omitting it means all of them
If you want to catch one command and not every slash command in the project, you need to understand the matcher. The matcher filters on command_name. Leave it out, or leave it empty, and your handler runs for every expansion in the session. The payload carries expansion_type, command_name, command_args, command_source, permission_mode, and the original prompt. The hooks reference lists two expansion_type values, slash_command and mcp_prompt. It does not list every value command_source can hold.
Stop the expansion, or add to it
Exit 0 lets the expansion proceed. Plain stdout or JSON additionalContext becomes context, the same as on UserPromptSubmit. Exit 1 also lets it proceed, with valid JSON still applied and plain output dropped. Exit 2 blocks the expansion and shows the JSON reason, or stderr, to you. The JSON shape is the same top-level decision: "block" with reason, plus hookSpecificOutput.additionalContext. The reference documents no field that replaces the expanded prompt. This hook is a gate and an annotator. It does not rewrite.
You write a hook to refuse /deploy outside working hours. It checks the clock and exits 2 with “deploys are closed until 09:00” on stderr. You leave the matcher off, since there is only one command you care about.
At 18:00 every custom command in the project shows your message and does nothing. An omitted matcher matches every expansion, and the hook never looked at command_name.
Set "matcher": "deploy", or read command_name inside the hook and exit 0 for everything else.