jared hebb ~ %

Claude Code hooks

The UserPromptSubmit hook in Claude Code

UserPromptSubmit fires when you send a message, before Claude reads it. It can add context or refuse the prompt outright.

The distinction that trips people up is between this event and UserPromptExpansion. A typed sentence raises only this one; a typed slash command raises both. A rule meant to catch everything a session sends has to sit here, not on the command-only event alone.

When it fires
When you submit a prompt, before Claude processes it.
Can it block?
Yes. Exit 2 blocks the prompt and erases it.
Matcher
No. It always fires.

What it receives

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

FieldTypeWhat it is
promptstringExactly what you typed.
prompt_idstringIdentifies this turn.
permission_modestringThe mode the session is in.
session_idstringStable for the session.
cwdstringWorking directory.
transcript_pathstringPath to the transcript.

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 as context Claude can see alongside your message.
2The prompt is blocked and erased. stderr is shown to Claude as an error.
otherNon-blocking error.

What it can return

additionalContext is the field worth knowing. It injects text Claude sees as part of the turn without you typing it, which is how a session gets today's date, the current branch, or an open ticket list every single time without anyone remembering to paste it.

{
  "decision": "block",
  "reason": "string",
  "additionalContext": "string",
  "systemMessage": "string",
  "continue": true,
  "stopReason": "string"
}

Giving every message the state it needs

The most useful version of this hook is boring. It prints a few facts Claude would otherwise guess at, and exits 0. Anything printed on stdout is added to the turn.

In settings.json

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          { "type": "command", "command": "node ~/.claude/hooks/session-facts.mjs", "timeout": 10 }
        ]
      }
    ]
  }
}

The script

const { execSync } = require('child_process');
const branch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim();
console.log(`Today is ${new Date().toISOString().slice(0, 10)}. Branch: ${branch}.`);
process.exit(0);

No matcher on this event, so there is no matcher key in the configuration. It fires on every message.

The catch

The default timeout on this event is 30 seconds, lowered from the 600 that most events get. It sits between you pressing enter and anything happening, so a slow script here is felt on every single message.