Skip to main content

Hooks: Deterministic Automation

Advanced

Hooks are shell commands Claude Code runs automatically at defined points in its lifecycle. Where permissions decide whether an action is allowed, hooks let you run deterministic logic around it — formatting, validation, logging, gates. They're how you make behaviour guaranteed instead of "please remember to."

What you'll learn
  • When to reach for a hook instead of an instruction or a permission
  • How a hook is wired up: event, matcher, and the JSON payload on stdin
  • The two ways a hook blocks an action — exit code 2 vs JSON on stdout
  • The good practices and common mistakes that separate fast, safe hooks from sluggish, silent ones

When to reach for a hook

Reach for a hook when you want a behaviour to be guaranteed, not merely requested. Each common job maps to a lifecycle event:

  • Auto-format / lint after every file edit (PostToolUse).
  • Block an action that violates a rule before it runs (PreToolUse).
  • Notify or log when a session ends or a task finishes (Stop).
  • Inject context at session start.
Hook events at a glance
Press Enter or Space to flip the card. Use the left and right arrow keys to move between cards.Term shown.
1 / 4

How they work

You register hooks in settings.json, matching an event (and often a tool matcher). When the event fires, Claude runs your command, passing a JSON payload on stdin (the tool name, its inputs, the session). Your command's exit code and output decide what happens next.

Guided walkthrough1 of 4
  1. Register the hook in settings.json under the lifecycle event you care about — for example PostToolUse.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}

The hook above reads the edited file's path out of the stdin JSON (.tool_input.file_path) and formats it. Don't assume an env var holds the path — read it from stdin. Useful path placeholders like ${CLAUDE_PROJECT_DIR} are available for locating scripts.

How a hook blocks

Two ways, depending on the event:

  • Exit code 2 — the hook fails the action and whatever it wrote to stderr becomes the message Claude sees. Simple and works for command hooks.
  • JSON on stdout (exit 0) — return a structured decision. For PreToolUse, that's a permissionDecision of deny; for PostToolUse/Stop/etc. it's {"decision": "block", "reason": "…"}.

The script below is a PreToolUse hook on the Bash tool. Read it top to bottom: it pulls the command out of stdin, and if it looks destructive, writes a reason to stderr and exits 2 to block.

#!/usr/bin/env bash
# PreToolUse hook on the Bash tool: refuse to delete things.
command=$(jq -r '.tool_input.command' < /dev/stdin)
if [[ "$command" == rm\ * || "$command" == *"rm -rf"* ]]; then
echo "Blocked: destructive 'rm' is not allowed by policy." >&2
exit 2
fi
exit 0

The mental model

A PreToolUse hook runs before the action and can block it; a PostToolUse hook runs after it succeeds and reacts to the result.

Good practices

  • Keep hooks fast and idempotent — they run a lot.
  • Fail loud on real problems, but don't block on cosmetic issues.
  • Treat hook output as feedback to Claude — a clear message helps it self-correct.
  • Hooks run with your shell's privileges — review any hook you didn't write (Reviewing Third-Party Code).

Common mistakes

  • Reading the file path from an env var. The path lives in the stdin JSON (.tool_input.file_path), not in $CLAUDE_FILE_PATH. Pipe stdin through jq.
  • Silent blocks. If a PreToolUse hook exits 2 with nothing on stderr, Claude is blocked but doesn't know why and can't adapt. Always write a clear reason.
  • Slow hooks. A PostToolUse hook runs after every matching edit. A 3-second linter makes the whole session feel sluggish — keep hooks fast and, ideally, only act on what changed.
  • Over-broad matchers. matcher: ".*" fires on every tool. Narrow with an exact name, an Edit|Write list, or the per-handler if field (e.g. "if": "Bash(git push *)").
  • Trusting hooks you didn't write. A hook runs arbitrary shell with your privileges. Review any hook from a plugin or template first — see Reviewing Third-Party Code.
Watch out
  • A hook runs arbitrary shell with your privileges — never wire up a hook from a plugin or template without reading it first.

Copy-paste starters are in Hooks & settings.json Recipes.

Auto-format edited files (PostToolUse on Edit|Write)

{
"hooks": {
  "PostToolUse": [
    {
      "matcher": "Edit|Write",
      "hooks": [
        { "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
      ]
    }
  ]
}
}

Check yourself

0/3
  1. Where does a hook find the path of the file that was just edited?
  2. A PreToolUse hook exits with code 2. What happens?
  3. Why is matcher ".*" considered a common mistake?
Key takeaways
  • Hooks make behaviour guaranteed, not requested — they run deterministic logic around actions that permissions only allow or deny.
  • Register a hook in settings.json against an event plus a matcher; Claude pipes a JSON payload on stdin and reads your exit code and output.
  • Read the file path from stdin (.tool_input.file_path) — not from an env var.
  • Block with exit code 2 (stderr becomes the message) or with structured JSON on stdout (exit 0); always include a clear reason.
  • Keep hooks fast, idempotent, and narrowly matched — and review any hook you didn't write, since it runs with your shell's privileges.

Next