why hooks exist

Forget JSON and exit codes for a second. A hook is just: "every time X happens, run this command." X might be "before Claude runs a shell command," or "right after Claude edits a file," or "when Claude finishes talking." You write an ordinary shell command or script, tell Claude Code which event should trigger it, and from then on it fires by itself — you never have to ask for it, and Claude never has to remember to do it.
You could just ask, instead — put "always run the linter before finishing" in a CLAUDE.md, and Claude will run it almost every time. But "almost every time" is a probability, not a promise: Claude is a language model, not a deterministic program, and it can skip a step, misread which file you meant, or get distracted by something else in a long task. None of that is a bug exactly, it's just what asking a model to do something is. A hook sidesteps the question of whether Claude remembers or agrees — the command runs because the event happened, full stop, the same way a smoke detector doesn't care whether you remembered to check for smoke, or a git pre-commit hook doesn't care whether you meant to leave that debug statement in.
That's the whole idea. Everything else on this page is detail on top of it: which events are available, what a hook is allowed to do (just watch? actually block something?), and where the configuration for all this lives.

your first hook: a desktop notification

The gentlest possible hook doesn't format anything or block anything — it just tells you when Claude is done, so you can stop watching the terminal. Hook configuration lives in the same settings.json files as everything else in Claude Code CLI Basics: put this in .claude/settings.json for the whole project, or ~/.claude/settings.json if you just want it for yourself, everywhere.

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "notify-send 'Claude is done' 'Check the terminal.'" }
        ]
      }
    ]
  }
}
            
notify-send is the Linux desktop-notification command (see Ubuntu terminal commands); swap in afplay /System/Library/Sounds/Glass.aiff on macOS if you'd rather hear a sound. Notice there's no matcher here — Stop fires once when Claude finishes responding, it isn't scoped to a particular tool, so there's nothing to match against.

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" }
        ]
      }
    ]
  }
}
            

checking what's configured: /hooks

Once you've got a few hooks scattered across project, personal, and local settings files, hunting through JSON to remember what's actually active gets old fast. Run /hooks in a session to open a browser of every hook event, with a count next to any event that has something configured; pick an event and it shows each hook's matcher, command, and which settings file it came from — project, user, local, a managed policy, even one bundled with a plugin or skill.
It's read-only — a place to look, not to edit. /hooks won't add, change, or remove anything; for that you're back to editing the settings file yourself, or asking Claude to do it. Edits to a settings file are picked up automatically within a few seconds while Claude Code keeps running, so there's no restart-and-recheck loop either way.

more worked examples

Same shape every time — an event, an optional matcher, a command. Only the command changes.
Auto-format a file the moment it's edited (PostToolUse, matched to the two tools that touch files):

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh" }
        ]
      }
    ]
  }
}
            

#!/bin/bash
FILE=$(jq -r '.tool_input.file_path')
case "$FILE" in
  *.py) black "$FILE" ;;
  *.js|*.ts) npx prettier --write "$FILE" ;;
esac
            
Load fresh context every time a session starts (SessionStart) — whatever the command prints to stdout gets added to Claude's context, so this is a cheap way to save it from having to run git status itself on turn one:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          { "type": "command", "command": "git branch --show-current && git status --short" }
        ]
      }
    ]
  }
}
            
Block a dangerous command outright (PreToolUse, scoped to Bash only) — this is the one worth reading closely, since it's the first example on this page that actually stops something rather than reacting to it:

#!/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
            

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. That's what the block-rm.sh script above is doing. 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.
Not every event can block — PostToolUse fires after the tool already ran, so the format-on-save example above can fix a file but never prevent the edit. 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