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.
| Field | Type | What it is |
|---|---|---|
prompt | string | Exactly what you typed. |
prompt_id | string | Identifies this turn. |
permission_mode | string | The mode the session is in. |
session_id | string | Stable for the session. |
cwd | string | Working directory. |
transcript_path | string | Path 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.
| Exit | What happens |
|---|---|
0 | Success. Anything on stdout is added as context Claude can see alongside your message. |
2 | The prompt is blocked and erased. stderr is shown to Claude as an error. |
other | Non-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.