jared hebb ~ %

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|Edit Bash mcp__.*

What it receives

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

FieldTypeWhat it is
tool_namestringThe tool that just ran.
tool_inputobjectThe arguments it ran with. For Write and Edit this carries file_path.
tool_outputstringWhat the tool returned.
permission_modestringThe mode in force for the call.
session_idstringStable for the session.
cwdstringWorking 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.

ExitWhat happens
0Success. Anything on stdout is added as context Claude can see.
2The turn continues to the next model call, but stderr is shown to Claude as a problem to fix.
otherNon-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.