Claude Code hooks
The Stop hook in Claude Code
Stop fires when Claude finishes responding, and can refuse to let it finish. Fields, exit codes, and the hook that blocks a session ending with uncommitted work.
The natural comparison is PostToolUse: that one reacts after a single tool call, while this is the last checkpoint before Claude hands the whole turn back. A check that belongs at "is this turn actually done" goes here, not scattered across every individual tool call in it.
- When it fires
- When Claude has finished responding and is about to hand the turn back to you.
- Can it block?
- Yes. Exit 2, or decision block, sends Claude back to work instead of ending the turn.
- 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 |
|---|---|---|
last_assistant_message | string | The reply Claude is about to finish on. |
stop_hook_active | boolean | True when a Stop hook already blocked this cycle. Check it or you build an infinite loop. |
session_id | string | Stable for the session. |
prompt_id | string | The turn being ended. |
transcript_path | string | Path to the transcript, if the hook wants to read the whole turn. |
cwd | string | Working 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.
| Exit | What happens |
|---|---|
0 | Let the turn end. |
2 | Do not stop. The conversation continues and stderr tells Claude why. |
other | Non-blocking error. The turn ends normally. |
What it can return
decision of block with a reason is the same as exit 2 but lets you attach additionalContext, which is where you put the specific commands you want run rather than a vague complaint.
{
"decision": "block",
"reason": "string",
"hookSpecificOutput": {
"hookEventName": "Stop",
"additionalContext": "string"
}
}
Refusing to finish with work sitting uncommitted
An audit of nineteen project folders here found finished, deployed work living only on one machine. This site tracked eight files while the live version had far more: a whole page, an invite, the function behind it, all shipped and none committed. No single session was careless. Nothing ever said to commit, and sessions run from a home directory so no repository feels like the current one. A Stop hook can catch it, because the one moment a session is certain to reach is the end. This one checks the directory the session ran in and blocks the first attempt to finish while anything is uncommitted. It blocks once, never twice, so a session can always end.
In settings.json
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node ~/.claude/hooks/uncommitted-check.mjs",
"timeout": 30,
"statusMessage": "Checking for uncommitted work"
}
]
}
]
}
}
The script
const fs = require('fs');
const { execSync } = require('child_process');
const input = JSON.parse(fs.readFileSync(0, 'utf8')) || {};
// One reminder is a reminder. Two is a trap.
if (input.stop_hook_active) process.exit(0);
const cwd = input.cwd || process.cwd();
let dirty = '';
try {
dirty = execSync('git status --porcelain', { cwd, encoding: 'utf8' }).trim();
} catch {
process.exit(0); // not a repository, nothing to say
}
if (!dirty) process.exit(0);
console.error('Uncommitted work in ' + cwd + ':\n' + dirty);
process.exit(2);
This checks the one directory the session ran in, which is enough to be useful. The version running on this machine instead reads a list of repositories that a PostToolUse hook recorded as written to, because a repository can be dirty for reasons the session had nothing to do with, and blocking on those teaches Claude to commit work it did not write.
The catch
stop_hook_active is not optional. Without that check the hook blocks, Claude works, the hook blocks again, and the session never ends. Every blocking Stop hook needs a way to give up.