Claude Code hooks: how they work, with examples you can copy

Dmitriy Solodukha · checked against Caprock v0.140.1

Claude Code hooks run your commands at points in a session. The events, the settings.json format, exit codes, and six tested examples to copy into your project.

A hook is a command Claude Code runs at a fixed point in a session: before a tool call, after a file edit, when Claude stops, when it needs you. You put it in settings.json; it gets the event as JSON on stdin and answers with an exit code or JSON on stdout. Unlike an instruction in CLAUDE.md, a hook runs every time.

How a hook is written

Three levels: the event, a matcher group, and the handlers to run. This one runs a script before every Edit or Write:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}
  • The matcher filters the event: a tool name for tool events, a source like startup or compact for SessionStart. Plain names separated by | or commas are an exact list; anything else is a regular expression, so Edit.* also matches NotebookEdit. Leave it out to match everything.
  • The type is command for a shell command; http, mcp_tool, prompt and agent exist too.
  • The timeout is in seconds; a command hook gets 600 by default.
  • CLAUDE_PROJECT_DIR holds the project root, so a script path works from any folder.

Hooks in every settings file run together: user, project, local, managed and plugins. Claude Code picks up edits to the files on its own; if a change does not show, restart the session.

Hook events

Claude Code has 33 events. The ones most hooks use:

Common Claude Code hook events, from the hooks reference, read 20 October 2026.
EventWhenExit 2
SessionStarta session starts or resumesdoes not block
UserPromptSubmityou send a promptblocks it
PreToolUsebefore a tool callblocks it
PermissionRequesta call needs permissionuse JSON
PostToolUseafter a tool calltells Claude
NotificationClaude needs youdoes not block
StopClaude finisheskeeps it working
SubagentStopa subagent finisheskeeps it working
PreCompactbefore compactionblocks it
SessionEndthe session endsdoes not block
The rest cover subagents, tasks, config and file changes, worktrees, model switches and MCP prompts.

What a hook receives and returns

On stdin every hook gets session_id, transcript_path, cwd and hook_event_name, and most get permission_mode; tool events add tool_name and tool_input. Read fields with jq.

  • Exit 0: carry on. Stdout is read as JSON if it is a JSON object; on SessionStart and UserPromptSubmit, plain text is added to Claude's context.
  • Exit 2: block, where the event allows it. Stderr goes to Claude as the reason.
  • Any other code: an error that does not block. A policy hook that exits 1 with nothing on stdout lets the action through; we tested that on 2.1.289 and again on 2.1.299. Two exceptions: if stdout is valid JSON, the exit code is ignored and the JSON decides, so a deny printed before exit 1 still blocks; and from Claude Code 2.1.295, "onFailure": "block" on a command or http hook makes a failure (exit 1, a crash, a timeout, a missing script) block the way exit 2 does. onFailure has no effect on Stop, SubagentStop, TaskCompleted, TeammateIdle or background hooks.

For more than yes or no, print JSON. On PreToolUse, hookSpecificOutput.permissionDecision is allow, deny, ask or defer, and when several hooks answer, deny wins.

Example 1: a desktop notification when Claude needs you

In ~/.claude/settings.json, so it works in every project. Phone and Telegram alerts: Claude Code notifications. On Linux, use notify-send 'Claude Code' 'Claude Code needs your attention' instead.

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

Example 2: format every file Claude edits

A PostToolUse hook on Edit|Write runs Prettier on the file just written. Swap in gofmt -w, ruff format or your own formatter.

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

Example 3: block edits to .env and lockfiles

Save as .claude/hooks/protect-files.sh, run chmod +x on it, and register it with the PreToolUse block at the top of this page.

#!/bin/bash
# PreToolUse on Edit|Write: refuse to touch secrets and lockfiles.
file=$(jq -r '.tool_input.file_path // empty')
for pattern in ".env" "package-lock.json" ".git/"; do
  if [[ "$file" == *"$pattern"* ]]; then
    echo "Blocked: $file matches protected pattern '$pattern'" >&2
    exit 2
  fi
done
exit 0

Asked to write .env, Claude got the block and said so: Blocked: …/.env matches protected pattern '.env'. No file was created.

Example 4: stop force pushes, with a reason

This one answers in JSON instead of an exit code. Save it as .claude/hooks/block-force-push.sh and register it under PreToolUse with the matcher Bash.

