I ran a secret scanner on every project for months before I realized Claude Code was writing .env files with real credentials baked in. Not because it was malicious. Just because the context had a key, and it needed a value.

The fix took five minutes once I knew hooks existed.

Claude Code hooks run a command of your choosing automatically at set points in a session. Before a file gets written, after a bash command runs, when Claude finishes responding. The hook receives JSON about the event on stdin, and a PreToolUse hook can block the tool call before it happens.

This is the guide I wish I had when I started.

What are Claude Code hooks?

Claude Code hooks are user-defined shell commands, HTTP endpoints, MCP tool calls, LLM prompts, or subagents that Claude Code runs automatically at specific points in its lifecycle. The point is deterministic control. A hook makes an action always happen, where an instruction in CLAUDE.md relies on the model choosing to follow it.

The flow is the same for every hook. An event fires, a matcher decides whether your hook applies, and Claude Code passes JSON about the event to your handler. The handler can inspect it, take action, and sometimes return a decision. The official hooks reference is the source for everything in this section.

Claude Code hooks list: the lifecycle events

As of 09/29/2026 the reference lists 33 events. They fall into three cadences:

  • Per session: SessionStart and SessionEnd.
  • Per turn: UserPromptSubmit, Stop, and StopFailure.
  • On every tool call inside the agentic loop: PreToolUse and PostToolUse. EndConversation calls skip both.

The last column shows whether the docs say exit code 2 can block the event.

EventWhen it firesCan it block?
SessionStartA session begins or resumesNo
SetupClaude Code starts with --init-only, or with --init or --maintenance in -p modeNo
UserPromptSubmitYou submit a prompt, before Claude processes itYes
UserPromptExpansionA typed command expands into a promptYes
PreToolUseBefore a tool call executesYes
PermissionRequestA tool call needs a permission decisionNo (deny through the JSON decision object)
PermissionDeniedAuto mode denies a tool callNo
PostToolUseAfter a tool call succeedsNo
PostToolUseFailureAfter a tool call failsNo
PostToolBatchAfter a batch of parallel tool calls resolvesYes
NotificationClaude Code sends a notificationNo
MessageDisplayWhile assistant message text is displayedNo
SubagentStartA subagent is spawnedNo
SubagentStopA subagent finishesYes
TaskCreatedA task is being created via TaskCreateYes
TaskCompletedA task is being marked as completedYes
StopClaude finishes respondingYes (keeps Claude working)
StopFailureThe turn ends due to an API errorNo
TeammateIdleAn agent team teammate is about to go idleYes
InstructionsLoadedA CLAUDE.md or .claude/rules file is loadedNo
ConfigChangeA configuration file changes during a sessionYes (except policy settings)
CwdChangedThe working directory changesNo
DirectoryAddedA working directory is added mid-sessionNo
FileChangedA watched file changes on diskNo
WorktreeCreateA worktree is being createdYes (any non-zero exit fails creation)
WorktreeRemoveA worktree is being removedYes (any non-zero exit fails removal)
PreCompactBefore context compactionYes
PostCompactAfter context compaction completesNo
PreModelSwitchBefore a requested model switch is appliedYes
PostModelSwitchAfter the session’s model changesNo
ElicitationAn MCP server requests user inputYes
ElicitationResultAfter a user responds to an MCP elicitationYes
SessionEndA session terminatesNo

“Block” means something different per event. On PreToolUse it stops the tool call. On UserPromptSubmit it rejects the prompt. On Stop it prevents Claude from stopping, so the conversation continues.

Handler types

Each hook entry runs a handler. The docs list five types:

  • command: run a shell command. Your script gets the event JSON on stdin and answers with exit codes and stdout.
  • http: send the event JSON as an HTTP POST to a URL.
  • mcp_tool: call a tool on a configured MCP server.
  • prompt: send a prompt to a Claude model for single-turn evaluation.
  • agent: spawn a subagent that can read files to verify a condition. The docs mark agent hooks as experimental.

