Firing a Shell Command on PostToolUse with Hooks
A PostToolUse hook is a shell command that runs automatically right after a matched tool call finishes.
The most common use is auto-formatting: the moment Claude edits a file, a formatter runs against it without anyone asking.
Summary
Hooks are configured in settings.json, under an event name like PostToolUse.
Each hook entry has a matcher, which limits it to specific tools such as Edit or Write, and one or more hooks commands to run when that matcher fires.
Unlike a slash command, nothing about a hook goes through the model. The harness runs the shell command directly and does not ask Claude whether it should.
A hook receives details about the completed tool call as JSON, typically on stdin, including which file was touched.
Because the hook is deterministic, it is the right place for anything that should happen every single time, with zero exceptions and zero judgment calls.
Recipe
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "npx prettier --write \"$CLAUDE_FILE_PATH\""
}
]
}
]
}
}When to reach for this:
- Auto-formatting a file the instant it is edited, so formatting never has to be asked for.
- Running a linter and surfacing warnings right after a change, rather than waiting for a separate CI step.
- Logging every file touched during a session for an audit trail.
- Kicking off a fast, narrow test suite for the file that just changed.
- Regenerating a derived artifact (like a compiled config or a type index) after its source changes.
Working Example
#!/usr/bin/env bash
# .claude/hooks/format-on-edit.sh
# Reads the PostToolUse event payload from stdin and formats the touched file.
set -euo pipefail
payload="$(cat)"
file_path="$(echo "$payload" | jq -r '.tool_input.file_path // empty')"
if [ -z "$file_path" ]; then
exit 0
fi
case "$file_path" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md)
npx prettier --write "$file_path"
echo "Formatted: $file_path"
;;
*)
exit 0
;;
esac{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/format-on-edit.sh"
}
]
}
]
}
}What this demonstrates:
- The
matcherfield limits the hook toEditandWritetool calls, so it never fires on, say, aBashcommand. - The script reads the event payload from stdin and pulls the changed file's path out with
jq, rather than guessing at an environment variable. - A
casestatement narrows formatting to file types Prettier actually understands, so the hook silently no-ops on files it shouldn't touch. - The script is a separate, version-controlled file, not an inline one-liner, which makes it testable and readable on its own.
set -euo pipefailmakes the script fail loudly on unexpected errors instead of silently doing nothing.
Deep Dive
How It Works
- Claude Code emits a
PostToolUseevent immediately after a tool call completes successfully. - The harness checks every registered
PostToolUsehook'smatcheragainst the tool that just ran; only matching hooks execute. - Each matching hook's
commandruns as a plain shell command, with the event payload (tool name, tool input, and result) available to it, typically via stdin as JSON. - The hook's exit code and output are reported back to Claude Code; a non-zero exit is typically surfaced as a failure, useful for hooks that also validate.
- Hooks run synchronously with respect to the tool call finishing, but they do not block or alter the tool call itself, since the tool has already completed by the time PostToolUse fires. That blocking behavior belongs to PreToolUse instead.
Matcher Patterns
| Matcher | Matches |
|---|---|
Edit | Only the Edit tool. |
Write | Only the Write tool. |
Edit|Write | Either Edit or Write (regex alternation). |
* or omitted | Every tool call, regardless of which tool ran. |
Shell Script Notes
# Prefer reading structured input over relying on environment variables
# that may or may not be set consistently across hook implementations.
payload="$(cat)"
file_path="$(echo "$payload" | jq -r '.tool_input.file_path // empty')"
# Guard against an empty path before doing any work.
[ -z "$file_path" ] && exit 0- Always guard against a missing or empty file path; not every tool call payload will have the shape a formatting hook expects.
- Keep the hook fast. It runs on every matching tool call, so a slow hook (a full test suite, a large build) will noticeably slow down every edit.
- Prefer a dedicated script file over a long inline command in
settings.json, since it is easier to test, diff, and reason about in isolation.
Gotchas
- Assuming the hook can block or undo the edit - PostToolUse fires after the tool call already completed, so the hook cannot prevent the edit from happening. Fix: use a PreToolUse hook instead if the goal is to reject or block an action before it happens.
- A slow hook makes every edit feel slow - a hook that runs a full test suite or a slow build on every single file edit adds that latency to every edit, not just meaningful ones. Fix: scope the hook to fast operations, or narrow the matcher and file-type check so it only runs where it matters.
- Hardcoding a file path instead of reading it from the event payload - a hook written for one specific file breaks the moment it needs to handle a second file. Fix: always read the actual file path out of the tool call payload, never assume a fixed path.
- Forgetting the matcher and firing on every tool call - an unscoped hook (no matcher, or
*) fires even on unrelated tools likeBashorRead, wasting cycles and cluttering output. Fix: set a matcher that names exactly the tools the hook cares about, such asEdit|Write. - A hook that throws on files it doesn't understand - a formatter invoked against a binary file or an unsupported extension can error out and pollute the session with noise. Fix: add an explicit file-extension guard (like the
casestatement above) so the hook silently exits on files it isn't meant to touch. - Treating hook output as invisible - a hook's stdout and exit code are surfaced back into the session, so a chatty or failing hook adds noise (or apparent errors) to every edit. Fix: keep hook output minimal and only non-zero exit on genuine failures the user should see.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Asking Claude to format the file in the prompt | A one-off request, not something that should happen on every edit going forward. | The behavior should be unconditional and automatic for the whole team, not dependent on remembering to ask. |
| A PreToolUse hook | The goal is to validate or block the edit before it happens, not react after it completes. | The action is a side effect that should run after a successful edit, like formatting or logging. |
| A CI pipeline step | The check is expensive (full test suite, full build) and does not need to run on every single local edit. | Fast feedback on every edit matters more than centralizing the check in CI. |
FAQs
Can a PostToolUse hook stop the edit it is reacting to?
No. PostToolUse fires after the tool call has already completed, so the edit has already happened by the time the hook runs. Blocking belongs to a PreToolUse hook instead.
How does a PostToolUse hook know which file was just edited?
The event payload includes the tool call's input, typically delivered as JSON on stdin, which contains the file path for Edit and Write tool calls.
What does the matcher field actually match against?
- It matches against the name of the tool that just ran, such as
EditorWrite. - Regex-style alternation (
Edit|Write) lets one hook entry cover multiple tools. - Omitting the matcher, or using
*, makes the hook fire on every tool call.
Can multiple PostToolUse hooks run for the same event?
Yes. Multiple hook entries can be registered under PostToolUse, and every entry whose matcher matches the completed tool call runs.
What happens if the hook script exits with a non-zero status?
The non-zero exit is typically surfaced back into the session as a failure signal, which is useful for hooks doing lightweight validation in addition to a side effect like formatting.
Should a formatting hook run against every file type?
No. It should check the file's extension (or path) and only run the formatter against types it actually understands, silently exiting on anything else.
Is a PostToolUse hook the right place for slow operations like a full test suite?
Generally no, since the hook runs on every matching edit and a slow hook adds that latency to every single edit. Slow checks are usually better left to CI or a manually triggered command.
Where do PostToolUse hooks get configured?
In settings.json, under a hooks.PostToolUse array, where each entry has a matcher and one or more hooks commands to run.
Does the hook run inside the same process as Claude Code?
No. The hook's command runs as an independent shell command, separate from the model's reasoning process, and only its output and exit code are reported back.
Can a PostToolUse hook see the result of the tool call, not just its input?
Yes. The event payload generally includes both the tool's input and its result, which is useful for hooks that want to react differently depending on what the edit actually produced.
Is it safe to commit a PostToolUse hook script to a shared repo?
Yes, and it is recommended, since a project-scoped hook script in version control means every teammate gets the same automatic formatting or logging behavior without individual setup.
What is the difference between a PostToolUse hook and a Stop hook?
A PostToolUse hook fires after each individual matched tool call completes, while a Stop hook fires once, when the session or turn ends, making it better suited for end-of-turn summaries or cleanup rather than per-edit reactions.
Related
- Custom Commands, Hooks, and Subagents: What Each One Is For - how PostToolUse fits among the three extension points.
- Blocking Risky File Edits with a PreToolUse Hook - the hook that runs before the edit instead of after.
- Common Hook Events and When to Use Each One - the full catalog of lifecycle events hooks can attach to.
- Custom Commands, Hooks & Subagents Best Practices - conventions for scoping hooks safely.
Stack versions: Written against the Claude model lineup current as of ~June 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (the default), and Claude Haiku 4.5. Model names, pricing, and product features move quickly - verify current specifics at platform.claude.com/docs before relying on them.