Published Nov 9, 2026 ⦁ 22 min read

Claude Code Hooks: Complete Guide with 11 Copy-Paste Examples

Claude Code hooks are commands, HTTP calls, MCP tool calls or small model checks that Claude Code runs on its own at fixed points in a session: before a tool runs, after a file is edited, when Claude needs your input, when it finishes a turn. You define them as JSON in a settings file, and they run every time, whether or not Claude "remembers" to. That makes Claude Code hooks the tool for rules that must hold: format every edit, never touch .env, ping me when you're stuck, don't stop until the tests pass. Below: where hooks live, all 33 events, the stdin JSON, exit codes and JSON output, matchers, the five handler types, and 11 copy-paste recipes. Checked against the Claude Code docs in November 2026 (v2.1.319).

33
Hook events in the current hooks reference
5
Handler types: command, http, mcp_tool, prompt, agent
600 s
Default timeout for command, http and mcp_tool hooks (prompt 30 s, agent 60 s)
1.5 s
Default shared time budget for SessionEnd hooks
10,000
Character cap on additionalContext, systemMessage and plain stdout
8
Consecutive Stop-hook continuations before Claude Code ends the turn anyway

What are Claude Code hooks, and when should you use them?

A hook is a handler the Claude Code harness runs when an event fires; the model doesn't decide whether it runs. Write "always run Prettier" in CLAUDE.md and Claude usually complies. Register a PostToolUse hook on Edit|Write and Prettier runs after every edit. Pick the right layer for each rule:

Best for Conventions and context: CLAUDE.md
Coding style, architecture notes, which test command to run. Claude reads it every session. It is guidance, not enforcement. See CLAUDE.md examples.
Best for Plain allow and deny lists: Permission rules
permissions.deny with Read(./.env) or Bash(rm -rf *) is simpler than a hook and is the hard gate. The docs call hook if filters best effort and point to the permission system for hard allow or deny.
Best for Rules that need code or must run every time: Hooks
Format after edits, block by inspecting the full command, notify you, inject live git state, make Claude keep working until tests pass.
Best for Reusable procedures Claude chooses to run: Skills
A checklist or workflow Claude loads when it's relevant. Browse ready-made ones in the skills directory.

Rule of thumb: if breaking the rule once costs real time or money, make it a hook.

  1. v1.0.38Hooks released.
  2. v1.0.41Stop split into Stop and SubagentStop.
  3. v1.0.48 to v1.0.62PreCompact, UserPromptSubmit, the CLAUDE_PROJECT_DIR variable and SessionStart added.
  4. v2.0.10PreToolUse hooks can rewrite tool input.
  5. v2.0.30Prompt-based Stop hooks.
  6. v2.1.0Hooks in skill and subagent frontmatter, once: true.
  7. v2.1.63HTTP hooks.
  8. v2.1.85The if field filters handlers with permission rule syntax.
  9. v2.1.118MCP tool hooks (mcp_tool).
  10. v2.1.251PreModelSwitch and PostModelSwitch events.
  11. v2.1.295onFailure set to block makes a crashed or timed-out guard hook block the action.

Where Claude Code hooks are configured

Hooks live under a "hooks" key. Where you put that key sets the scope (hook locations):

