Guide · Updated

Claude Code hooks: a practical guide with examples you can copy

How Claude Code hooks work, where they go, every hook event, and copyable examples: format on save, block dangerous commands, notify, and run tests on Stop.

Claude Code hooks are commands that run automatically at fixed points in a session: before a tool runs, after Claude edits a file, when Claude needs your input, when it finishes a turn. You add them under hooks in a settings file. Unlike an instruction in CLAUDE.md, which Claude may or may not follow, a hook always runs. This guide covers how hooks work, lists every event, and gives six examples you can paste, as of Claude Code in October 2026.

How a hook is configured

Each event name maps to a list of groups. A group has an optional matcher and the hooks to run:

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

The pieces:

  • matcher filters what the event fires for. On tool events it is the tool name: Bash, Edit|Write, or mcp__github__.* for one MCP server. On Notification it is the notification type, on SessionStart how the session started. Some events, including Stop and UserPromptSubmit, take no matcher and always fire.
  • if narrows a single hook further with permission-rule syntax, such as "if": "Bash(git *)", so the process only starts for matching calls. It works on tool events only, and holds one rule per hook.
  • Input arrives as JSON on stdin: session_id, cwd, hook_event_name, plus event fields like tool_name and tool_input.
  • Exit codes decide the outcome. 0 means no objection. 2 blocks the action on events that can block, and your stderr becomes the reason Claude sees. Any other code is a non-blocking error and the action goes ahead.
  • JSON on stdout with exit 0 gives finer control, such as permissionDecision: "deny" with a reason.

Where hooks live

LocationApplies to
~/.claude/settings.jsonAll your projects, on this machine
.claude/settings.jsonOne project, shared through git
.claude/settings.local.jsonOne project, only you
Managed policy settingsEveryone in the organization
A plugin's hooks/hooks.jsonWhile the plugin is enabled
Skill or subagent frontmatterWhile that skill or subagent is in use

Claude Code picks up edits to these files while it runs. "disableAllHooks": true turns hooks off without deleting them.

Every hook event

There are 33 events. Grouped by what they're for:

AreaEvents
SessionSessionStart, Setup, SessionEnd, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, FileChanged
Prompts and outputUserPromptSubmit, UserPromptExpansion, MessageDisplay
Tools and permissionsPreToolUse, PermissionRequest, PermissionDenied, PostToolUse, PostToolUseFailure, PostToolBatch
End of a turnStop, StopFailure, Notification
Subagents and teamsSubagentStart, SubagentStop, TaskCreated, TaskCompleted, TeammateIdle
Context and modelPreCompact, PostCompact, PreModelSwitch, PostModelSwitch
WorktreesWorktreeCreate, WorktreeRemove
MCP input requestsElicitation, ElicitationResult

Most hooks people write use five of them: PreToolUse to block, PostToolUse to react to edits, Notification and Stop to know when Claude is done or waiting, and SessionStart to add context.

Six hooks worth copying

The examples use jq to read the input. Save scripts in .claude/hooks/ and run chmod +x on each.

1. Format every file Claude edits

.claude/hooks/format.sh picks a formatter by file type:

#!/bin/bash
file=$(jq -r '.tool_input.file_path // empty')
[ -f "$file" ] || exit 0
case "$file" in
  *.js|*.jsx|*.ts|*.tsx|*.css|*.json|*.md) npx prettier --write "$file" ;;
  *.py) ruff format "$file" ;;
  *.go) gofmt -w "$file" ;;
  *.rs) rustfmt "$file" ;;
esac >/dev/null 2>&1
exit 0

Register it in .claude/settings.json. Setting args runs the script directly without a shell, so the path needs no quoting:

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

Claude can also change files from the shell, which this matcher won't see. A FileChanged hook watches files whatever wrote them.

2. Block dangerous shell commands

.claude/hooks/guard-bash.sh denies a few commands and tells Claude why:

#!/bin/bash
cmd=$(jq -r '.tool_input.command // empty')
deny() {
  jq -n --arg r "$1" '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
  exit 0
}
grep -Eq '(^|[;&|( ])rm +-[a-zA-Z]*([rR][a-zA-Z]*f|f[a-zA-Z]*[rR])' <<<"$cmd" && deny "No rm -rf. Remove specific files instead."
grep -Eq 'git push.*(--force|(^| )-f( |$))' <<<"$cmd" && deny "No force pushes."
grep -Eiq 'drop (table|database)|terraform (apply|destroy)' <<<"$cmd" && deny "Ask the user to run this one."
exit 0
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-bash.sh", "args": [] }]
      }
    ]
  }
}

To start the script only for some commands, add an if to the hook, for example "if": "Bash(git push *)". A deny from PreToolUse holds in every permission mode, including --dangerously-skip-permissions, which makes it the guardrail the skip-permissions guide recommends. Treat it as a seatbelt against mistakes, though: a determined workaround (a script file, an alias) slips past string matching. Hard limits belong in deny rules and a sandbox or container.

3. Protect secrets and lockfiles

The simplest way to block is exit 2 with a message on stderr. .claude/hooks/protect-files.sh:

