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).
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:
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.Rule of thumb: if breaking the rule once costs real time or money, make it a hook.
- v1.0.38Hooks released.
- v1.0.41
Stopsplit intoStopandSubagentStop. - v1.0.48 to v1.0.62
PreCompact,UserPromptSubmit, theCLAUDE_PROJECT_DIRvariable andSessionStartadded. - v2.0.10
PreToolUsehooks can rewrite tool input. - v2.0.30Prompt-based Stop hooks.
- v2.1.0Hooks in skill and subagent frontmatter,
once: true. - v2.1.63HTTP hooks.
- v2.1.85The
iffield filters handlers with permission rule syntax. - v2.1.118MCP tool hooks (
mcp_tool). - v2.1.251
PreModelSwitchandPostModelSwitchevents. - v2.1.295
onFailureset 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|WriteandEdit, Writeboth match those two tools only. - Anything else: an unanchored JavaScript regex.
Edit.*also matchesNotebookEdit; 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.
-pand SDK runs have no dialog, so a repo's committed hooks run immediately. Before scriptingclaude -pover 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
allowManagedHooksOnlyso 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.