Claude Code hooks
The PostToolUse hook in Claude Code
PostToolUse fires after a tool call succeeds. Fields, exit codes, how decision block works, and a working example that checks written files.
Reach for this one when a check can only be answered by looking at what actually got written, not at what was asked for. A PreToolUse hook sees the command before it runs; this one sees the result, which is the only way to catch a write that succeeded on the surface but produced the wrong content.
- When it fires
- After a tool call has already succeeded.
- Can it block?
- Yes, but the work is already done. Blocking here feeds a correction back to Claude rather than preventing anything.
- Matcher
- Yes, on
tool_name - Matcher examples
Write|EditBashmcp__.*
What it receives
The event arrives as JSON on standard input. These are the fields worth reading.
| Field | Type | What it is |
|---|---|---|
tool_name | string | The tool that just ran. |
tool_input | object | The arguments it ran with. For Write and Edit this carries file_path. |
tool_output | string | What the tool returned. |
permission_mode | string | The mode in force for the call. |
session_id | string | Stable for the session. |
cwd | string | Working directory at the time of the call. |
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. |
2 | The turn continues to the next model call, but stderr is shown to Claude as a problem to fix. |
other | Non-blocking error. First line of stderr appears in the transcript. |
What it can return
decision of block stops Claude continuing and hands it the reason. updatedToolOutput rewrites what Claude sees the tool returned, which is how you trim a noisy command's output down to the part that matters.
{
"decision": "block",
"reason": "string",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"updatedToolOutput": "string",
"additionalContext": "string"
},
"systemMessage": "string"
}
Catching banned words the moment a file is written
Every site here bans em dashes and a list of words that make copy read like it came out of a model. A rule in a document is invisible to a session that never opens that document, so the rule runs as a PostToolUse hook on Write and Edit instead. It scans the file that was just written, and on a hit it exits 2 so the finding goes straight back to Claude, which corrects it on the next turn. It never edits or reverts anything itself.
In settings.json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "node ~/.claude/hooks/ai-tells-check.mjs" },
{ "type": "command", "command": "node ~/.claude/hooks/track-edits.mjs" }
]
}
]
}
}
The script
const fs = require('fs');
const input = JSON.parse(fs.readFileSync(0, 'utf8')) || {};
const path = (input.tool_input || {}).file_path || '';
if (!/\.(md|html|txt)$/i.test(path)) process.exit(0);
// Your own list. \u2013 and \u2014 are the en and em dash.
const banned = /\b(delve|leverage|robust|seamless|elevate|utilize)\b|[\u2013\u2014]/gi;
let text;
try {
text = fs.readFileSync(path, 'utf8');
} catch {
process.exit(0); // the write may have been a delete or a rename
}
const hits = [...new Set(text.match(banned) || [])];
if (!hits.length) process.exit(0);
console.error(`${path} uses: ${hits.join(', ')}. Rewrite these.`);
process.exit(2);
Two hooks under one matcher run as a list. Both fire on every matching write.
The catch
Exit 2 here does not undo the write. The file is on disk either way, so a PostToolUse hook is an advisor, not a guard. If you need something to never happen, that belongs in PreToolUse.