PostToolUse Hooks in Claude Code: Auto-Format and Auto-Test Without the Noise
Wire a PostToolUse hook to run prettier or pytest after every Write/Edit, parse the JSON decision protocol, and use suppressOutput to keep the transcript clean.
PostToolUse Hooks in Claude Code: Auto-Format and Auto-Test Without the Noise
Claude Code edits files. Your CI cares about formatting, lint, and tests. The cheapest way to bridge the two is a PostToolUse hook: a shell command the agent runs after every Write, Edit, or MultiEdit, with structured JSON in and structured JSON out. Done right, your agent self-corrects on a failed prettier run before you ever see the diff. Done wrong, your transcript becomes a wall of formatter chatter.
This piece walks the contract end-to-end: the input payload, the JSON decision protocol, the suppressOutput flag, and a working bash script you can drop into .claude/settings.json today.
Why PostToolUse over PreToolUse
Both hooks fire on tool calls, but they answer different questions.
PreToolUse runs before the tool executes. Its job is gating \u2014 block a Bash command that touches ~/.ssh/, refuse a Write to /etc/, etc. The agent never sees the result of the tool because the tool never ran.
PostToolUse runs after the tool executes. The tool already changed the file. Now the hook gets to react: format it, lint it, run a smoke test, or tell the agent "you broke something, here's the error, fix it."
For auto-format and auto-test, PostToolUse is the right hook 100% of the time. You want the file written first, then formatted in place, then either approved silently or sent back to the agent with a complaint. That feedback loop is the whole point.
The input payload
When a PostToolUse hook fires, Claude Code spawns your command and pipes a JSON object to stdin. The shape:
{
"session_id": "abc-123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/path/to/your/project",
"hook_event_name": "PostToolUse",
"tool_name": "Edit",
"tool_input": {
"file_path": "/path/to/your/project/src/index.ts",
"old_string": "...",
"new_string": "..."
},
"tool_response": {
"filePath": "/path/to/your/project/src/index.ts",
"success": true
}
}
The two fields you actually use: tool_name (filter to Write|Edit|MultiEdit) and tool_input.file_path (the file you want to format). Everything else is for logging or advanced flows.
The output protocol
Your hook talks back through three channels, in increasing power:
- Exit code 0, no stdout \u2014 silent success, agent continues. This is what you want 90% of the time.
- Exit code 2, stderr \u2014 non-blocking warning. Agent sees the stderr, decides whether to react.
- Exit code 0, JSON on stdout \u2014 structured decision. This is the lever for "tell the agent to fix it."
The JSON output schema:
{
"decision": "block",
"reason": "prettier failed: src/index.ts:42 unexpected token",
"suppressOutput": true
}
decision: "block" is the magic \u2014 Claude reads reason and treats it as a signal to revise. suppressOutput: true hides the JSON itself from the user-visible transcript so your terminal isn't drowning in hook chatter.
A working auto-format hook
Here's a bash script that formats TypeScript with prettier and TypeScript-checks with tsc, blocking on errors:
#!/usr/bin/env bash
# .claude/hooks/post-edit-format.sh
set -euo pipefail
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
# Only act on Write/Edit/MultiEdit
case "$tool_name" in
Write|Edit|MultiEdit) ;;
*) exit 0 ;;
esac
# Only TypeScript files
case "$file_path" in
*.ts|*.tsx) ;;
*) exit 0 ;;
esac
# Format in place; capture failure
if ! prettier_out=$(npx prettier --write "$file_path" 2>&1); then
jq -n --arg reason "prettier failed on $file_path: $prettier_out" \
'{decision: "block", reason: $reason, suppressOutput: true}'
exit 0
fi
# Type check just this file's project
if ! tsc_out=$(npx tsc --noEmit 2>&1); then
jq -n --arg reason "tsc errors after edit: $tsc_out" \
'{decision: "block", reason: $reason, suppressOutput: true}'
exit 0
fi
# Silent success
exit 0
Wire it in .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/post-edit-format.sh"
}
]
}
]
}
}
The matcher field is a regex against tool_name. The script double-checks anyway because hooks are cheap to run but expensive to debug when they silently misfire.
suppressOutput: when to use it
suppressOutput: true hides the hook's JSON output from the transcript view. Use it when:
- The hook succeeded silently (no point cluttering the log)
- The hook blocked with a clear
reason(Claude needs the reason; the human doesn't need the raw JSON) - The output would be repetitive (e.g.
prettier --checkrunning on every single edit in a 50-file refactor)
Skip suppressOutput (or set it to false) when:
- You're debugging why a hook is or isn't firing
- The hook is doing something the user genuinely wants to see (a "tests passed" green checkmark on every commit-ready edit)
The default is false, which means without the flag the JSON shows up. For production hooks, set it to true everywhere except your debug runs.
Auto-test as a different pattern
Auto-test deserves its own consideration. Running pytest on every Write is overkill \u2014 a 30-second test suite times out the hook (default 60s, configurable per-hook). Better patterns:
- Scope by path: only run tests for the package containing the edited file. Map
src/foo/bar.pytotests/foo/test_bar.pyand run that one file. - Run only fast tests: tag your suite with
pytest -m "not slow"in the hook; let CI run the slow ones. - Run a syntax check only:
python -m py_compile <file>in 50ms beatspytestin 30s and catches the most common breakage class \u2014 typos.
Here's the syntax-only Python variant:
#!/usr/bin/env bash
set -euo pipefail
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
[[ "$file_path" == *.py ]] || exit 0
if ! err=$(python3 -m py_compile "$file_path" 2>&1); then
jq -n --arg reason "syntax error: $err" \
'{decision: "block", reason: $reason, suppressOutput: true}'
fi
That hook runs in under 100ms per edit. Compare to a 30-second full pytest run that times out half the time and the math gets obvious: 300\u00d7 faster, catches 80% of the same regressions.
Watch the failure modes
Three things bite people when they first wire a PostToolUse hook:
- Hook timeout. The default 60s hard cap means a slow
tsc --noEmiton a 500-file project will be killed mid-run, leaving the agent in a confused state. Configuretimeoutper-hook in settings if you need more. - Recursive edits. A hook that calls back into Claude Code (e.g. via the SDK) can trigger another
PostToolUseand loop. Don't do that. Hooks should call deterministic CLI tools, not other agents. decision: "block"on a flaky linter. If your formatter sometimes fails on transient network issues (yes, some linters fetch rules), every flake becomes a fake "fix this" signal to the agent. Pin tool versions, run offline, and onlyblockon errors that are genuinely the agent's fault.
For a polyglot monorepo, the script above expands cleanly: branch on file extension, dispatch to language-specific commands, exit silent on no-op. Keep each hook under 200 lines and under 5 seconds.
References: