PreToolUse and PostToolUse Hooks
Intercepting and reacting to tool calls. Common patterns: linting after edits, logging Bash commands, blocking dangerous operations.
What it is#
PreToolUse and PostToolUse are the two hook event types that fire around each tool call Claude Code makes. They’re the workhorse pair of the hook system — most of the value people extract from hooks comes from these two events.
PreToolUsefires after the model has decided to call a tool but before the tool actually runs. The hook sees the proposed action and can block it (non-zero exit), modify its inputs (rewrite the JSON on stdout), or just observe.PostToolUsefires after the tool finishes — whether success or error — and before the result is shown back to the model. The hook sees the result and can transform it, append to it, or run side effects like a linter or a log writer.
Together they form a pre-flight / post-flight pair: PreToolUse is your last chance to stop something; PostToolUse is your guaranteed-fires-after callback for “do this every time a thing happens.”
Both are subprocess shell commands invoked with structured JSON on stdin. They can be written in any language. They can be matched by tool name, by tool kind, or (for tools with structured inputs) by input shape.
When to use it#
Reach for PreToolUse when:
- You need to block. A curated denylist of
rm -rf /,git push --force main, oraws s3 rm s3://prod/*belongs here. The model never gets to run them. - You need to gate. “Confirm before any Bash command matches
prod.*deploy” — exit non-zero with a message; the model has to either rephrase or escalate. - You need to mutate inputs. Inject a
--dry-runflag before anyterraform apply. Rewrite a misspelled file path. Normalise an argument. - You need to audit attempted actions. Logging the attempt (whether allowed or not) is sometimes more interesting than logging only what ran.
Reach for PostToolUse when:
- You need a guaranteed side effect. Run the linter after every Edit/Write. Run
prettierafter every JSON file write. The model can’t forget — the hook runs every time. - You need to audit results. Append every Bash command + its result + a timestamp to a log. Cheap, durable, useful for incident review.
- You need to transform results. Strip ANSI escape codes from command output before the model sees it. Redact secrets from log dumps.
- You need to notify. Buzz a desktop notification when a long test run finishes. Post to a teammate’s Slack when CI passes.
Don’t use these hooks when:
- The behaviour is sometimes-yes-sometimes-no. Hooks fire every time. Use a slash command or a sub-agent if intent matters.
- The hook would be slow. Every match adds latency. A 2-second hook on every Edit destroys the conversational feel.
- The work belongs in CI. If it must run before merge, run it in CI; don’t tax every in-session edit with it.
How it works#
Lifecycle of a tool call#
A single tool call goes through five phases when hooks are configured:
- Decide. The model emits a
tool_userequest. - PreToolUse. Claude Code spawns any matching PreToolUse hooks with the proposed tool name + inputs as JSON on stdin. If any hook exits non-zero, the tool is blocked and the hook’s stderr is surfaced to both the user and the model. If a hook rewrites the JSON on stdout, the rewritten inputs are used.
- Execute. The tool runs against the (possibly modified) inputs.
- PostToolUse. Claude Code spawns any matching PostToolUse hooks with the tool name + inputs + result as JSON on stdin. If a hook rewrites the JSON on stdout, the rewritten result is used. Non-zero exit signals an issue but does not “un-run” the tool.
- Surface. The (possibly modified) result is shown to the model and continues the conversation.
The order is fixed and synchronous: each phase blocks the next. A 500ms hook adds 500ms to that step.
The stdin payload#
PreToolUse stdin (illustrative):
{ "event": "PreToolUse", "tool": "Bash", "input": { "command": "git push --force origin main", "description": "force-push main" }}PostToolUse stdin adds an output field:
{ "event": "PostToolUse", "tool": "Edit", "input": { "file_path": "/repo/src/foo.ts" }, "output": { "ok": true, "diff": "..." }}The exact schema is versioned in the docs; hooks should defensively use jq defaults (// empty) rather than assume fields exist.
Matching#
Hook config supports filtering by event and by tool. A typical project hooks block:
{ "hooks": [ { "event": "PreToolUse", "matchers": [{ "tool": "Bash" }], "command": "~/.claude/hooks/audit-bash.sh" }, { "event": "PostToolUse", "matchers": [{ "tool": "Edit" }, { "tool": "Write" }], "command": "pnpm lint:fix" } ]}Prefer specific matchers — a PostToolUse hook with no matcher runs on every tool call, which destroys session latency. If you only care about Bash, match on Bash.
Configuration#
Where they live#
Both hooks live in the same hooks array in your settings JSON:
- User-global:
~/.claude/settings.json. Applies to every project. - Project-shared:
<repo>/.claude/settings.json. Committed alongside the code; applies only when working in that repo. - Local override:
<repo>/.claude/settings.local.json. Gitignored; for individual overrides like “skip the slow Pre-hook on my machine”.
The merge order (project then local, with user-global as fallback) is documented in the settings file. Hooks from each layer accumulate; they don’t override one another.
Minimal authoring patterns#
A blocking PreToolUse hook on Bash:
#!/usr/bin/env bashset -euo pipefailpayload="$(cat)"cmd="$(echo "$payload" | jq -r '.input.command // empty')"if echo "$cmd" | grep -qE 'git push.*--force.*\b(main|master)\b'; then echo "Blocked: force-push to main is not permitted. Use a regular push or a feature branch." >&2 exit 1fiexit 0A PostToolUse hook on Edit/Write that lints:
#!/usr/bin/env bashset -euo pipefailif command -v pnpm >/dev/null && [ -f "pnpm-lock.yaml" ]; then pnpm lint:fix >/dev/null 2>&1 || truefiexit 0The hook silences stdout/stderr because the lint output isn’t useful for the model — only the file change matters. If the lint fails, the file is still in a workable state for the next conversational step.
Hooks that modify the payload#
A PreToolUse hook can rewrite tool inputs by emitting modified JSON on stdout. A hook that always adds --confirm to terraform apply:
#!/usr/bin/env bashpayload="$(cat)"cmd="$(echo "$payload" | jq -r '.input.command // empty')"if echo "$cmd" | grep -qE '^terraform apply\b' && ! echo "$cmd" | grep -q '\-\-auto-approve'; then new_cmd="${cmd/terraform apply/terraform apply -auto-approve=false}" echo "$payload" | jq --arg new "$new_cmd" '.input.command = $new' exit 0fiecho "$payload"exit 0Note the pattern: emit the (possibly modified) JSON on stdout, exit 0. Stderr is for human messages; stdout is the payload substitute.
Examples#
Lint-after-edit#
The single highest-leverage hook to start with:
{ "event": "PostToolUse", "matchers": [{ "tool": "Edit" }, { "tool": "Write" }], "command": "pnpm lint:fix"}The model now cannot leave the working tree in a lint-dirty state. No more “remember to run lint” reminders in CLAUDE.md.
Auto-format on JSON writes#
A PostToolUse hook that runs prettier only on .json/.jsonc writes:
#!/usr/bin/env bashpayload="$(cat)"path="$(echo "$payload" | jq -r '.input.file_path // empty')"case "$path" in *.json|*.jsonc) npx prettier --write "$path" >/dev/null 2>&1 || true ;;esacexit 0Bash audit log#
A PostToolUse hook that appends every Bash command and its exit status to a log:
#!/usr/bin/env bashmkdir -p ~/.claude/auditpayload="$(cat)"cmd="$(echo "$payload" | jq -r '.input.command // empty')"ok="$(echo "$payload" | jq -r '.output.ok // empty')"printf '%s\t%s\t%s\n' "$(date -Iseconds)" "$ok" "$cmd" >> ~/.claude/audit/bash.logexit 0A week later, you can grep the log for “every time the model touched docker” or “every command around 14:32 when the staging deploy broke”.
Blocking a curated denylist#
A PreToolUse hook on Bash that refuses a list of patterns:
#!/usr/bin/env bashpayload="$(cat)"cmd="$(echo "$payload" | jq -r '.input.command // empty')"
# Patterns to refuse outrightdeny=( 'rm[[:space:]]+-rf[[:space:]]+/' 'git[[:space:]]+push.*--force.*\b(main|master)\b' 'kubectl[[:space:]]+delete[[:space:]]+ns' ':\(\)\{[[:space:]]*:\|:\&[[:space:]]*\}')
for pattern in "${deny[@]}"; do if echo "$cmd" | grep -qE "$pattern"; then echo "Blocked: command matches denylist pattern: $pattern" >&2 exit 1 fidoneexit 0Soft-gate on production commands#
A PreToolUse hook that doesn’t outright refuse but forces an intent check:
#!/usr/bin/env bashpayload="$(cat)"cmd="$(echo "$payload" | jq -r '.input.command // empty')"if echo "$cmd" | grep -qE '\b(prod|production)\b'; then echo "This command references production. Confirm intent and re-run with PROD_CONFIRMED=1 prefix." >&2 if ! echo "$cmd" | grep -q 'PROD_CONFIRMED=1'; then exit 1 fifiexit 0The first time this fires, the model sees the stderr message in its tool result and typically either rephrases (often picking a safer alternative) or surfaces the intent to you for confirmation.
Composable: audit then lint#
PostToolUse hooks compose because each one is just another row in the array:
{ "hooks": [ { "event": "PostToolUse", "matchers": [{ "tool": "Bash" }], "command": "~/.claude/hooks/audit-bash.sh" }, { "event": "PostToolUse", "matchers": [{ "tool": "Edit" }, { "tool": "Write" }], "command": "~/.claude/hooks/lint-after-edit.sh" }, { "event": "PostToolUse", "matchers": [{ "tool": "Edit" }, { "tool": "Write" }], "command": "~/.claude/hooks/audit-edit.sh" } ]}Three small hooks, each doing one thing, beat one mega-hook that branches internally.
Gotchas#
- Pre and Post are not symmetric. PreToolUse can block; PostToolUse cannot un-run. If you want to prevent an action, it must be a Pre.
- Slow hooks visibly slow the session. A 1-second PostToolUse on every Bash call adds a second to every command. Profile your hooks.
jqis a runtime dependency. Most hook examples assumejqis on PATH. If teammates run on machines without it, hooks fail mid-session in confusing ways. Either document the dep or use a more portable parsing approach.- Non-zero exit means block — even by accident. A bash script with
set -eand an unrelated failure will exit non-zero, blocking the action. Test hooks under failure conditions before deploying. - stdout has two meanings. In a hook that doesn’t intend to mutate the payload, anything on stdout still substitutes the payload. Use
>/dev/nullfor non-mutating hooks, or be explicit about echoing the original payload. - Matchers are AND, hook rows are OR. Inside one hook config, all matchers must match. Across hook rows, any matching row fires. Easy to get backwards if you’re thinking in firewall rules.
- PostToolUse fires on errors too. A failed Bash command still triggers PostToolUse. Your audit log will see the failure; your linter may try to format a half-written file. Defensive hooks handle the error case.
- Hooks are not transactional with the action. A PostToolUse hook crashing after a successful Edit does not undo the Edit. If you need transactional behaviour, you need a higher-level mechanism — hooks aren’t it.
Related features#
- Hooks Overview
- UserPromptSubmit Hooks
- Building a Security Hook
- Permission Rules and Allowlists
- Settings and Configuration
When PreToolUse versus a permission rule
Permission rules (permissions.allow / permissions.deny in settings) are the static, declarative version of PreToolUse hooks. If your rule is “allow git status, deny git push --force”, that belongs in the permissions block — simpler, declarative, no subprocess overhead. If your rule needs to look at command context, do regex matching on inputs, consult an external service, or rewrite arguments, that’s a PreToolUse hook. Rule of thumb: try the permission rule first; reach for the hook when the rule’s vocabulary isn’t expressive enough.