Every example below uses command, because it is the simplest to test.

Why do you need hooks?

Claude Code is an autonomous agent. It reads files, writes code, runs commands, and makes decisions faster than you can review each one. That autonomy is the point. But it creates a gap: how do you enforce standards without reviewing every action manually?

Hooks close that gap. They are your enforcement layer, running in the background, checking every operation against your rules, and either letting it through or blocking it before any damage is done.

Think of them as middleware for your AI agent. The tool call fires an event, your hook intercepts it, does its check, and returns a decision. On a PreToolUse hook, exit code 2 or a permissionDecision of deny stops the tool call. Exit 0 with no output means the hook has no opinion, so the normal permission flow continues. Staying silent does not approve the call.

Hooks are also a different tool from skills and CLAUDE.md. A skill is model-decided: Claude chooses whether to load it, which is why Claude Code skills work well for know-how and poorly for rules that must never be skipped. A hook always runs when its event and matcher match. If you are choosing between a hook, a skill, and a CLAUDE.md line, skills vs subagents covers the skill side of that decision. The docs also say to prefer CLAUDE.md for instructions that never change, because it loads without running a script.

Which hook events do I actually use?

Out of that list, these are the events I reach for most, plus one compaction trick:

PreToolUse, fires before a tool call executes. You can inspect the tool input and block the call. This is where guardrails live.

PostToolUse, fires after a tool call succeeds. You get the tool response. Use it for logging or formatting. The tool already ran, so a PostToolUse hook cannot undo it.

Notification, fires when Claude Code sends a notification. Good for custom alerts.

Stop, fires when Claude finishes responding. That is not the same as the task being done, and it does not fire on user interrupts.

SubagentStop fires when a subagent finishes, if you run parallel agents.

How does a Claude Code hook block a tool call?

This is where my original version of this post was wrong, so read this part closely. There are two ways to block a tool call from a PreToolUse hook.

Option 1: exit code 2. Write a reason to stderr and exit 2. Claude Code blocks the call and shows Claude your stderr message. Exit 2 blocks even if you also print JSON with an allow decision.

Option 2: a JSON decision. Exit 0 and print this to stdout:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Database writes are not allowed"
  }
}

permissionDecision accepts allow, deny, ask (prompt the user), and defer (the docs say Claude Code honors it only in non-interactive -p mode and ignores it with a warning in an interactive session). When several PreToolUse hooks disagree, the docs give the precedence as deny, then defer, then ask, then allow.

Who reads permissionDecisionReason depends on the decision. On deny, Claude sees it. On ask, you see it in the prompt and Claude does not. On allow and defer, it only goes to the debug log. To tell Claude why whatever the decision, add an additionalContext string next to it, which the docs say is added to Claude’s context alongside the tool result.

What {"continue": false} does instead. It is a universal field that tells Claude Code to stop processing entirely after the hook runs, and it takes precedence over event-specific decisions. That is a stop, not a tool block. Use it when you want the whole run to halt, with an optional stopReason shown to you. For guarding one tool call, use exit 2 or permissionDecision.

Two more facts that matter for a gate:

  • Exit 1 does not block. For most events, exit code 2 is the only exit code that blocks on its own. Without valid JSON on stdout, exit 1 is a non-blocking error and the action proceeds.
  • A timed-out hook does not block. If a command hook hits its timeout, the tool call continues through the normal permission flow. Do not count on a stalled hook as a gate.

The old top-level decision and reason fields are deprecated for PreToolUse. Use hookSpecificOutput.permissionDecision instead. A PreToolUse deny is honored even in bypassPermissions mode, and a hook can tighten permissions but not loosen them past your deny rules.

Where do you configure Claude Code hooks?

Hooks live in JSON settings files. Where you put one decides its scope:

  • ~/.claude/settings.json: all your projects, local to your machine.
  • .claude/settings.json: one project, can be committed to the repo.
  • .claude/settings.local.json: one project, not shared.
  • A plugin’s hooks/hooks.json, or skill and subagent frontmatter, for hooks that travel with those.

