Inside the Claude Code PostToolUse hook: what fires on edit
The Claude Code PostToolUse hook when Claude edits a file: exact payload fields, matcher and if syntax, exit codes, async handlers, and the failure event.
The Claude Code PostToolUse hook is the most useful integration point in the agent’s lifecycle and also the most under-documented one. This post walks through what fires, what payload you get, what the exit codes mean, and how tailtest hooks the lifecycle without blocking Claude’s turn. The Claude Code PostToolUse hook is what makes hook-based testing possible, so understanding the contract is worth the half-hour.
I’m Vaishnavi. I work on the Claude Code plugin specifically. I have spent months reading PostToolUse logs, instrumenting the hook entry point, and reproducing the few cases where it does something unexpected.
Updated 8 August 2026. Every field name, exit code, and config shape below was re-checked against the current Claude Code hooks reference on that date. The hook surface moved a long way since this post first ran in March: the payload keys are not what most blog posts (including our earlier version of this one) claimed, failures now have their own event, and command handlers can run asynchronously. If you built against the older shape, the corrections are marked in place.
What fires, and when
When Claude Code invokes a tool (Edit, Write, MultiEdit, Bash, and the rest), the lifecycle is:
PreToolUsehooks fire. These can block the tool call by exiting with code 2.- The tool runs.
- The tool returns a result.
PostToolUsehooks fire on success,PostToolUseFailureon failure. These see the result.PostToolBatchfires once after a batch of parallel calls resolves.- The result is rendered into Claude’s context as tool output.
- Claude composes its next response.
PostToolUse is the boundary between “the tool ran” and “Claude sees the result.” This is the only place in the lifecycle where you can intercept what Claude just did before Claude responds to it. Pre-tool hooks see the intent; post-tool hooks see the consequence. For testing, you want the consequence.
The hooks themselves are configured in ~/.claude/settings.json (user level), .claude/settings.json (project level), or .claude/settings.local.json (project, not committed). The shape is an object keyed by event name, and each entry pairs a matcher with the handlers to run:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "uvx tailtest --hook claude --event PostToolUse",
"if": "Edit(*.py)",
"timeout": 30
}
]
}
]
}
}
Two fields do the filtering, and they work at different levels. matcher is evaluated against the tool name: a plain string like Edit|Write matches those tools, and anything containing a character outside letters, digits, underscore, hyphen, space, comma, and escaped pipe is treated as an unanchored JavaScript regex, which is how ^Notebook works. The if field is the per-handler filter and takes permission-rule syntax, so Edit(*.py) or Bash(git *) narrows a matched event down to the paths or commands you care about. There is no file_path_regex field; path filtering is if.
timeout is in seconds and defaults to 600 for command hooks. Hooks that exceed it get cancelled.
One property to know before you design around precedence: hook entries merge additively across settings levels. Claude Code’s reference states that “hook entries merge across settings levels rather than replacing each other: user, project, and local settings add their own hooks without removing managed ones.” A project-level hook does not replace a user-level hook with a similar purpose. Both run. This is the correct behaviour for a testing tool that installs at project level, but it means an installer cannot assume it owns the event.
The event payload
When PostToolUse fires, Claude Code passes a JSON payload to the hook command’s stdin:
{
"session_id": "abc123",
"prompt_id": "...",
"transcript_path": "/Users/you/.claude/projects/.../session.jsonl",
"cwd": "/abs/path/to/project",
"permission_mode": "default",
"hook_event_name": "PostToolUse",
"tool_name": "Edit",
"tool_use_id": "toolu_...",
"tool_input": {
"file_path": "/abs/path/to/file.py",
"old_string": "original content",
"new_string": "replacement content"
},
"tool_response": {}
}
The field names are the part people get wrong, usually because they are guessing from the event name. It is hook_event_name, not event. It is tool_name, not tool. It is tool_response, not tool_result. For MultiEdit, tool_input carries an edits array whose entries hold the same file_path, old_string, and new_string shape, so a hook that reads tool_input.file_path unconditionally sees nothing on a multi-edit call.
Read the payload from stdin. There is no documented set of TOOL_* environment variables for PostToolUse, so a hook built on env vars is building on a coincidence rather than a contract.
Exit codes and what they mean
The exit code of the hook command is load-bearing, and it does not follow Unix convention. Claude Code interprets it as follows:
0means success. Stdout is parsed for structured JSON output, and stderr goes to the debug log only.2is the blocking error. Stderr is fed back to Claude as an error message. OnPreToolUsethis blocks the tool call; onPostToolUsethe tool has already run, so what it stops is the agentic loop before the next model call.- Any other non-zero code is non-blocking. Exit 1 does not block anything. The transcript shows a hook error notice with the first line of stderr and the action proceeds.
That third bullet is the one that costs people an afternoon. A hook that returns the test runner’s exit code directly will exit 1 on a failing test, and exit 1 does nothing. If you want a failing test to interrupt the agent, you exit 2 and put the reason on stderr.
Rather than a bare stdout line, PostToolUse can also return structured JSON on stdout with exit 0:
{
"systemMessage": "2 tests failing after this edit",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "tests/test_pricing.py::test_negative_quantity failed: expected ValueError"
}
}
additionalContext is the supported channel for feeding a summary into Claude’s next turn, systemMessage surfaces a warning to the human, and suppressOutput hides the hook’s stdout from the transcript while keeping it in the debug log.
The way tailtest uses this: we exit 0 in almost all cases and surface a structured summary that Claude reads in its next turn. The summary looks like:
[tailtest] tests:passed=12 failed=0 classified=ok adversarial=skipped budget=384
That one line is sufficient for Claude to know “edits are clean, continue.” When tests fail, the line expands:
[tailtest] tests:passed=10 failed=2 classified=real_bug,test_bug
[tailtest] real_bug: tests/test_pricing.py::test_negative_quantity
[tailtest] test_bug: tests/test_cart.py::test_add_item (stale fixture)
[tailtest] report: .tailtest/reports/latest.json
Claude reads this in its next turn and acts on it: usually fixing the real_bug, sometimes acknowledging the test_bug and regenerating the stale fixture. The structured form (real_bug, test_bug) maps to R12 classification labels.
Timing constraints and the latency budget
PostToolUse runs synchronously in Claude’s turn. Whatever the hook takes, the user feels. This is the single biggest constraint on hook design.
We measured latency budgets across 2,100 PostToolUse events in tailtest’s own dogfooding logs. The shape:
- Hook dispatch overhead (Claude Code’s machinery): 18ms median, 42ms p99
- Tailtest entry point (Python startup, config load, dispatch): 142ms median, 290ms p99
- Test runner (pytest with testmon): 980ms median, 3.2s p99
- R12 classification: 28ms median, 60ms p99
- R15 adversarial (when budget allows): 2.4s median, 8.1s p99
- Report write and emit: 12ms median, 24ms p99
Total median for a standard PostToolUse cycle without adversarial: 1.18 seconds. With adversarial: 3.58 seconds. Neither is close to the 600-second default timeout, which is the point: the timeout is not the constraint, the felt pause is.
The quick depth mode in tailtest exists to compress this. It skips R15 entirely and runs only impacted tests via pytest --testmon against the changed file’s blast radius. Median drops to 380ms. For tight refactor loops this is what you want.
There is now a second lever for the same problem. A command handler accepts "async": true, which runs it in the background without blocking the turn, and "asyncRewake": true, which runs it in the background and wakes Claude when it exits with code 2. For a test runner that is close to the ideal shape: the agent keeps working while the suite runs, and it gets interrupted only when something actually broke. The trade is ordering. An async hook has no guarantee that it finished before Claude composes its next message, so anything the model must see before its next turn still belongs in a synchronous handler.
Non-obvious gotchas
A few things we learned the hard way.
Failed edits do not come through PostToolUse. There is a separate PostToolUseFailure event: PostToolUse fires after a tool call succeeds, PostToolUseFailure after one fails. If you want to react to a rejected edit (the old_string did not match, for instance), you register the failure event. A hook that assumes it sees every attempt will silently miss the ones that matter most.
MultiEdit emits one PostToolUse for the whole batch, not one per edit. If Claude makes 6 edits in one MultiEdit call, you get one event whose tool_input.edits array holds them all. Read the array, not a top-level file_path.
Parallel tool calls have their own boundary. PostToolBatch fires after a full batch of parallel tool calls resolves and before the next model call. When an agent edits four files at once, four PostToolUse events and one PostToolBatch is usually the split you want: per-file checks on the former, the suite-level pass on the latter.
Bash tool also fires PostToolUse. If Claude runs a shell command, PostToolUse fires with tool_name set to Bash and no file path in tool_input. Filter on the matcher, or your test runner fires after every ls.
Working directory is not always the project root. The cwd field is wherever the session was started. If the user invoked Claude from a subdirectory, cwd is that subdir. We resolve to the project root by walking up to the nearest .tailtest/config.yaml or .git/.
Your hook is not the only hook. Because hook entries merge across user, project, local, managed, and plugin settings, an installer that writes a project-level entry is adding to the event, not taking it over. Design the handler to be safe when it runs alongside somebody else’s.
What we hook beyond PostToolUse
Tailtest’s Claude Code plugin also installs:
- A
PreToolUseveto onBashcalls that would run a known-destructive pattern in the project root (rm -rf,git push --force, etc.). This is conservative and overridable. - A
Stophook that runs at the end of Claude’s turn. The Stop hook does the cross-file consistency check that PostToolUse cannot do efficiently (some R-rules need the full set of edits in a turn before they can run). - A
Notificationhook that writes a structured event to.tailtest/sessions/<session_id>.jsonlfor the eventual session replay tooling.
The Stop hook is worth highlighting. PostToolUse is per-edit; Stop is per-turn. If Claude made 8 edits in one turn, PostToolUse fired 8 times and Stop fires once at the end. The Stop hook is where we run the suite-level checks (full test suite at standard depth, integration tests at thorough depth). The per-edit cycle catches the obvious bugs at edit time; the per-turn cycle catches the integration bugs at turn boundary.
The split mirrors what Shridip described in the 5 levels of AI testing maturity. Level 3 (per-edit hooks) catches unit-level bugs. Level 4 (per-turn integration plus failure classification) catches the next layer.
How to write your own PostToolUse hook
If you want to write your own hook without tailtest, the minimum viable shape:
#!/usr/bin/env bash
# .claude/hooks/run-tests-on-edit.sh
# Payload arrives as JSON on stdin. jq pulls the edited path out of tool_input.
payload=$(cat)
file=$(printf '%s' "$payload" | jq -r '.tool_input.file_path // .tool_input.edits[0].file_path // empty')
[ -n "$file" ] || exit 0
case "$file" in
*.py) pytest --testmon -q "$(dirname "$file")" || { echo "pytest failed for $file" >&2; exit 2; } ;;
*.ts|*.tsx) jest --findRelatedTests "$file" || { echo "jest failed for $file" >&2; exit 2; } ;;
*) exit 0 ;;
esac
Add this to your .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|MultiEdit|Write",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/run-tests-on-edit.sh" }
]
}
]
}
}
Note the exit 2 on failure. Exit 1 would let Claude carry on as if the suite passed. That is the entire MVP, and it is under thirty lines including the JSON. You do not need tailtest to start. What tailtest adds is the runner dispatch across four languages, R12 classification, R15 adversarial pass, the structured report, and the four-agent abstraction so the same config works across Claude Code, Cursor, Codex CLI, and Cline. If you only care about Claude Code and Python, the above bash script is a real starting point. If you grow into needing the rest, the upgrade path is uvx tailtest install --agent claude.
Where to read more
The agent edits platform page covers the runtime where the hook plugs in. The hook-based testing explained post covers the broader architectural argument for hooks over prompts. The Claude Code solution page walks through the full integration.
FAQ
What is the Claude Code PostToolUse hook?
PostToolUse is a hook event that fires after a Claude Code tool call succeeds (Edit, Write, MultiEdit, Bash, and the rest). It is configured under the hooks key in .claude/settings.json and runs a handler that receives the tool call as JSON on stdin. Failed tool calls raise PostToolUseFailure instead.
What fields are in the PostToolUse payload?
session_id, prompt_id, transcript_path, cwd, permission_mode, hook_event_name, tool_name, tool_use_id, tool_input, and tool_response. For the Edit tool, tool_input holds file_path, old_string, and new_string. For MultiEdit, it holds an edits array of objects with those same three fields.
Does PostToolUse block Claude’s response?
A command handler runs synchronously by default, so its latency is felt by the user. Setting "async": true runs it in the background instead, and "asyncRewake": true runs it in the background and wakes Claude if it exits with code 2.
Can PostToolUse roll back an edit?
No. PostToolUse fires after the edit has already happened. Exiting 2, or returning "decision": "block" with a reason, stops the agentic loop before the next model call and tells Claude what went wrong. To prevent an edit before it happens, use PreToolUse.
What is the maximum runtime for a PostToolUse hook?
The timeout field is in seconds and defaults to 600 for command hooks. In practice the default is not the constraint; anything over a second or two is felt as a pause, which is what the async options are for.
How is PostToolUse different from a git pre-commit hook?
PostToolUse fires per edit, inside the agent’s turn. Pre-commit fires per commit, when the human commits. Agents make dozens of edits between commits. PostToolUse catches what pre-commit misses.