jared hebb ~ %

Claude Code hooks

The UserPromptExpansion hook in Claude Code

UserPromptExpansion fires when a typed command expands into a prompt, before Claude sees it. It matches on the command name and can block the expansion.

expanded_prompt is the field worth reading closely: it carries the actual text about to reach Claude, which is not guaranteed to match the command's source file once arguments and any templating are resolved. A hook auditing what a command really sends should read this field rather than assume from command_name alone.

When it fires
When a command you typed expands into a prompt, before that prompt reaches Claude.
Can it block?
Yes. Exit 2 blocks the expansion.
Matcher
Yes, on command_name
Matcher examples
post-week review|deploy

What it receives

The event arrives as JSON on standard input. These are the fields worth reading.

FieldTypeWhat it is
command_namestringWhich command was typed. Also what the matcher filters on.
expanded_promptstringThe full prompt the command expanded into.
prompt_idstringIdentifies this turn.
session_idstringStable for the session.
cwdstringWorking directory.

Exit codes

The exit code is the decision. Anything the script writes to standard error on a blocking exit is what Claude gets told.

ExitWhat happens
0Success. Anything on stdout is added to the context.
2Blocks the expansion. stderr is shown to Claude.
otherNon-blocking error.

What it can return

Matching on command_name is what makes this useful. It lets one command carry rules the others do not, without those rules living inside the command itself where they can be edited away.

{
  "decision": "block",
  "reason": "string",
  "additionalContext": "string",
  "systemMessage": "string"
}

Stopping a publishing command from running on the wrong day

A command that queues a week of posts should not run mid-week and quietly overwrite the queue. Checking that here refuses it before Claude has read a single instruction, which is cheaper than having Claude work it out and stop.

In settings.json

{
  "hooks": {
    "UserPromptExpansion": [
      {
        "matcher": "post-week",
        "hooks": [
          { "type": "command", "command": "node ~/.claude/hooks/weekend-only.mjs" }
        ]
      }
    ]
  }
}

The script

const day = new Date().getDay();
if (day === 0 || day === 6) process.exit(0);

console.error("This one is a weekend job. Running it now would overwrite the queue.");
process.exit(2);

The prompt is blocked before Claude reads it, so nothing is spent on a run that was never going to be wanted.

The catch

This is a different event from UserPromptSubmit. Typing a message raises UserPromptSubmit; typing a command that expands raises this one as well. A rule you want on both has to be attached to both.