The structure has three levels: an event, a matcher group, and one or more handlers.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "/Users/you/scripts/scan-secrets.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hooks": [
          {
            "type": "command",
            "command": "/Users/you/scripts/auto-format.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/Users/you/scripts/notify-complete.sh"
          }
        ]
      }
    ]
  }
}

The matcher field filters when the hooks fire. For tool events it matches the tool name. If it contains only letters, digits, underscores, hyphens, spaces, commas and |, it is an exact string or a list of exact strings, so Write|Edit matches the Write tool and the Edit tool and nothing else. If it contains any other character it is a JavaScript regular expression, unanchored, so Edit.* matches both Edit and NotebookEdit. Matchers are case-sensitive. Leave the matcher out, or use *, to match every occurrence of the event.

For tool events you can narrow further with an if field on the handler, written in permission rule syntax. "if": "Bash(rm *)" runs the hook only when a Bash subcommand matches rm *, so the script does not spawn for every command. The docs call if best-effort, so use permission rules, not a hook, when you need a hard allow or deny.

Two more notes. All matching hooks run in parallel. And in an interactive session, Claude Code holds back hooks from every settings file, including your own, until you accept the workspace trust dialog for the folder.

What your hook receives

Every hook gets a JSON object via stdin. For a PreToolUse hook on the Write tool, it looks like this (abridged):

{
  "session_id": "abc123",
  "cwd": "/Users/you/project",
  "hook_event_name": "PreToolUse",
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/Users/you/project/.env",
    "content": "STRIPE_SECRET_KEY=sk_live_abc123..."
  },
  "tool_use_id": "toolu_01ABC123..."
}

For a Bash tool, tool_input contains command instead of file_path. For Edit, you get file_path, old_string, and new_string. The shape matches the tool’s schema. For the file tools, file_path is always absolute, because Claude Code expands ~ and relative paths before the hook runs.

PostToolUse hooks also get tool_response, the actual output the tool returned.

What your hook must return

For a command hook, the exit code and stdout are the whole interface.

  • Exit 0 with no output: no decision. The normal permission flow continues.
  • Exit 2: a blocking error on events that can block. Your stderr text becomes the reason Claude sees.
  • Exit 0 with a JSON object: structured control, such as the permissionDecision example above.

The part that trips people up is stdout parsing. Claude Code treats your stdout as JSON only if it starts with { and ends with }. Anything else is plain text. On most events plain text is written to the debug log and ignored, so a stray line of output before your JSON does not raise an error. It silently turns your decision into nothing. Build the JSON with a real encoder (jq or Python) instead of string concatenation, so quotes and backslashes inside values are escaped.

#!/bin/bash
# WRONG - the extra line means stdout no longer starts with "{",
# so Claude Code reads all of it as plain text and ignores the decision
echo "Scanning for secrets..."
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"secret found"}}'

# RIGHT - print nothing except the JSON object
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"secret found"}}'

The universal JSON fields are continue, stopReason, suppressOutput, systemMessage and terminalSequence. The docs say Claude Code accepts suppressOutput but does not act on it. Choose one approach per hook: exit codes alone, or exit 0 and JSON. If you mix them, exit 2 keeps its blocking effect.

Hook 1: Secret scanner

This is the one I wish I had from day one. It runs before any Write, Edit or NotebookEdit and blocks the operation if it finds credentials. Each tool names its new text differently (content, new_string, new_source), so the script reads all three. It uses exit 2, so the reason goes to stderr and Claude can read it and explain. If the input will not parse, it blocks instead of waving the write through.

#!/bin/bash
# ~/.claude/scripts/scan-secrets.sh

INPUT=$(cat)

