Back to Knowledge
New

Hooks & Automation

You can write "always run Prettier" in CLAUDE.md and Claude will usually do it. Hooks

Hooks (Claude Code)

Handlers that run automatically at specific moments in a Claude Code session, such as before a tool runs (PreToolUse), after it finishes (PostToolUse), when you submit a prompt, or when Claude stops. A hook can be a shell command, an HTTP call, an MCP tool, or a prompt, and can block risky actions. Configure them under the `hooks` key in settings.json or ship them in a plugin.

"Like motion-sensor lights. When something happens (motion), an action triggers automatically (lights on)."

make it happen every time. They're shell commands Claude Code runs at fixed moments: before a tool call, after an edit, when Claude tries to stop. The model doesn't get a vote. That makes hooks the place for rules that must never be skipped.

1Instructions vs. guarantees

CLAUDE.md

CLAUDE.md

A Markdown file Claude Code reads at the start of every session: project context, commands, conventions, and rules. Put it at `./CLAUDE.md` (shared with the team), `~/.claude/CLAUDE.md` (personal, all projects), or `CLAUDE.local.md` (personal, gitignored). Run `/init` to generate a starter; AGENTS.md is read too.

"Like a welcome packet for a new team member. It tells Claude everything it needs to know about your project."

is context. Claude reads it, weighs it, and follows it most of the time. The official docs say it plainly: to block an action regardless of what Claude decides, use a PreToolUse hook. Think of CLAUDE.md as the employee handbook and hooks as the badge reader on the server room door.

CLAUDE.md says

"Never edit .env files."

Followed... until a long session buries it.

A hook enforces

PreToolUse on Edit|Write → path matches .env → exit 2

Blocked every time, and Claude is told why.

2The events you'll actually use

Claude Code exposes 30-plus hook events (including ones for subagents, tasks, worktrees, compaction, config changes, and model switches). These ten cover almost every real use:

EventFires whenGood for
SessionStartA session starts or resumesPrint branch status or open issues so Claude starts with fresh facts
UserPromptSubmitYou hit Enter, before Claude sees the promptAdd context, or block prompts that contain secrets
PreToolUseBefore any tool call runsBlock dangerous commands and protected files
PermissionRequestA permission dialog is about to appearAuto-decide routine approvals
PostToolUseAfter a tool call succeedsFormat, lint, or type-check the file that was just edited
NotificationClaude Code sends a notification (e.g. needs permission, idle)Desktop or phone alerts
SubagentStopA subagent finishesCheck a subagent's work before it reports back
StopClaude finishes respondingRun tests, refuse to stop if they fail; ping you
PreCompactBefore the conversation is compactedSave a transcript or handoff notes
SessionEndThe session endsQuick cleanup or logging (tiny time budget)

Full list and input schemas: Hooks reference

3How a hook is wired up

Hooks live under a hooks key in a settings file: ~/.claude/settings.json (you, everywhere), .claude/settings.json (the project, committed), or .claude/settings.local.json (you, this project). Plugins ship them in hooks/hooks.json. The shape is: event → list of matchers → list of handlers.

.claude/settings.json

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

matcher

For tool events it filters the tool name. "Bash", "Edit|Write", or a regex like "mcp__github__.*". Events like Stop don't need one.

type

command (a shell command, the common case), plus http, mcp_tool, prompt, and an experimental agent type.

Input: JSON on stdin

Your script receives the event as JSON: tool_name, tool_input (e.g. tool_input.command for Bash, tool_input.file_path for Edit/Write), cwd, session_id, and more. jq is your friend.

${CLAUDE_PROJECT_DIR}

Points at the project root where the session started, so your script paths work no matter which folder Claude has cd'd into.

Run /hooks in a session to browse every configured hook, its matcher, and which settings file it came from. Or skip hand-editing: ask Claude "add a hook that..." and review the diff.

4Exit codes: how hooks say no

Exit 0: success, carry on. On most events stdout only goes to the debug log. (On SessionStart and UserPromptSubmit, plain stdout is added as context Claude can see.)

Exit 2: blocking error. On events that can block, the action is stopped and your stderr text is shown to Claude as the reason. Even a JSON "allow" can't override it.

Any other code: a non-blocking error. The action proceeds. This is the classic bug: you wrote exit 1 thinking it would block. It won't.

EventWhat exit 2 does
PreToolUseBlocks the tool call
UserPromptSubmitBlocks the prompt
StopPrevents Claude from stopping, so it keeps working
PostToolUseCan't block (the tool already ran), but stderr still reaches Claude
PermissionRequestDoesn't block; use the JSON decision object instead

