why hooks exist

You can ask Claude, in a CLAUDE.md, to always run the linter before finishing a task — and it usually will. A hook is the difference between "usually" and "always": a shell command you configure to run automatically at a specific point in Claude Code's lifecycle, enforced by the harness rather than requested of the model. It can even block an action outright by exiting non-zero, the same way a git pre-commit hook can reject a commit.

events worth actually using

There are dozens of hook events covering the full session lifecycle, but a handful cover almost every real use case:
EventFiresTypical use
PreToolUsebefore a tool call runsblock a dangerous command outright, or require confirmation for a pattern
PostToolUseafter a tool call succeedsauto-format a file the moment it's edited, log every command run
UserPromptSubmitbefore Claude processes what you typedinject extra context, or reject a prompt outright
SessionStartwhen a session begins or resumesprint a status banner, load environment-specific context
Stopwhen Claude finishes respondingrun the full test suite before letting the turn actually end
SubagentStopwhen a subagent finishesvalidate a subagent's output before its summary reaches the main session

configuration shape

Hooks live in the same settings.json/settings.local.json files covered in Claude Code CLI Basics — project-level hooks are meant to be committed, exactly like a repo's git hooks. A matcher scopes a hook to specific tools (e.g. only Bash) using a tool name, several names separated by |, or a regex.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh" }
        ]
      }
    ]
  }
}
            

how a hook actually blocks something

A hook is a script that reads event data as JSON on stdin (which tool, what arguments, which session) and communicates back through its exit code: 0 means allow, 2 means block and treat stderr as the rejection reason shown to Claude, any other code is a non-blocking error. A PreToolUse hook can also return structured JSON with an explicit permissionDecision of deny, allow, or ask instead of relying on the exit code alone.

#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
  echo "Blocked: rm -rf is not allowed by this project's hooks." >&2
  exit 2
fi
            
Not every event can block — PostToolUse fires after the tool already ran, so it can flag or fix, but not prevent. PreToolUse, UserPromptSubmit, and Stop are the ones where blocking actually makes sense.

the tradeoff

A hook that's too aggressive (blocking anything that merely resembles a risky pattern) trains you to work around it rather than trust it — the same failure mode as an overly strict git hook people learn to --no-verify past. Start narrow: enforce the one or two things that have actually caused a real problem before, not every theoretical one.

related topics

Git Hooks & Automation — the same enforced-not-requested idea, one layer down in the stack.
Claude Skills — automation you invoke on demand, instead of automation that runs on every matching event.
Claude Code CLI Basics — where hook configuration actually lives.

reference

code.claude.com — hooks