Location Applies to Shared?
~/.claude/settings.json All your projects on this machine No
.claude/settings.json This project, everyone who clones it Yes, commit it
.claude/settings.local.json This project, only you No, gitignored
Managed policy settings Everyone in the organization Admin-controlled
Plugin hooks/hooks.json While the plugin is enabled Yes, ships with the plugin
Skill frontmatter Rest of the session after the skill is invoked Yes, in SKILL.md
Subagent frontmatter Only while that subagent runs Yes, in .claude/agents/*.md

Hooks merge across these levels: a project file adds to yours instead of replacing it, and an identical handler in two files runs once. Edits are normally picked up without a restart. The structure is event, then matcher group, then handlers:

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

Add new events as siblings inside the one hooks object, not as a second hooks block.

Plugin hooks

A plugin ships hooks in hooks/hooks.json, same format plus an optional description. Use ${CLAUDE_PLUGIN_ROOT} for bundled scripts and ${CLAUDE_PLUGIN_DATA} for state that should survive plugin updates:

{
  "description": "Automatic code formatting",
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/scripts/format.sh", "args": [], "timeout": 30 }
        ]
      }
    ]
  }
}

Hooks in skills and subagents

Skills and subagents use the same format in YAML frontmatter (hooks in skills and agents). A skill's hooks register when it's invoked and last for the session (once: true drops one after its first successful run). A subagent's hooks run only while it runs, and its Stop becomes SubagentStop.

---
name: db-migrator
description: Writes and applies database migrations. Use for schema changes.
tools: Read, Edit, Write, Bash
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "./scripts/check-migration-command.sh"
---
You write reversible Postgres migrations...

Save that as .claude/agents/db-migrator.md. Plugin subagents ignore the hooks field. More in the subagents guide.

The /hooks command

/hooks opens a read-only list of every loaded hook with its source (user, project, local, plugin, session); select one to see its command and file. Edit the JSON to change it. To switch all hooks off, set "disableAllHooks": true, or for one run claude --settings '{"disableAllHooks": true}'. Other commands are in the Claude Code commands guide.

All Claude Code hook events (full list)

The hooks reference lists 33 events. "Blocks" means exit code 2 (or the matching JSON decision) stops the action. Events with no matcher ignore any matcher you set.

Event Fires Matcher Blocks?
SessionStart Session starts or resumes startup, resume, clear, compact, fork No
Setup --init-only, or --init / --maintenance with -p init, maintenance No
InstructionsLoaded CLAUDE.md or a rules file loads load reason No
UserPromptSubmit Prompt submitted, before Claude sees it none Yes
UserPromptExpansion Slash command expands into a prompt command name Yes
MessageDisplay Assistant text is displayed none No
PreToolUse Before a tool call tool name Yes
PermissionRequest Permission dialog about to show tool name JSON only
PermissionDenied Auto mode denies a call tool name No
PostToolUse Tool call succeeded tool name No
PostToolUseFailure Tool call failed tool name No
PostToolBatch Parallel tool calls resolved none Yes
Notification Claude Code sends a notification notification type No
SubagentStart Subagent spawned agent type No
SubagentStop Subagent finished agent type Yes
TaskCreated Task created none Yes
TaskCompleted Task marked done none Yes
Stop Claude finished responding none Yes
StopFailure Turn ended on an API error error type No
TeammateIdle Agent team teammate going idle none Yes
ConfigChange Settings or skills file changed config source Yes
CwdChanged Working directory changed none No
DirectoryAdded /add-dir added a directory how it was added No
FileChanged Watched file changed on disk filenames No
WorktreeCreate Worktree being created none Yes
WorktreeRemove Hook-created worktree removed none Yes
PreCompact Before compaction manual, auto Yes
PostCompact After compaction manual, auto No
PreModelSwitch Before a model switch model name Yes
PostModelSwitch After the model changed model name No
Elicitation MCP server asks for input server name Yes
ElicitationResult You answered an elicitation server name Yes
SessionEnd Session ends exit reason No

Most setups need five: PreToolUse, PostToolUse, Notification, Stop and SessionStart. Lists of 9, 23 or 30 events in older guides miss newer ones such as DirectoryAdded and the model-switch events.

Hook input: the JSON every hook gets on stdin

Command hooks get one JSON object on stdin; HTTP hooks get it as the POST body. The common input fields are session_id, transcript_path, cwd, hook_event_name, usually permission_mode, and agent_id / agent_type inside subagents. A PreToolUse hook for Bash receives:

{
  "session_id": "abc123",
  "prompt_id": "550e8400-e29b-41d4-a716-446655440000",
  "transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
  "cwd": "/home/user/my-project",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test",
    "description": "Run test suite",
    "timeout": 120000,
    "run_in_background": false
  },
  "tool_use_id": "toolu_01ABC123..."
}

The event-specific fields you'll use most:

Event Extra fields
PreToolUse tool_name, tool_input, tool_use_id (plus mcp_server for MCP tools)
PostToolUse same, plus tool_response and duration_ms
UserPromptSubmit prompt
Notification message, title, notification_type
Stop stop_hook_active, last_assistant_message, background_tasks, session_crons
SessionStart source, sometimes model
SessionEnd reason
PreCompact trigger, custom_instructions

tool_input depends on the tool: Bash has command, Edit has file_path, old_string and new_string, Write has file_path and content. File paths are always absolute (with backslashes on Windows). There's no $CLAUDE_MODEL variable.

Exit codes and JSON output: blocking, allowing and adding context

A hook talks back two ways (exit code output). Pick one per hook.

Exit code Meaning What happens
0 Success Any JSON on stdout is applied. Plain stdout becomes context on UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch, and guides the summary on PreCompact; elsewhere it only goes to the debug log
2 Blocking error On events that can block, the action stops and stderr becomes the reason
Anything else Non-blocking error The action goes ahead and you see a hook error notice with the first line of stderr

For finer control, exit 0 and print one JSON object. Universal fields: continue: false plus stopReason stops Claude, systemMessage warns the user, terminalSequence emits a desktop notification. Event fields go at the top level (decision: "block" and reason on UserPromptSubmit, PostToolUse, Stop and others) or inside hookSpecificOutput with hookEventName.

additionalContext inside hookSpecificOutput adds text to Claude's context as a system reminder. Write facts ("This repo uses bun test"), not orders, which can trip prompt-injection defenses.

{
  "hookSpecificOutput": {
    "hookEventName": "PostToolUse",
    "additionalContext": "This file is generated. Edit src/schema.ts and run bun generate instead."
  }
}

How a PreToolUse hook blocks, allows or rewrites a tool call

The PreToolUse hook is the most powerful one, because it runs before anything happens. Its decision goes in hookSpecificOutput (PreToolUse decision control):

Field Values
permissionDecision "allow" skips the prompt, "deny" blocks, "ask" shows the permission prompt, "defer" pauses the call (only in -p mode)
permissionDecisionReason Shown to Claude on deny, to you on ask
updatedInput Replaces the whole tool input before it runs
additionalContext Text added next to the tool result

When hooks disagree, the strictest wins: deny, defer, ask, allow. A deny holds even in bypassPermissions mode; an allow never overrides your deny rules. Exit 0 with no output is not an approval. The old top-level decision: "approve" is deprecated here.

Matchers and the if field

The matcher decides which matcher group runs (matcher patterns):

  • "*", "" or no matcher: every occurrence.
  • Only letters, digits, _, -, spaces, , and |: exact names. Edit|Write and Edit, Write both match those two tools only.
  • Anything else: an unanchored JavaScript regex. Edit.* also matches NotebookEdit; use ^Edit$ for an exact match.

Matchers are case-sensitive. MCP tools are named mcp__<server>__<tool>; match a server with mcp__github__.* (without .* it matches nothing).

On tool events, if narrows one handler with permission rule syntax: "if": "Bash(git *)" runs only when a subcommand matches git *, "if": "Edit(*.ts)" only for TypeScript files. One rule per if.

Hook types: command, HTTP, MCP tool, prompt and agent

Every handler has a type (handler fields):

Type What runs Default timeout Good for
command A shell command or executable; JSON on stdin 600 s Almost everything
http POSTs the JSON to url; reads JSON back 600 s Shared team audit or policy services
mcp_tool Calls a tool on a configured MCP server 600 s Reusing an existing MCP server as a checker
prompt One model call returns {"ok": true} or {"ok": false, "reason": "..."} 30 s Judgment calls on the hook input
agent A subagent with Read, Grep and Glob, up to 50 turns (experimental) 60 s Verifying against real files

Command hooks run in shell form (sh -c, or Git Bash on Windows) unless you add args, which switches to exec form and spawns the executable with no shell. Use exec form for commands with a path placeholder like ${CLAUDE_PROJECT_DIR}, and shell form when you need pipes, redirects or ~. Other command fields: async: true runs in the background, asyncRewake: true also wakes Claude on exit 2, and shell: "powershell" picks PowerShell. Every handler accepts timeout (seconds) and statusMessage (spinner text).

Not every event takes every type: SessionStart and Setup accept only command and mcp_tool, and side-effect events like Notification and SessionEnd reject prompt and agent. An HTTP hook, with headers that may only use allowlisted variables:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "http",
            "url": "http://localhost:8080/hooks/pre-tool-use",
            "timeout": 30,
            "headers": { "Authorization": "Bearer $MY_TOKEN" },
            "allowedEnvVars": ["MY_TOKEN"]
          }
        ]
      }
    ]
  }
}

Environment variables and timeouts

These are available to hook commands (reference scripts by path):

Variable What it holds
CLAUDE_PROJECT_DIR Project root where the session started (read cwd from the input for Claude's current folder)
CLAUDE_PLUGIN_ROOT The plugin's install folder (plugin hooks)
CLAUDE_PLUGIN_DATA The plugin's persistent data folder
CLAUDE_ENV_FILE File whose export lines run before each Bash command. Only in SessionStart, Setup, CwdChanged and FileChanged
CLAUDE_CODE_REMOTE "true" in cloud sessions, unset locally
CLAUDE_EFFORT The current effort level

CLAUDE_CODE_STOP_HOOK_BLOCK_CAP raises the 8-continuation cap on Stop hooks, and CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS raises the 1.5-second SessionEnd budget. UserPromptSubmit and the model-switch hooks default to 30 seconds, MessageDisplay to 10. A timed-out hook's output is discarded, so a stalled PreToolUse guard doesn't block unless you set onFailure: "block".

Claude Code hooks examples: 11 recipes that work

Each recipe has the settings JSON and the script path. The Bash scripts need jq; run chmod +x on every script.

1. Claude Code hook notification: get alerted when Claude needs input

The Notification event fires on permission_prompt (a permission dialog has waited about six seconds) and idle_prompt (Claude finished about 60 seconds ago and you haven't typed). This script works on macOS and Linux and shows Claude Code's own message. Save it as ~/.claude/hooks/notify.sh:

#!/bin/bash
# ~/.claude/hooks/notify.sh
INPUT=$(cat)
MSG=$(jq -r '.message // "Claude Code needs your attention"' <<<"$INPUT")
TITLE=$(jq -r '.title // "Claude Code"' <<<"$INPUT")

case "$(uname -s)" in
  Darwin)
    osascript -e 'on run argv' \
      -e 'display notification (item 1 of argv) with title (item 2 of argv) sound name "Glass"' \
      -e 'end run' "$MSG" "$TITLE" ;;
  Linux)
    notify-send "$TITLE" "$MSG" ;;
esac
exit 0

Register it in ~/.claude/settings.json so it covers every project:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [{ "type": "command", "command": "~/.claude/hooks/notify.sh" }]
      }
    ]
  }
}

Prefer a native terminal notification? Hooks can't write to /dev/tty, but they can return terminalSequence and Claude Code emits it for you. This version uses OSC 777 (Ghostty, Warp, urxvt); iTerm2 and WezTerm use OSC 9, Kitty OSC 99:

#!/bin/bash
# ~/.claude/hooks/notify-terminal.sh
body=$(jq -r '.message // "Needs your attention"')
seq=$(printf '\033]777;notify;%s;%s\007' "Claude Code" "$body")
jq -nc --arg seq "$seq" '{terminalSequence: $seq}'

2. Auto-format after every edit (Prettier and ruff)

Run the right formatter on each file Claude writes. Save as .claude/hooks/format.sh:

#!/bin/bash
# .claude/hooks/format.sh
FILE=$(jq -r '.tool_input.file_path // empty')
[ -z "$FILE" ] && exit 0

case "$FILE" in
  *.ts|*.tsx|*.js|*.jsx|*.mjs|*.json|*.css|*.scss|*.md|*.html|*.yml|*.yaml)
    npx prettier --write "$FILE" >/dev/null 2>&1 ;;
  *.py)
    ruff format "$FILE" >/dev/null 2>&1
    ruff check --fix "$FILE" >/dev/null 2>&1 ;;
  *.go)
    gofmt -w "$FILE" >/dev/null 2>&1 ;;
esac
exit 0
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh",
            "args": [],
            "statusMessage": "Formatting"
          }
        ]
      }
    ]
  }
}

The script always exits 0, so a formatter error never interrupts Claude. Files rewritten by Bash don't trigger Edit|Write; use a FileChanged hook if that matters.

3. Block edits to .env and protected paths

Save as .claude/hooks/protect-files.sh. It blocks .env and .env.* (but not .env.example) plus anything in .git/, secrets/ or your own hooks folder:

#!/bin/bash
# .claude/hooks/protect-files.sh
FILE=$(jq -r '.tool_input.file_path // empty')
FILE="${FILE//\\//}"   # turn Windows backslashes into forward slashes
NAME=$(basename "$FILE")

case "$NAME" in
  .env.example) ;;     # the template is fine to edit
  .env|.env.*)
    echo "Blocked: $NAME holds secrets. Tell the user what to change instead." >&2
    exit 2 ;;
esac

for dir in "/.git/" "/secrets/" "/.claude/hooks/"; do
  if [[ "$FILE" == *"$dir"* ]]; then
    echo "Blocked: $FILE is inside a protected path ($dir)." >&2
    exit 2
  fi
done
exit 0
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write|NotebookEdit",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.sh",
            "args": [],
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

Claude gets the Blocked: line as feedback and changes course. To block reading .env too, add "permissions": {"deny": ["Read(./.env)", "Read(./.env.*)"]}, which also covers @-mentioned files that no PreToolUse hook sees.

4. Block dangerous Bash commands

This PreToolUse hook inspects the full command, denies the destructive ones and asks you about risky ones. It's Python, so no jq needed. Save as .claude/hooks/block-dangerous.py:

#!/usr/bin/env python3
# .claude/hooks/block-dangerous.py
import json
import re
import sys

data = json.load(sys.stdin)
cmd = data.get("tool_input", {}).get("command", "")

DENY = [
    (r"\brm\s+-\S*[rR]\S*\s+(/|~/?|\$HOME/?)(\s|$|[;&|])", "recursive delete of / or your home folder"),
    (r"\bgit\s+push\b.*(--force\b|\s-f\b).*\b(main|master)\b", "force push to main"),
    (r"\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z)?sh\b", "piping a download into a shell"),
    (r"\b(drop\s+(table|database)|truncate\s+table)\b", "destructive SQL"),
]
ASK = [
    (r"\bgit\s+reset\s+--hard\b", "git reset --hard throws away local changes"),
    (r"\bgit\s+clean\s+-\S*f", "git clean deletes untracked files"),
]

def decide(decision, reason):
    print(json.dumps({
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": decision,
            "permissionDecisionReason": reason,
        }
    }))
    sys.exit(0)

for pattern, why in DENY:
    if re.search(pattern, cmd, re.IGNORECASE):
        decide("deny", f"Blocked by project hook: {why}. Find a safer way or ask the user.")
for pattern, why in ASK:
    if re.search(pattern, cmd, re.IGNORECASE):
        decide("ask", why)
sys.exit(0)  # no decision: normal permission rules apply
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/block-dangerous.py"],
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

Tested: rm -rf /, rm -rf ~, git push -f origin main, curl ... | bash and DROP TABLE are denied, git reset --hard asks, rm -rf build and npm test pass. On Windows, match Bash|PowerShell.

5. Log every Bash command

An audit trail of what ran, as JSON Lines, across all projects. Put this in ~/.claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -c '{ts: (now|todate), ok: true, cwd: .cwd, session: .session_id, cmd: .tool_input.command}' >> ~/.claude/bash-log.jsonl"
          }
        ]
      }
    ],
    "PostToolUseFailure": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -c '{ts: (now|todate), ok: false, cwd: .cwd, session: .session_id, cmd: .tool_input.command}' >> ~/.claude/bash-log.jsonl"
          }
        ]
      }
    ]
  }
}

Read it with tail -f ~/.claude/bash-log.jsonl | jq .. Commands a hook denied never reach either event; log those from PreToolUse instead if you want attempts too.

6. Run tests before Claude stops

A Stop hook that exits 2 sends Claude back to work with your stderr as its instructions. Save as .claude/hooks/tests-on-stop.sh:

#!/bin/bash
# .claude/hooks/tests-on-stop.sh
INPUT=$(cat)

# Already sent back once by a Stop hook: let Claude stop, avoid loops.
if [ "$(jq -r '.stop_hook_active' <<<"$INPUT")" = "true" ]; then
  exit 0
fi

cd "$CLAUDE_PROJECT_DIR" || exit 0
# Nothing changed this turn: skip the test run.
[ -z "$(git status --porcelain 2>/dev/null)" ] && exit 0

if ! OUTPUT=$(npm test --silent 2>&1); then
  echo "Tests fail. Fix them before finishing. Last lines of output:" >&2
  echo "$OUTPUT" | tail -n 40 >&2
  exit 2
fi
exit 0
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/tests-on-stop.sh",
            "args": [],
            "timeout": 300,
            "statusMessage": "Running tests"
          }
        ]
      }
    ]
  }
}

Stop fires after every reply, not only at task end, hence the clean-tree check. For feedback during work, run tests from a PostToolUse hook with "async": true.

7. Inject git context at SessionStart

Plain stdout from SessionStart becomes context before your first prompt, and it reruns after /clear and compaction. Save as .claude/hooks/session-context.sh:

#!/bin/bash
# .claude/hooks/session-context.sh
cd "$CLAUDE_PROJECT_DIR" || exit 0
echo "Current git branch: $(git branch --show-current 2>/dev/null)"
echo "Uncommitted files:"
git status --porcelain 2>/dev/null | head -20
echo "Last 5 commits:"
git log --oneline -5 2>/dev/null
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume|clear|compact",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/session-context.sh", "args": [] }
        ]
      }
    ]
  }
}

SessionStart runs on every session, so keep it fast. Static rules belong in CLAUDE.md, not here.

8. Load direnv variables with CLAUDE_ENV_FILE

Claude's Bash tool doesn't pick up direnv on its own. Write the exports to CLAUDE_ENV_FILE at start and on every directory change. In ~/.claude/settings.json:

{
  "hooks": {
    "SessionStart": [
      { "hooks": [{ "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" }] }
    ],
    "CwdChanged": [
      { "hooks": [{ "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" }] }
    ]
  }
}

Run direnv allow once in each folder with an .envrc. The same pattern works with devbox shellenv.

9. Stop secrets from going into a prompt

A UserPromptSubmit hook can reject a prompt before Claude sees it. Save as ~/.claude/hooks/no-secrets-in-prompt.sh:

#!/bin/bash
# ~/.claude/hooks/no-secrets-in-prompt.sh
PROMPT=$(jq -r '.prompt // empty')
PATTERN='(sk-ant-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|-----BEGIN [A-Z ]*PRIVATE KEY-----)'

if grep -qE -- "$PATTERN" <<<"$PROMPT"; then
  jq -n '{
    decision: "block",
    reason: "That prompt looks like it contains a secret. Remove it and send again.",
    hookSpecificOutput: { hookEventName: "UserPromptSubmit", suppressOriginalPrompt: true }
  }'
fi
exit 0
{
  "hooks": {
    "UserPromptSubmit": [
      { "hooks": [{ "type": "command", "command": "~/.claude/hooks/no-secrets-in-prompt.sh" }] }
    ]
  }
}

suppressOriginalPrompt keeps the secret out of the block message. UserPromptSubmit has no matcher and a 30-second default timeout, so keep it quick.

10. Auto-approve the plan prompt

PermissionRequest hooks answer permission dialogs for you. This one approves ExitPlanMode so you aren't asked every time a plan is ready. ExitPlanMode needs updatedInput alongside allow, so the hook echoes the input back. In ~/.claude/settings.json:

{
  "hooks": {
    "PermissionRequest": [
      {
        "matcher": "ExitPlanMode",
        "hooks": [
          {
            "type": "command",
            "command": "jq -c '{hookSpecificOutput: {hookEventName: \"PermissionRequest\", decision: {behavior: \"allow\", updatedInput: .tool_input}}}'"
          }
        ]
      }
    ]
  }
}

Keep the matcher narrow. An empty matcher here would approve every permission prompt, file writes and shell commands included.

11. Ask a model whether Claude is really done

A prompt hook needs no script. On Stop, {"ok": false} sends the reason back to Claude as its next instruction:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "You review whether Claude should stop. Context: $ARGUMENTS. If every task the user asked for is complete and no errors are left unaddressed, respond {\"ok\": true}. Otherwise respond {\"ok\": false, \"reason\": \"what is left to do\"}.",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

Each check is a small model request (Claude API prices); set model to choose it. The built-in /goal command does a session-scoped version.

Security: what to check before you add a hook

Command hooks run with your full user permissions, and a hook in a cloned repo's .claude/settings.json is code you didn't write (security considerations).

  • Interactive sessions hold back all settings-file hooks until you accept the folder's workspace trust dialog.
  • -p and SDK runs have no dialog, so a repo's committed hooks run immediately. Before scripting claude -p over someone else's repo, read its .claude/ folder, use --bare, or pass --settings '{"disableAllHooks": true}'.
  • Quote variables ("$FILE"), reject .. in paths, use ${CLAUDE_PROJECT_DIR} instead of relative paths.
  • Admins can set allowManagedHooksOnly so only managed and force-enabled plugin hooks run.

Debugging Claude Code hooks

First check that /hooks lists the hook under the right event and that the JSON is valid (no trailing commas or comments). Then run the script by hand with sample input:

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | python3 .claude/hooks/block-dangerous.py
echo '{"tool_input":{"file_path":"/repo/.env"}}' | .claude/hooks/protect-files.sh; echo "exit: $?"

Then turn on the debug log (debug hooks). claude --debug writes to ~/.claude/debug/<session-id>.txt (not the terminal), claude --debug-file /tmp/claude.log picks the path, and /debug turns logging on mid-session. Search for Hooks: lines, which show each hook's exit status and duration. CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose adds matcher details, and Ctrl+O shows blocks and hook error notices in the transcript.

The mistakes that cause most "my hook doesn't work" posts:

Symptom Cause Fix
Hook never fires Matcher case or spelling (bash, mcp__github) Use Bash, mcp__github__.*; check /hooks
Guard lets everything through Script exits 1, or path is wrong and the shell exits 127 Exit 2 to block; add "onFailure": "block"
JSON decision ignored, no error Shell profile echo printed before the JSON Wrap profile output in if [[ $- == *i* ]]
JSON decision ignored, no error permissionDecision or additionalContext at the top level Nest it in hookSpecificOutput; log shows Hook JSON output had unrecognized keys
Permission denied or not found Script not executable, or relative path chmod +x, use ${CLAUDE_PROJECT_DIR}
Claude never stops Stop hook ignores stop_hook_active Exit 0 when it's true
if hook never runs if used on a non-tool event Use if only on tool events

Where to go next

Start with the notification hook, then commit formatting and the protected-files guard in .claude/settings.json so your team gets them. New to the tool? Read how to use Claude Code, compare it in AI coding agents compared, and check Claude Code pricing before adding prompt or agent hooks that make extra model calls.

FAQ

What are Claude Code hooks?

Claude Code hooks are handlers that Claude Code runs automatically at lifecycle events such as PreToolUse, PostToolUse, Notification, Stop and SessionStart. A handler can be a shell command, an HTTP endpoint, an MCP tool, a single model prompt or a subagent. Unlike CLAUDE.md instructions, they run every time the event fires.

Where do I put Claude Code hooks?

Add a "hooks" key to a settings file: ~/.claude/settings.json for all your projects, .claude/settings.json for a project you share through git, or .claude/settings.local.json for a project only you use. Plugins use hooks/hooks.json, and skills and subagents can declare hooks in their frontmatter. Run /hooks to confirm what loaded.

What exit code must a PreToolUse hook return to block Claude from executing the tool?

Exit code 2. Claude Code blocks the tool call and gives Claude your stderr as the reason. You can also exit 0 and print JSON with hookSpecificOutput.permissionDecision set to "deny". Exit code 1 is a non-blocking error, so the tool still runs.

What's the difference between PreToolUse and PostToolUse hooks?

PreToolUse runs before the tool call and can allow, deny, ask, defer or rewrite it. PostToolUse runs after a successful call, can't undo it, and is used for formatting, logging or feeding results back to Claude. Failed calls trigger PostToolUseFailure instead.

Can Claude Code hooks cause infinite loops?

A Stop hook that always blocks keeps sending Claude back to work. Check stop_hook_active in the input and exit 0 when it's true. Claude Code also ends the turn after 8 consecutive Stop-hook continuations, a cap you can change with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP.

Do hooks run inside subagents?

Yes. Hooks from settings files, managed settings and plugins also fire for a subagent's tool calls, and the input carries agent_id and agent_type. Hooks declared in a subagent's own frontmatter run only while that subagent is active.

How do I turn off Claude Code hooks?

Set "disableAllHooks": true in a settings file, or start one run with claude --settings '{"disableAllHooks": true}'. To remove a single hook, delete its entry from the JSON; there's no per-hook toggle. Hooks from managed settings can only be disabled in managed settings.