For finer control, hooks can print JSON to stdout instead (for example a PreToolUse permissionDecision of "deny"). Exit codes plus stderr are enough for everything on this page.

5Four recipes worth stealing

Auto-format after every edit

Pulls the edited file path out of the JSON and runs Prettier on it. Claude never ships unformatted code again.

.claude/settings.json

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

Block rm -rf and writes to secrets

Two small scripts. Remember to chmod +x them.

.claude/hooks/block-dangerous.sh

#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command // ""')

if echo "$cmd" | grep -Eq 'rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f|rm[[:space:]]+-[a-zA-Z]*f[a-zA-Z]*r'; then
  echo "Blocked: recursive force delete. Delete specific files instead, or ask the user to run it." >&2
  exit 2
fi

if echo "$cmd" | grep -Eq 'git[[:space:]]+push.*(--force|-f)'; then
  echo "Blocked: force push. Ask the user." >&2
  exit 2
fi

exit 0

.claude/hooks/protect-secrets.sh

#!/usr/bin/env bash
path=$(jq -r '.tool_input.file_path // ""')

case "$path" in
  *.env|*.env.*|*/secrets/*|*.pem|*.key)
    echo "Blocked: $path holds secrets. Tell the user what to change instead." >&2
    exit 2 ;;
esac

exit 0

.claude/settings.json

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-dangerous.sh" }]
      },
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-secrets.sh" }]
      }
    ]
  }
}

Pattern-matching shell commands is a seatbelt, not a vault. A determined (or confused) agent can phrase things differently. Pair it with permission deny rules and don't run agents with credentials they don't need. More in Prompt Injection & Security.

Desktop notification when Claude is done

Go make coffee. Your laptop will tell you when it's your turn. Put this in your user settings so it works everywhere.

~/.claude/settings.json (macOS)

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "osascript -e 'display notification \"Claude finished\" with title \"Claude Code\"'" }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [
          { "type": "command", "command": "osascript -e 'display notification \"Claude needs you\" with title \"Claude Code\"'" }
        ]
      }
    ]
  }
}

On Linux, swap the command for notify-send "Claude Code" "Claude finished".

Don't let Claude stop with failing tests

A Stop hook that exits 2 sends Claude back to work with your stderr as instructions. The stop_hook_active check is what keeps this from looping forever.

.claude/hooks/tests-must-pass.sh

#!/usr/bin/env bash
input=$(cat)

# Already continuing because of this hook? Let it stop this time.
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi

if ! output=$(npm test --silent 2>&1); then
  echo "Tests are failing. Fix them before finishing:" >&2
  echo "$output" | tail -n 40 >&2
  exit 2
fi

exit 0

.claude/settings.json

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/tests-must-pass.sh", "timeout": 300 }
        ]
      }
    ]
  }
}

timeout is in seconds. Only send the tail of the output back: 2,000 lines of test logs is a context bomb.

6Common traps

Slow hooks

Matching hooks run on every matching tool call. A 20-second type-check after every single edit makes a session crawl. Scope the matcher tightly, check only the changed file, and save the full test run for Stop. Command hooks default to a 600-second timeout, so a hung script can stall you for ten minutes.

Infinite loops

A Stop hook that always exits 2 means Claude can never stop. Check stop_hook_active. Also watch for PostToolUse hooks that edit files and trigger more edits.

exit 1 instead of exit 2

Only exit 2 blocks. exit 1 logs a non-blocking error and the action goes through.

Silent failures

Missing jq, a script without execute permission, a relative path that breaks after Claude cds. Use ${CLAUDE_PROJECT_DIR} and test hooks by triggering them on purpose.

Copying hooks you didn't read

Hooks run with your full user permissions, outside Claude's sandbox. That includes hooks shipped in plugins. Read them first.

7Hook, instruction, or permission rule?

CLAUDE.md

Judgment calls and style: "prefer Server Components," "keep functions small." Things where context matters.

Permission rules

Simple allow/deny by tool and pattern. See permission modes

Permission Modes

Settings that control how much Claude Code can do without asking: `default` (shown as Manual, asks before edits and commands), `acceptEdits` (auto-approves file edits), `plan` (read-only planning), `auto` (Claude judges what's safe; the starting mode on Pro, Max, and Team plans), `dontAsk`, and `bypassPermissions` (no prompts at all; for sandboxes only). Shift+Tab cycles through the common ones.

"Like parental controls for AI. You choose how much freedom to give based on the task."

. Reach for these first for "never run X."

Hooks

Logic that must run every time: format, verify, notify, block based on file contents or custom conditions.

Next steps

Once your hooks are solid, package them in a plugin so every project gets them.