PostToolUse hooks: the after-the-fact watchdog pattern
Published 2026-05-11 by Owner
The agent just ran prettier across your file. Or didn’t. You’ll find out at code review.
That’s the gap PostToolUse closes. It fires after each tool call completes — Bash, Edit, Write, Read, and the rest — and gives a hook script visibility into what just happened: which tool ran, what input it received, and what output it produced. The hook can react: format the file, append a log entry, emit a warning, push a metric. It cannot undo the tool call. That window is already closed.
This asymmetry is exactly the point. PostToolUse is an observer, not a gatekeeper. It cleans up, records, and annotates without slowing down the agent’s decision loop. Claude Code’s hook system has two sides — PreToolUse for interception, PostToolUse for observation — and they solve different problems. Most people reach for PreToolUse first because “blocking bad actions” sounds important. PostToolUse is quieter, but in practice it’s where the most durable automation lives: formatters that run without being asked, logs that outlast the session, warnings that surface decisions the agent made three steps ago. The asymmetry is a feature, not a limitation.
PreToolUse vs PostToolUse: picking the right hook
Claude Code offers hooks at two points in the tool lifecycle. PreToolUse fires before the call and can block it — return a non-zero exit or a specific JSON {"block": true} to prevent execution. PostToolUse fires after the call and cannot block anything; the deed is done.
The choice between them is not about capability, it’s about when you have enough information to act.
PreToolUse suits policies that can be checked without knowing the outcome:
- “Never run
rm -rfwithout a precedingls” - “Never write to paths under
dist/directly” - “Require a ticket number in any commit message”
PostToolUse suits reactions that depend on the result:
- “Format the file after any Edit or Write to it”
- “Log every Bash command and its exit code to an audit trail”
- “Warn when the agent just modified a generated file it shouldn’t have touched”
There’s also a practical reason to prefer PostToolUse for non-blocking concerns: if the hook fails, it doesn’t interrupt the agent. A crash in a PostToolUse formatter is annoying; a crash in a PreToolUse validator that blocks every Edit call grinds the session to a halt.
Use PreToolUse when you want a hard stop. Use PostToolUse when you want to observe and react.
What a PostToolUse hook can return
Hook scripts communicate back to the agent in two ways.
Exit code. A zero exit means the hook ran cleanly. A non-zero exit is logged but does not stop the session — the tool output is already delivered to the model. This is different from PreToolUse, where a non-zero exit blocks execution. A failing PostToolUse hook is surfaced in the UI as a warning but execution continues; the session does not halt.
Stdout text. Anything the hook writes to stdout is injected as a system-level context message in the conversation. The model sees it on the next turn. This is the primary mechanism for PostToolUse to influence behavior: not by blocking, but by informing. The injected text appears in the conversation history the same way a tool result would, but with a system tag indicating it came from a hook rather than a tool call.
A hook that outputs nothing on success and a warning string on anomaly is the right default posture. Chatty hooks that output status text on every successful call add noise to the conversation that the model has to parse around. The goal is signal, not confirmation that things are working.
A real hook: auto-format on Write and Edit
The most common PostToolUse use case is running a formatter after file edits. Here’s a working configuration and script.
.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/format-on-write.sh"
}
]
}
]
}
}
.claude/hooks/format-on-write.sh:
#!/usr/bin/env bash
set -euo pipefail
# Claude Code passes tool context as JSON on stdin.
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Only act on file paths we care about.
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
EXT="${FILE_PATH##*.}"
case "$EXT" in
ts|tsx|js|jsx|mjs|cjs)
if command -v prettier &>/dev/null; then
prettier --write "$FILE_PATH" --log-level silent
fi
;;
py)
if command -v ruff &>/dev/null; then
ruff format "$FILE_PATH" --quiet
fi
;;
go)
if command -v gofmt &>/dev/null; then
gofmt -w "$FILE_PATH"
fi
;;
*)
exit 0
;;
esac
# Silent exit on success — no stdout noise.
exit 0
The jq extraction of .tool_input.file_path handles both Edit and Write, which both carry the target path under that key. Read does not carry a writable path, but the matcher "Edit|Write" already filters it out before the script runs.
The // empty fallback in the jq call handles tool calls that don’t carry a file_path field — exit 0 skips them cleanly rather than failing with a null-path error. Without this fallback, any tool call matched by the pattern that lacks a file path will cause the script to error on the empty-string case expansion.
Running prettier --write directly from a hook means the formatted file is on disk before the model’s next turn. If the model reads the file back, it sees the formatted version. The agent doesn’t need to know formatting happened; it just sees clean output. This is the closest thing to transparent tooling in the Claude Code hook system: the agent works, the hook cleans up, and neither has to know about the other. Critically, the hook never writes to stdout — injecting “file formatted” on every Edit is exactly the noisy hook pattern described in the next section.
Audit log hook
A lighter-weight use case: append every tool call to a session log.
.claude/hooks/audit-log.sh:
#!/usr/bin/env bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
SESSION_LOG=".claude/session-audit.log"
# One-line entry: timestamp, tool name, and a summary of the input.
SUMMARY=$(echo "$INPUT" | jq -r '
.tool_input |
if .command then "cmd: " + .command
elif .file_path then "file: " + .file_path
elif .query then "query: " + .query
else "(no summary)"
end
')
echo "${TIMESTAMP} [${TOOL}] ${SUMMARY}" >> "$SESSION_LOG"
exit 0
Register it with "matcher": ".*" to catch all tool calls. The log grows one line per tool call and survives the session. Useful for debugging what the agent did, in order, when something went wrong three steps back.
The audit log hook is the one case where "matcher": ".*" is appropriate. It produces no stdout output, so it adds zero noise to the conversation. It just writes to a file and exits. The broad matcher is fine because the hook is genuinely relevant to all tool calls — knowing the agent ran a Read before an Edit is as useful as knowing about the Edit itself when you’re reconstructing what went wrong.
The noisy hook trap
The matcher ".*" fires the hook on every tool call without exception. For a formatter hook, that’s wrong — formatting a Read output makes no sense. For a logging hook, it’s fine. The problem is when a hook that should be surgical uses a broad matcher and then outputs something on each call.
Consider a hook that emits "✓ tool completed" to stdout on every successful Bash command. Over a 40-tool session that runs 15 Bash calls, the model receives 15 injected context messages reading "✓ tool completed". None of them carry information. All of them occupy context window space and slightly increase the chance the model misreads the conversation state.
The fix is scope discipline:
{
"matcher": "Edit|Write",
"hooks": [...]
}
Name exactly the tools the hook is relevant for. Claude Code supports regex matchers, so "matcher": "Bash" fires only on Bash calls, "matcher": "Edit|Write" on file writes only, and "matcher": ".*" on everything.
A hook that produces output should only produce it when the output conveys something the model needs to know. “File formatted” is noise. “Warning: you just wrote to src/generated/schema.ts — this file is auto-generated and will be overwritten on next build” is signal.
The heuristic: if a PostToolUse hook would output the same message 80% of the time, the output should be silenced in those cases. Reserve stdout for the anomalies.
What PostToolUse doesn’t do
A few limits worth knowing before building on top of this:
It cannot undo. If the agent runs a destructive Bash command, PostToolUse fires after. The damage is done. For operations that warrant a hard stop, PreToolUse with a blocking return is the right tool.
Output injection is advisory. The model reads the injected context, but it can choose to continue without acting on it. A warning that says “you just edited a generated file” will be seen, but the model may have already planned the next step. Use PostToolUse warnings to inform; use PreToolUse blockers to enforce.
Tool output is not modified. PostToolUse sees the tool output but cannot alter what the model received. The model’s view of the tool result is already fixed when the hook runs.
These constraints make PostToolUse most useful as infrastructure: logging, formatting, metric emission, downstream automation triggers. Anything where the question is “what happened?” rather than “should this happen?”
Where to go from here
The formatter + audit log combination covers most production hook use cases. From there, the patterns worth exploring:
- Trigger a test runner after writes to
*.test.tsfiles and inject the result as context so the model sees failures on the turn immediately following the edit — no separate “run tests” prompt needed - Emit structured JSON to a log aggregator for session-level analytics across many agent runs; a few weeks of data will show which tool sequences correlate with sessions that end in a revert
- Check modified files against a list of protected paths and surface a warning if the agent strays into infrastructure code it shouldn’t touch
- Write each modified file path to a
.claude/changed-files.txtmanifest so a post-session script knows exactly what to review, test, or deploy
The insight that ties all of this together: PostToolUse hooks are most effective when they run as silent infrastructure until something needs attention. The formatter never speaks. The audit logger never speaks. The protected-path check only speaks when the condition triggers. That’s the pattern — quiet by default, signal on anomaly — and it’s what keeps hook output from becoming background noise the model learns to ignore.
PreToolUse and PostToolUse are complementary. PostToolUse handles the reactive, non-blocking, observational layer. PreToolUse handles enforcement. A production hook setup typically uses both — PreToolUse as a policy layer that stops known-bad patterns before they execute, PostToolUse as the logging and cleanup layer that runs regardless.