#!/bin/bash
# PreToolUse on Bash: deny force pushes, with a reason Claude reads.
cmd=$(jq -r '.tool_input.command // empty')
force='(--force|[[:space:]]-f([[:space:]]|$))'
if [[ "$cmd" =~ git[[:space:]]+push.*$force ]]; then
  jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse",
    permissionDecision: "deny",
    permissionDecisionReason: "Force push is blocked. Push without --force."}}'
fi
exit 0

A deny from a hook holds even with --dangerously-skip-permissions, which makes this the way to keep a few rules when prompts are off; see skipping permissions.

Example 5: log every command Claude runs

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt"
          }
        ]
      }
    ]
  }
}

Example 6: don't let Claude stop while the tests fail

A Stop hook that exits 2 sends Claude back to work with the reason. Check stop_hook_active: it is true when Claude is already continuing because of a stop hook, and without the check a test that never passes keeps it going until Claude Code's cap of eight forced continues.

#!/bin/bash
# Stop: keep Claude working while the tests fail.
if [ "$(jq -r '.stop_hook_active')" = "true" ]; then exit 0; fi
if ! npm test >/tmp/claude-stop-tests.log 2>&1; then
  echo "Tests fail; see /tmp/claude-stop-tests.log. Fix them first." >&2
  exit 2
fi
exit 0

Register it under Stop with no matcher; Stop ignores matchers. Asked only to say hello in a folder whose test failed, Claude read the reason, made the test pass and stopped.

When a hook does not run

  • Look at what loaded: /hooks lists every hook Claude Code loaded and the file it came from. It is read-only; edit the JSON to change one.
  • Read the debug log: claude --debug writes each hook run, its exit code and output to ~/.claude/debug/.
  • Matchers are case-sensitive: bash matches nothing; the tool is Bash.
  • The script is not executable, or a path has a space and is not quoted.
  • An untrusted folder: in an interactive session, hooks wait until you accept the trust dialog.
  • Hooks are switched off: disableAllHooks: true in any settings file turns them all off.

Command hooks run with your user's full permissions. A repository's .claude/settings.json can carry hooks, and in -p runs the folder is treated as trusted, so read a cloned repo's .claude/ before running it headless.

Caprock is built on hooks

Caprock, the free app that records every coding agent on your machine, gets live activity through one small program it registers on ten events. In the app, when the hooks are missing, the Install hooks button on the Home screen adds them (since 0.70.1); without the app, caprock up asks before adding it. It adds its entries next to yours, backs up settings.json first, and caprock hooks uninstall removes only its own.

$ caprock hooks status
settings: /Users/ds/.claude/settings.json
shim:     /Users/ds/Library/Application Support/caprock/caprock-hook (exists: true)
installed: [SessionStart UserPromptSubmit PreToolUse PostToolUse Stop SubagentStop SessionEnd PreCompact StopFailure PermissionRequest]
missing:   []

The hook posts each event to Caprock on 127.0.0.1; most events return in under a second, inside a five-second timeout, so it never holds up a keystroke. If Caprock is not running, it exits quietly and the session carries on. On SessionStart it answers with the JSON output above: additionalContext saying where the previous session in this repository left off. On by default; turn it off in settings.

Every event lands in Caprock's record. On my machine, in the 24 hours to 4 October 2026:

6,233
tool calls seen by PreToolUse
2,063
Stop and SubagentStop events
215
prompts, from UserPromptSubmit

Questions people ask

What are Claude Code hooks?
Commands, HTTP calls, MCP tools or prompts that Claude Code runs at fixed points in a session, such as before a tool call or when Claude finishes. They are set in settings.json under hooks.
Where do Claude Code hooks go?
In ~/.claude/settings.json for every project, .claude/settings.json to share with a repository, or .claude/settings.local.json for yourself. Hooks from all of them run; they merge rather than override.
How does a hook block a tool call?
A PreToolUse hook exits with code 2 and writes the reason to stderr, or prints JSON with permissionDecision set to deny. Claude sees the reason. Exit code 1 does not block on its own; it does when the hook prints a JSON deny, or, from Claude Code 2.1.295, when the hook sets onFailure to block.
Do hooks run with --dangerously-skip-permissions?
Yes. A PreToolUse hook that denies a call blocks it in every permission mode, bypassPermissions included.
Why is my hook not running?
Check /hooks to see what Claude Code loaded, the matcher's case and spelling, that the script is executable, and claude --debug, which writes hook runs to ~/.claude/debug/. A hook in a folder you have not trusted waits for the trust dialog.

Sources