PreToolUse and PostToolUse Hooks

Intercepting and reacting to tool calls. Common patterns: linting after edits, logging Bash commands, blocking dangerous operations.

Feature Intermediate
10 min read
hooks automation safety linting

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.

  • PreToolUse fires 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.
  • PostToolUse fires 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, or aws 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-run flag before any terraform 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 prettier after 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.
PreToolUse. Fires before the tool runs. Can block (non-zero exit) or modify inputs (rewrite stdout JSON). The last line of defence — the model literally cannot execute past a PreToolUse veto.
PostToolUse. Fires after the tool runs. Cannot un-run the action. Best for guaranteed-fires side effects: linting, logging, formatting, notifying. Can transform the result the model sees.

How it works#

Lifecycle of a tool call#

A single tool call goes through five phases when hooks are configured:

  1. Decide. The model emits a tool_use request.
  2. 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.
  3. Execute. The tool runs against the (possibly modified) inputs.
  4. 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.
  5. 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:

~/.claude/hooks/block-force-push.sh
#!/usr/bin/env bash
set -euo pipefail
payload="$(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 1
fi
exit 0

A PostToolUse hook on Edit/Write that lints:

~/.claude/hooks/lint-after-edit.sh
#!/usr/bin/env bash
set -euo pipefail
if command -v pnpm >/dev/null && [ -f "pnpm-lock.yaml" ]; then
pnpm lint:fix >/dev/null 2>&1 || true
fi
exit 0

The 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 bash
payload="$(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 0
fi
echo "$payload"
exit 0

Note 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 bash
payload="$(cat)"
path="$(echo "$payload" | jq -r '.input.file_path // empty')"
case "$path" in
*.json|*.jsonc)
npx prettier --write "$path" >/dev/null 2>&1 || true
;;
esac
exit 0

Bash audit log#

A PostToolUse hook that appends every Bash command and its exit status to a log:

#!/usr/bin/env bash
mkdir -p ~/.claude/audit
payload="$(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.log
exit 0

A 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 bash
payload="$(cat)"
cmd="$(echo "$payload" | jq -r '.input.command // empty')"
# Patterns to refuse outright
deny=(
'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
fi
done
exit 0

Soft-gate on production commands#

A PreToolUse hook that doesn’t outright refuse but forces an intent check:

#!/usr/bin/env bash
payload="$(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
fi
fi
exit 0

The 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.
  • jq is a runtime dependency. Most hook examples assume jq is 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 -e and 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/null for 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.
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.

Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.