# The text being written: Write sends content, Edit sends new_string,
# NotebookEdit sends new_source. If the input will not parse, block:
# a scanner that cannot read what it scans must not let it through.
if ! CONTENT=$(printf '%s' "$INPUT" | python3 -c "
import json, sys
ti = json.load(sys.stdin).get('tool_input') or {}
print(''.join(str(ti.get(k) or '') for k in ('content', 'new_string', 'new_source')))
" 2>/dev/null); then
  echo "Blocked: the secret scanner could not parse its input, so it failed closed." >&2
  exit 2
fi

# Check for common secret patterns, one per line
while IFS= read -r pattern; do
  if printf '%s' "$CONTENT" | grep -qE -- "$pattern"; then
    echo "Blocked: content matches a secret pattern ($pattern). Reference an environment variable instead." >&2
    exit 2
  fi
done <<'PATTERNS'
sk_live_[A-Za-z0-9]+
sk-ant-[A-Za-z0-9_-]+
-----BEGIN ([A-Z]+ )?PRIVATE KEY-----
xoxb-[A-Za-z0-9-]+
AKIA[A-Z0-9]{16}
ghp_[A-Za-z0-9]{36}
rpa_[A-Za-z0-9]+
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
PATTERNS

exit 0

Register it in settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit|NotebookEdit",
        "hooks": [{ "type": "command", "command": "/Users/you/.claude/scripts/scan-secrets.sh" }]
      }
    ]
  }
}

Now every Write, Edit and NotebookEdit call goes through the scanner. If it finds a Stripe live key, an Anthropic key, a private key, a Slack token, or an AWS key, it blocks with a reason Claude can read and explain. It only catches the key formats on its list, and the matcher only sees calls to the tools it names, so a Bash command such as echo ... > .env never reaches it. For the limit of what hooks can and cannot see, read Claude Code hook events and the output gating gap.

Hook 2: Auto-formatter

After Claude edits a TypeScript or Python file, run the formatter automatically. No more “Claude wrote valid code but wrong indentation.”

#!/bin/bash
# ~/.claude/scripts/auto-format.sh

INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(d.get('tool_input', {}).get('file_path', ''))
" 2>/dev/null || echo "")

if [[ -z "$FILE" ]]; then
  exit 0
fi

case "$FILE" in
  *.ts|*.tsx)
    command -v prettier &>/dev/null && prettier --write "$FILE" &>/dev/null
    ;;
  *.py)
    command -v ruff &>/dev/null && ruff format "$FILE" &>/dev/null
    ;;
esac

exit 0

PostToolUse on Edit and Write:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "/Users/you/.claude/scripts/auto-format.sh" }]
      }
    ]
  }
}

The formatter runs quietly after every edit. Claude’s next read of the file sees clean, formatted code without any back-and-forth. A formatter needs no decision, so the script prints nothing and exits 0.

Hook 3: Slack notification on task complete

I work with Claude running in the background while I do other things. The Stop hook lets me know when it is done without watching the terminal. Remember that Stop fires whenever Claude finishes responding, so on a long session it fires at the end of every turn.

#!/bin/bash
# ~/.claude/scripts/notify-complete.sh

TOKEN="$SLACK_BOT_TOKEN"
CHANNEL="$SLACK_NOTIFY_CHANNEL"

if [[ -z "$TOKEN" || -z "$CHANNEL" ]]; then
  exit 0
fi

