Claude Code hooks are commands that run automatically at fixed points in a session: before a tool runs, after Claude edits a file, when Claude needs your input, when it finishes a turn. You add them under hooks in a settings file. Unlike an instruction in CLAUDE.md, which Claude may or may not follow, a hook always runs. This guide covers how hooks work, lists every event, and gives six examples you can paste, as of Claude Code in October 2026.
How a hook is configured
Each event name maps to a list of groups. A group has an optional matcher and the hooks to run:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }]
}
]
}
}The pieces:
- matcher filters what the event fires for. On tool events it is the tool name:
Bash,Edit|Write, ormcp__github__.*for one MCP server. OnNotificationit is the notification type, onSessionStarthow the session started. Some events, includingStopandUserPromptSubmit, take no matcher and always fire. - if narrows a single hook further with permission-rule syntax, such as
"if": "Bash(git *)", so the process only starts for matching calls. It works on tool events only, and holds one rule per hook. - Input arrives as JSON on stdin:
session_id,cwd,hook_event_name, plus event fields liketool_nameandtool_input. - Exit codes decide the outcome.
0means no objection.2blocks the action on events that can block, and your stderr becomes the reason Claude sees. Any other code is a non-blocking error and the action goes ahead. - JSON on stdout with exit 0 gives finer control, such as
permissionDecision: "deny"with a reason.
Where hooks live
| Location | Applies to |
|---|---|
~/.claude/settings.json | All your projects, on this machine |
.claude/settings.json | One project, shared through git |
.claude/settings.local.json | One project, only you |
| Managed policy settings | Everyone in the organization |
A plugin's hooks/hooks.json | While the plugin is enabled |
| Skill or subagent frontmatter | While that skill or subagent is in use |
Claude Code picks up edits to these files while it runs. "disableAllHooks": true turns hooks off without deleting them.
Every hook event
There are 33 events. Grouped by what they're for:
| Area | Events |
|---|---|
| Session | SessionStart, Setup, SessionEnd, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, FileChanged |
| Prompts and output | UserPromptSubmit, UserPromptExpansion, MessageDisplay |
| Tools and permissions | PreToolUse, PermissionRequest, PermissionDenied, PostToolUse, PostToolUseFailure, PostToolBatch |
| End of a turn | Stop, StopFailure, Notification |
| Subagents and teams | SubagentStart, SubagentStop, TaskCreated, TaskCompleted, TeammateIdle |
| Context and model | PreCompact, PostCompact, PreModelSwitch, PostModelSwitch |
| Worktrees | WorktreeCreate, WorktreeRemove |
| MCP input requests | Elicitation, ElicitationResult |
Most hooks people write use five of them: PreToolUse to block, PostToolUse to react to edits, Notification and Stop to know when Claude is done or waiting, and SessionStart to add context.
Six hooks worth copying
The examples use jq to read the input. Save scripts in .claude/hooks/ and run chmod +x on each.
1. Format every file Claude edits
.claude/hooks/format.sh picks a formatter by file type:
#!/bin/bash file=$(jq -r '.tool_input.file_path // empty') [ -f "$file" ] || exit 0 case "$file" in *.js|*.jsx|*.ts|*.tsx|*.css|*.json|*.md) npx prettier --write "$file" ;; *.py) ruff format "$file" ;; *.go) gofmt -w "$file" ;; *.rs) rustfmt "$file" ;; esac >/dev/null 2>&1 exit 0
Register it in .claude/settings.json. Setting args runs the script directly without a shell, so the path needs no quoting:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh", "args": [] }]
}
]
}
}Claude can also change files from the shell, which this matcher won't see. A FileChanged hook watches files whatever wrote them.
2. Block dangerous shell commands
.claude/hooks/guard-bash.sh denies a few commands and tells Claude why:
#!/bin/bash
cmd=$(jq -r '.tool_input.command // empty')
deny() {
jq -n --arg r "$1" '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
exit 0
}
grep -Eq '(^|[;&|( ])rm +-[a-zA-Z]*([rR][a-zA-Z]*f|f[a-zA-Z]*[rR])' <<<"$cmd" && deny "No rm -rf. Remove specific files instead."
grep -Eq 'git push.*(--force|(^| )-f( |$))' <<<"$cmd" && deny "No force pushes."
grep -Eiq 'drop (table|database)|terraform (apply|destroy)' <<<"$cmd" && deny "Ask the user to run this one."
exit 0{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-bash.sh", "args": [] }]
}
]
}
}To start the script only for some commands, add an if to the hook, for example "if": "Bash(git push *)". A deny from PreToolUse holds in every permission mode, including --dangerously-skip-permissions, which makes it the guardrail the skip-permissions guide recommends. Treat it as a seatbelt against mistakes, though: a determined workaround (a script file, an alias) slips past string matching. Hard limits belong in deny rules and a sandbox or container.
3. Protect secrets and lockfiles
The simplest way to block is exit 2 with a message on stderr. .claude/hooks/protect-files.sh:
#!/bin/bash
path=$(jq -r '.tool_input.file_path // empty')
case "$path" in
*.env|*.env.*|*/secrets/*|*package-lock.json|*pnpm-lock.yaml|*/.git/*)
echo "Blocked: $path is protected. Tell the user what should change instead." >&2
exit 2 ;;
esac
exit 0Register it on PreToolUse with the matcher Edit|Write, like the example above. It covers Claude's edit tools; add "deny": ["Read(./.env)"] under permissions to keep the file out of Claude's context too.
4. Get notified when Claude needs you
On Linux, notify-send shows a desktop notification when Claude waits for permission or has sat idle after finishing:
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [{ "type": "command", "command": "notify-send 'Claude Code' 'Claude Code needs you'" }]
}
]
}
}On macOS, use osascript instead. The notifications guide has macOS, ntfy and Telegram versions and a Stop hook that includes Claude's last message.
5. Don't let Claude stop with failing tests
A Stop hook that exits 2 makes Claude keep working, with your stderr as the next instruction. .claude/hooks/tests-before-stop.sh:
#!/bin/bash input=$(cat) [ "$(jq -r '.stop_hook_active' <<<"$input")" = "true" ] && exit 0 [ -z "$(git status --porcelain 2>/dev/null)" ] && exit 0 if ! out=$(npm test 2>&1); then echo "Tests are failing. Fix them before you finish:" >&2 tail -n 30 <<<"$out" >&2 exit 2 fi exit 0
{
"hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/tests-before-stop.sh", "args": [], "timeout": 300 }] }
]
}
}Stop fires after every reply, so the script skips turns where nothing changed. Keep the stop_hook_active check: it lets Claude stop after one extra pass. Without it, tests that can never pass would keep Claude going, because the cap of eight consecutive blocks resets each time Claude calls a tool.
6. Give Claude context when a session starts
Plain text a SessionStart hook prints is added to Claude's context. This one runs on startup and after compaction:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|compact",
"hooks": [{ "type": "command", "command": "git branch --show-current; git log --oneline -5; git status --short" }]
}
]
}
}For facts that never change, CLAUDE.md is the better home.
Beyond shell commands
Hooks have five types. Besides command:
promptsends the hook input to a Claude model, which answers{"ok": false, "reason": "..."}to keep Claude working. It suits checks like "is every requested task done?" The built-in/goalcommand is a shortcut for this kind of Stop hook.agentspawns a subagent with tools to verify something, such as running the tests. Experimental as of October 2026.httpPOSTs the input JSON to a URL, for example a shared audit log for a team.mcp_toolcalls a tool on a configured MCP server.
Command hooks can also run in the background. "async": true lets Claude carry on without waiting, and "asyncRewake": true wakes Claude when the background hook exits with code 2. Briefkin's Claude Code integration is built from exactly these pieces: command hooks on prompt submit, notifications, tool use, Stop and session end keep one card per session up to date on a shared board, and an asyncRewake Stop hook wakes a session when you answer its question from the board. The docs show what it installs.
Debug a hook
- Run
/hooksto confirm the hook loaded and see which file it came from. - Run the script by hand with sample input, then check the exit code:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' | .claude/hooks/guard-bash.sh
echo $?- Start Claude Code with
claude --debugand read the log at~/.claude/debug/<session-id>.txt. A shell profile that prints text before your JSON shows up there asHook output does not start with {. - Press
Ctrl+Ofor the transcript view, where blocks and hook errors appear.
Keep hooks safe
Command hooks run with your full user permissions, so read any hook before you add it. Quote variables ("$file", not $file) and reference scripts by absolute path or ${CLAUDE_PROJECT_DIR}.
Watch repositories you didn't write. In an interactive session, Claude Code holds back hooks until you accept the workspace trust dialog. A claude -p run treats the folder as trusted, so hooks committed in a cloned repo's .claude/settings.json run immediately. Review that folder first, or turn hooks off for the run:
claude -p "Summarize this repository" --settings '{"disableAllHooks": true}'Questions
Where do Claude Code hooks go?
Under a hooks key in a settings file: ~/.claude/settings.json for all your projects, .claude/settings.json for one project (commit it to share with your team), or .claude/settings.local.json for one project, just for you. Plugins, managed settings, skills and subagents can also define hooks. Run /hooks to see everything that is loaded.
What is the difference between exit code 2 and exit code 1 in a hook?
Exit 2 is a blocking error: on events that can block, such as PreToolUse and Stop, Claude Code stops the action and passes your stderr along as the reason. Any other non-zero code is a non-blocking error, so the action goes ahead. A guard script that crashes with exit 1 does not block anything.
Do hooks still run with --dangerously-skip-permissions?
Yes. PreToolUse hooks fire before the permission mode is checked, so a hook that denies a call blocks it in every mode, including bypassPermissions. Hooks can tighten what is allowed but can't loosen your deny rules.
Why is my Claude Code hook not running?
Check /hooks to confirm it loaded under the right event, that the matcher matches the tool name exactly (matchers are case-sensitive), that the script is executable, and that the settings file is valid JSON with no trailing commas. In an interactive session, hooks wait until you accept the workspace trust dialog for the folder.
How do I stop a Stop hook from looping forever?
Read stop_hook_active from the hook's input and exit 0 when it is true, which means Claude is already continuing because of a Stop hook. Claude Code also overrides a Stop hook after eight consecutive blocks, but that count resets whenever Claude calls a tool, so don't rely on it alone.