#!/bin/bash
path=$(jq -r '.tool_input.file_path // empty')
case "$path" in
  *.env|*.env.*|*/secrets/*|*package-lock.json|*pnpm-lock.yaml|*/.git/*)
    echo "Blocked: $path is protected. Tell the user what should change instead." >&2
    exit 2 ;;
esac
exit 0

Register it on PreToolUse with the matcher Edit|Write, like the example above. It covers Claude's edit tools; add "deny": ["Read(./.env)"] under permissions to keep the file out of Claude's context too.

4. Get notified when Claude needs you

On Linux, notify-send shows a desktop notification when Claude waits for permission or has sat idle after finishing:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [{ "type": "command", "command": "notify-send 'Claude Code' 'Claude Code needs you'" }]
      }
    ]
  }
}

On macOS, use osascript instead. The notifications guide has macOS, ntfy and Telegram versions and a Stop hook that includes Claude's last message.

5. Don't let Claude stop with failing tests

A Stop hook that exits 2 makes Claude keep working, with your stderr as the next instruction. .claude/hooks/tests-before-stop.sh:

#!/bin/bash
input=$(cat)
[ "$(jq -r '.stop_hook_active' <<<"$input")" = "true" ] && exit 0
[ -z "$(git status --porcelain 2>/dev/null)" ] && exit 0
if ! out=$(npm test 2>&1); then
  echo "Tests are failing. Fix them before you finish:" >&2
  tail -n 30 <<<"$out" >&2
  exit 2
fi
exit 0
{
  "hooks": {
    "Stop": [
      { "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/tests-before-stop.sh", "args": [], "timeout": 300 }] }
    ]
  }
}

Stop fires after every reply, so the script skips turns where nothing changed. Keep the stop_hook_active check: it lets Claude stop after one extra pass. Without it, tests that can never pass would keep Claude going, because the cap of eight consecutive blocks resets each time Claude calls a tool.

6. Give Claude context when a session starts

Plain text a SessionStart hook prints is added to Claude's context. This one runs on startup and after compaction:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|compact",
        "hooks": [{ "type": "command", "command": "git branch --show-current; git log --oneline -5; git status --short" }]
      }
    ]
  }
}

For facts that never change, CLAUDE.md is the better home.

Beyond shell commands

Hooks have five types. Besides command:

  • prompt sends the hook input to a Claude model, which answers {"ok": false, "reason": "..."} to keep Claude working. It suits checks like "is every requested task done?" The built-in /goal command is a shortcut for this kind of Stop hook.
  • agent spawns a subagent with tools to verify something, such as running the tests. Experimental as of October 2026.
  • http POSTs the input JSON to a URL, for example a shared audit log for a team.
  • mcp_tool calls a tool on a configured MCP server.

Command hooks can also run in the background. "async": true lets Claude carry on without waiting, and "asyncRewake": true wakes Claude when the background hook exits with code 2. Briefkin's Claude Code integration is built from exactly these pieces: command hooks on prompt submit, notifications, tool use, Stop and session end keep one card per session up to date on a shared board, and an asyncRewake Stop hook wakes a session when you answer its question from the board. The docs show what it installs.

Debug a hook

  1. Run /hooks to confirm the hook loaded and see which file it came from.
  2. Run the script by hand with sample input, then check the exit code:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' | .claude/hooks/guard-bash.sh
echo $?
  1. Start Claude Code with claude --debug and read the log at ~/.claude/debug/<session-id>.txt. A shell profile that prints text before your JSON shows up there as Hook output does not start with {.
  2. Press Ctrl+O for the transcript view, where blocks and hook errors appear.

Keep hooks safe

Command hooks run with your full user permissions, so read any hook before you add it. Quote variables ("$file", not $file) and reference scripts by absolute path or ${CLAUDE_PROJECT_DIR}.

Watch repositories you didn't write. In an interactive session, Claude Code holds back hooks until you accept the workspace trust dialog. A claude -p run treats the folder as trusted, so hooks committed in a cloned repo's .claude/settings.json run immediately. Review that folder first, or turn hooks off for the run:

claude -p "Summarize this repository" --settings '{"disableAllHooks": true}'

Questions

Where do Claude Code hooks go?

Under a hooks key in a settings file: ~/.claude/settings.json for all your projects, .claude/settings.json for one project (commit it to share with your team), or .claude/settings.local.json for one project, just for you. Plugins, managed settings, skills and subagents can also define hooks. Run /hooks to see everything that is loaded.

What is the difference between exit code 2 and exit code 1 in a hook?

Exit 2 is a blocking error: on events that can block, such as PreToolUse and Stop, Claude Code stops the action and passes your stderr along as the reason. Any other non-zero code is a non-blocking error, so the action goes ahead. A guard script that crashes with exit 1 does not block anything.

Do hooks still run with --dangerously-skip-permissions?

Yes. PreToolUse hooks fire before the permission mode is checked, so a hook that denies a call blocks it in every mode, including bypassPermissions. Hooks can tighten what is allowed but can't loosen your deny rules.

Why is my Claude Code hook not running?

Check /hooks to confirm it loaded under the right event, that the matcher matches the tool name exactly (matchers are case-sensitive), that the script is executable, and that the settings file is valid JSON with no trailing commas. In an interactive session, hooks wait until you accept the workspace trust dialog for the folder.

How do I stop a Stop hook from looping forever?

Read stop_hook_active from the hook's input and exit 0 when it is true, which means Claude is already continuing because of a Stop hook. Claude Code also overrides a Stop hook after eight consecutive blocks, but that count resets whenever Claude calls a tool, so don't rely on it alone.

More guides

All guides

Know when your agents need you

Briefkin puts your Claude Code, Codex and Cursor sessions on one board: working, needs you, done or failed. Telegram pings you when one is waiting on you, and scheduled skills post their reports next to them.

Free during the open beta. No card.