INPUT=$(cat)
SESSION=$(echo "$INPUT" | python3 -c "
import json, sys
print(json.load(sys.stdin).get('session_id', 'unknown'))
" 2>/dev/null || echo "unknown")

curl -sf -X POST https://slack.com/api/chat.postMessage 
  -H "Authorization: Bearer $TOKEN" 
  -H "Content-Type: application/json" 
  -d "{"channel":"$CHANNEL","text":":white_check_mark: Claude finished responding (session: $SESSION)"}" 
  > /dev/null

exit 0
{
  "hooks": {
    "Stop": [
      {
        "hooks": [{ "type": "command", "command": "/Users/you/.claude/scripts/notify-complete.sh" }]
      }
    ]
  }
}

Now when a long refactor finishes, my phone buzzes. If you only want a ping when Claude needs you, the Notification event has matchers such as permission_prompt and idle_prompt for that.

Hook 4: Approval gate for destructive bash commands

This one requires more care. Some bash commands are irreversible: dropping databases, deleting branches, modifying production configs. The PreToolUse hook on Bash lets you intercept these.

#!/bin/bash
# ~/.claude/scripts/approve-destructive.sh

INPUT=$(cat)
CMD=$(echo "$INPUT" | python3 -c "
import json, sys
print(json.load(sys.stdin).get('tool_input', {}).get('command', ''))
" 2>/dev/null || echo "")

while IFS= read -r pattern; do
  if echo "$CMD" | grep -qiF -- "$pattern"; then
    python3 -c "
import json, sys
print(json.dumps({'hookSpecificOutput': {
  'hookEventName': 'PreToolUse',
  'permissionDecision': 'deny',
  'permissionDecisionReason': 'Blocked: ' + sys.argv[1] + ' requires explicit approval. Run the command manually if intended.'
}}))
" "$pattern"
    exit 0
  fi
done <<'PATTERNS'
rm -rf
drop table
git push --force
git reset --hard
kubectl delete
systemctl stop
PATTERNS

exit 0

This version denies and explains. You review the command, run it yourself if it is correct, and Claude continues from there. The docs also offer a middle path: return permissionDecision of ask and Claude Code prompts you to confirm the call instead of denying it. On ask the reason goes to you, not Claude, so add additionalContext if you want Claude to know why it was stopped. I still deny, because I want a destructive command to be a deliberate act I run myself.

Treat this gate as a speed bump, not a safety boundary. It matches fixed strings, so rm -fr, an rm with two spaces before -rf, git push -f, git clean -fdx and find . -delete all pass it (I piped each one through the script to check). Commands you never want run belong in permission deny rules, which Claude Code enforces even when a hook returns allow:

{
  "permissions": {
    "deny": [
      "Bash(rm -rf *)",
      "Bash(rm -fr *)",
      "Bash(git push --force *)",
      "Bash(git push -f *)"
    ]
  }
}

Deny rules are sturdier than a grep: Claude Code splits compound commands, strips wrappers like timeout, and matches past a leading variable assignment. The docs are still clear that a Bash rule matches the command as Claude writes it, not every way to run the program (/bin/rm or bash -c get past it), so for a real boundary use the sandbox.

Compaction hooks: what are hooks in Claude Code compaction?

Compaction summarizes the conversation when the context window fills up, and that can lose details. Two events wrap it. PreCompact runs before compaction, matches manual or auto, and can block it with exit 2. If it blocks an automatic compaction that was triggered before the limit, the conversation continues uncompacted. PostCompact runs after and cannot change the result.

The pattern from the docs guide is a SessionStart hook with the compact matcher, which re-injects critical reminders after every compaction. For that event, plain text your command writes to stdout is added to Claude’s context:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Reminder: use Bun, not npm. Run bun test before committing.'"
          }
        ]
      }
    ]
  }
}

Claude Code hook use cases and best practices

Every use case below is one the docs describe. Pick the event by when the action has to happen.

Use cases

  • Block risky actions. PreToolUse on Bash or Write, as in hooks 1 and 4. The docs example blocks edits to protected files.
  • Format after edits. PostToolUse on Edit and Write, as in hook 2.
  • Get notified. Notification or Stop, as in hook 3.
  • Restore context after compaction. SessionStart with the compact matcher.
  • Audit configuration changes. ConfigChange logs when a settings file changes mid-session.
  • Reload the environment. CwdChanged and FileChanged work with tools like direnv.
  • Auto-approve a known permission prompt. PermissionRequest can allow a specific request on your behalf.

Best practices

  • Enforce with exit 2. If a hook is a policy, exit 2 or return a deny decision. Exit 1 lets the action through.
  • Test with piped JSON. Run echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh and check echo $? before you trust the hook.
  • Watch the first run. A mistyped script path is a non-blocking error, so a gate can sit silently disabled. Use /hooks to confirm it is registered and claude --debug-file /tmp/claude.log to see what matched.
  • Keep hooks fast. A hook blocks Claude until it finishes. Narrow it with a matcher and an if field, and set "async": true for slow work. An async hook cannot block or return decisions.
  • Follow the docs’ security list. Validate and sanitize input, quote shell variables, block path traversal with a check for .., use absolute paths, and skip sensitive files like .env and keys.
  • Guard Stop hooks against loops. If a Stop hook blocks, read stop_hook_active from the input and exit 0 when it is true. Claude Code overrides a Stop hook after eight consecutive blocks.
  • Do not stack input rewrites. If several PreToolUse hooks return updatedInput for the same tool, the last one to finish wins, and hooks run in parallel.

Gotchas that cost me time

Shell profile output can disable your hook. If your .zshrc or .bashrc prints anything (greeting messages, nvm output, conda activation) and the profile gets sourced, that text lands in front of your JSON. Then stdout no longer starts with {, and Claude Code ignores the decision. On exit 0 you see nothing in the transcript. The docs fix is to run those echo lines only in interactive shells, with a check like if [[ $- == *i* ]]; then echo "Shell ready"; fi.

Do not hide stderr on a blocking hook. My older scripts started with exec 2>/dev/null. That is wrong for an exit 2 hook, because stderr is the reason Claude sees when you block. Only silence stderr on hooks that never block.

Hooks run in a non-interactive shell. Your aliases and shell functions are not loaded. Use absolute paths to commands (/opt/homebrew/bin/prettier, not prettier), or reference project scripts with the CLAUDE_PROJECT_DIR variable.

PreToolUse latency adds up. If your hook takes 500ms and Claude runs 50 Edit operations, that is 25 extra seconds. Profile your hooks. Secret scanning should be under 50ms. If it is slow, check for regex backtracking.

Matchers are exact strings until they are not. Write|Edit is an exact list. Edit.* is a regex and also matches NotebookEdit. Wrap a regex in ^ and $ if you need a whole-name match.

On Windows, match PowerShell too. Where the PowerShell tool is enabled, a hook that matches only Bash never fires. Use Bash|PowerShell for hooks that inspect shell commands.

A starter settings.json hooks section

This is the four-hook setup from this post in one file. Replace the paths with where you saved the scripts:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit|NotebookEdit",
        "hooks": [{ "type": "command", "command": "/Users/you/.claude/scripts/scan-secrets.sh" }]
      },
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "/Users/you/.claude/scripts/approve-destructive.sh" }]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "/Users/you/.claude/scripts/auto-format.sh" }]
      }
    ],
    "Stop": [
      {
        "hooks": [{ "type": "command", "command": "/Users/you/.claude/scripts/notify-complete.sh" }]
      }
    ]
  }
}

Four hooks cover the common cases: secrets, destructive commands, formatting, and completion notifications. My own setup has since grown well past four, but these are the ones I would start with. Add more only when a rule keeps getting skipped.

If you are using Claude Code for real projects, you already know the trust issue. You cannot review every edit. Hooks are how you stop trusting blindly and start trusting with guardrails. Hooks guard what Claude does in a session. What the project has learned needs somewhere to live between sessions.

Where to go from here

Hooks are composable. You can put multiple hooks on the same event, and they run in parallel. You can use them to log every tool call to a file for auditing. You can build approval workflows that post to Slack and wait for a reply before proceeding. If you are wiring Claude Code around executive-function friction, the Claude for ADHD workflow shows the rules I start with. For the wider picture of how hooks fit with skills, subagents and CLAUDE.md, start with the Claude Code complete guide.

The pattern I am building toward: a full audit log of every Claude action, with replay capability. Every Write, Edit, and Bash call gets logged with the session ID, timestamp, and tool input. When something goes wrong, I can reconstruct exactly what happened and in what order.

That is the next post. For now, start with the secret scanner. It is the one hook that pays for itself the first time it catches something.


See also: Claude vs Cursor vs Copilot: 2026 Comparison