Guide · Coding agents
Claude Code hooks: a practical guide with examples
Claude Code hooks are commands that Claude Code runs automatically at fixed points: before a tool call, after a file edit, when Claude stops, when a session starts. You define them under hooks in a settings.json file. Each hook receives the event as JSON on stdin, and exit code 2 blocks the action, so a hook can enforce a rule every time.
By Tahir NazirUpdated 15 min read
On this page
- What are Claude Code hooks?
- Where do hooks go? The settings.json format
- Every Claude Code hook event, and when it fires
- Exit codes and JSON output: how a hook talks back
- 7 Claude Code hook examples you can copy
- Are Claude Code hooks safe?
- Claude Code hooks on Windows
- Debugging: why isn’t my hook running?
- Questions people ask
What are Claude Code hooks?
A hook is a handler that Claude Code runs when a lifecycle event fires. Most are shell commands, but a handler can also be an HTTP endpoint (http), a tool on an MCP server (mcp_tool), a one-shot question to a Claude model (prompt) or a subagent that checks files first (agent, still experimental).
The point is certainty. An instruction in CLAUDE.md is advice Claude usually follows; a hook runs every time its event fires, whatever Claude decides. Use CLAUDE.md for conventions, permission rules for plain allow and deny lists, and hooks for anything that needs a script.
Where do hooks go? The settings.json format
| File | Applies to | Shared? |
|---|---|---|
~/.claude/settings.json | All your projects | No |
.claude/settings.json | This project | Yes, commit it |
.claude/settings.local.json | This project | No, gitignored |
| Managed policy settings | The organisation | Set by admins |
Plugin hooks/hooks.json | While the plugin is enabled | With the plugin |
| Skill frontmatter | Rest of the session once the skill is invoked | With the skill |
| Subagent frontmatter | While that subagent runs | With the subagent |
Hooks from every level merge rather than override each other. The same handler defined in two settings files runs once.
Inside the file, hooks nest three levels deep: the event, a matcher group that filters it, and one or more handlers. Every matching handler runs, in parallel:
{
"hooks": {
"PreToolUse": [ <- the event
{
"matcher": "Edit|Write", <- which tools, sources or types
"hooks": [ <- one or more handlers
{ "type": "command", "command": "<shell command>" }
]
}
],
"Stop": [ ... ] <- more events sit beside it
}
}Claude Code picks up edits to settings files mid-session. Run /hooks to check: it’s a read-only list of every configured hook and the file it came from.
How matchers work
- Plain names:
Bashmatches only Bash;Edit|WriteandEdit, Writematch either. Matchers are case-sensitive. - Anything with other characters is an unanchored JavaScript regex:
Edit.*also matchesNotebookEdit, so write^Edit$for an exact match. - MCP tools are named
mcp__<server>__<tool>. Match a whole server withmcp__github__.*; the.*is required. - **
"*",""or no matcher** matches everything. Events without matcher support ignore the field. - `if` narrows one handler on tool events with permission-rule syntax, such as
"if": "Bash(git push *)". It’s best-effort, so don’t rely on it for security.
Every Claude Code hook event, and when it fires
There are 33 hook events. SessionStart and SessionEnd fire once per session, UserPromptSubmit and Stop once per turn, and PreToolUse and PostToolUse around every tool call.
Can stop or deny what happens next
Session start
Every turn (each prompt)
Agentic loop · every tool call · repeats
- PreToolUse
- PermissionRequestif a prompt is due
- PostToolUseorPostToolUseFailure
- PostToolBatch
- Auto mode denial
- PermissionDenied
- Agent tool
- SubagentStartSubagentStop
- Task tools
- TaskCreatedTaskCompleted
- MCP tool asks you
- ElicitationElicitationResult
After a turn
Session end
Any time, outside the loop
| Event | Fires when | A hook can |
|---|---|---|
Setup | --init-only, or -p with --init or --maintenance | One-time CI setup |
SessionStart | A session starts, resumes, clears or compacts | Add context; set env vars |
UserPromptSubmit | You submit a prompt | Block it; add context |
UserPromptExpansion | A typed command expands | Block it; add context |
PreToolUse | Before each tool call | Allow, deny or ask; edit the input |
PermissionRequest | A permission prompt is due | Allow or deny it |
PostToolUse | A tool call succeeds | Feed back; replace the output |
PostToolUseFailure | A tool call fails | Add context |
PostToolBatch | A batch of parallel calls ends | Add context; stop the loop |
PermissionDenied | Auto mode denies a call | Let Claude retry |
SubagentStart | A subagent starts | Add context to it |
SubagentStop | A subagent finishes | Keep it working |
TaskCreated | A task is created | Cancel it |
TaskCompleted | A task is marked done | Refuse it |
Elicitation | An MCP server asks you for input | Answer or decline |
ElicitationResult | You answer that request | Change or block it |
Stop | Claude finishes responding | Keep Claude working |
StopFailure | The turn ends on an API error | Log or alert |
TeammateIdle | A teammate is about to go idle | Keep it working |
PreCompact | Before compaction | Block it |
PostCompact | After compaction | Log the summary |
SessionEnd | The session ends | Clean up (1.5-second budget) |
Notification | Claude Code sends a notification | Alert you elsewhere |
MessageDisplay | Reply text streams to the screen | Change what you see |
InstructionsLoaded | A CLAUDE.md or rules file loads | Audit only |
ConfigChange | A settings or skill file changes | Block the change |
CwdChanged | The working directory changes | Reload env vars |
FileChanged | A watched file changes on disk | React; can’t block |
DirectoryAdded | /add-dir adds a directory | Prepare it |
WorktreeCreate | A worktree is created | Replace the git default |
WorktreeRemove | That worktree is removed | Clean up, or fail the removal |
PreModelSwitch | Before a model switch you asked for | Block or confirm it |
PostModelSwitch | After the model changes | Add model-specific context |
From the hooks reference, checked 2026-10-11 against Claude Code 2.1.293. `SessionStart` accepts only `command` and `mcp_tool` handlers (`mcp_tool` ones are skipped at launch); `Setup` runs only `command` handlers.
Exit codes and JSON output: how a hook talks back
- Exit 0: success. Claude Code reads any JSON on stdout. Plain text goes to the debug log, except on
UserPromptSubmit,UserPromptExpansion,SessionStartandPostModelSwitch, where Claude gets it as context. - Exit 2: a blocking error, with stderr as the reason. On
PreToolUsethe call is stopped and Claude reads why. Events that can’t block just show the message: to Claude afterPostToolUse, to you afterSessionStart. - Anything else: a non-blocking error. The action goes ahead and the transcript shows a “hook error” notice.
For finer control, exit 0 and print one JSON object, and nothing else, to stdout. PreToolUse puts its decision inside hookSpecificOutput:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Use the staging database, not production"
}
}- `permissionDecision`:
allow,deny,ask, ordeferin-pmode. When hooks disagree,denywins. Anallowskips the prompt but never overrides a deny rule. - `updatedInput` rewrites the tool’s arguments. It replaces the whole input, so include unchanged fields.
- `additionalContext` gives Claude a note beside the result. At the top level instead of inside
hookSpecificOutput, it’s silently ignored. - Other events such as
PostToolUseandStopuse a top-level"decision": "block"plusreason;PermissionRequestusesdecision.behavior. - Universal fields:
"continue": falsestops Claude,systemMessagewarns you andterminalSequencesends a desktop notification, though some events ignore the first two.
Injected context fills Claude’s context window, and each string is capped at 10,000 characters. Paste a sample into the token counter to see what a hook adds per turn.
7 Claude Code hook examples you can copy
The Bash scripts need jq. Save them under .claude/hooks/ and chmod +x them on macOS and Linux. Merge the JSON into a single hooks object, one key per event.
1. Format every file Claude edits
Prettier runs after each Edit or Write; --ignore-unknown skips file types it can’t format instead of failing:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "npx prettier --write --ignore-unknown \"$(jq -r '.tool_input.file_path')\""
}
]
}
]
}
}Don’t pipe the path through xargs, as some examples do: xargs strips backslashes, so on Windows C:\Users\…\src\a.js reached Prettier as C:Users…srca.js and failed with “No files matching the pattern were found”. The quoted form worked.
2. Block edits to protected files
This script exits 2 when Claude tries to edit .env files, anything in .git/ or secrets/, or the lockfile, and also when jq is missing, so it fails closed instead of letting every edit through. Claude reads the message and changes course:
#!/bin/bash
# .claude/hooks/protect-files.sh: block edits to protected paths. Exit 2 = block.
command -v jq >/dev/null || { echo "protect-files: jq is not installed" >&2; exit 2; }
input=$(cat)
file=$(jq -r '.tool_input.file_path // empty' <<<"$input")
file="${file//\\//}" # Windows paths arrive with backslashes
shopt -s nocasematch # .ENV and .env are the same file on Windows and macOS
case "$file" in
*/.env.example) ;; # the template is fine to edit
*/.git/*|*/secrets/*|*/.env|*/.env.*|*/package-lock.json)
echo "Blocked: $file is protected. Ask the user to change it by hand." >&2
exit 2 ;;
esac
exit 0{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}Given a Windows path to .env, it printed Blocked: C:/project/.env is protected. and exited 2; .env.example and src/index.ts exited 0. It only sees Edit and Write: a shell command like echo KEY=1 >> .env bypasses it. To catch those, the docs suggest also matching Bash|PowerShell and checking git status --porcelain, or using a FileChanged hook.
3. Run the tests after edits
With "async": true the tests run in the background while Claude keeps working, and the result reaches Claude on its next turn:
#!/bin/bash
# .claude/hooks/run-tests.sh: run the tests after a source edit, tell Claude the result.
input=$(cat)
file=$(jq -r '.tool_input.file_path // empty' <<<"$input")
case "$file" in
*.ts|*.tsx|*.js|*.jsx) ;;
*) exit 0 ;; # not a source file: nothing to do
esac
if out=$(npm test --silent 2>&1); then
msg="Tests passed after editing $file."
else
msg="Tests failed after editing $file. Last lines:
$(tail -n 30 <<<"$out")"
fi
jq -nc --arg msg "$msg" \
'{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $msg}}'{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests.sh",
"async": true
}
]
}
]
}
}Only the last 30 lines of a failure go back. An async hook can’t block; to stop Claude finishing until tests pass, run the check from a Stop hook that exits 2.
4. Get a notification when Claude is done
Stop fires whenever Claude finishes responding, and Notification with permission_prompt when an approval has waited about six seconds. The script returns a terminalSequence for Claude Code to emit: OSC 9 suits Windows Terminal, iTerm2, WezTerm and ConEmu; Ghostty, Warp and urxvt use OSC 777.
#!/bin/bash
# ~/.claude/hooks/notify.sh: ask the terminal for a desktop notification (OSC 9).
seq=$(printf '\033]9;%s\007' "${1:-Claude Code}")
jq -nc --arg seq "$seq" '{terminalSequence: $seq}'{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$HOME\"/.claude/hooks/notify.sh 'Claude Code finished'"
}
]
}
],
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "\"$HOME\"/.claude/hooks/notify.sh 'Claude Code needs your approval'"
}
]
}
]
}
}5. Keep an audit log of shell commands
Every command Claude tries to run, allowed or not, is appended to a JSON Lines file. It matches PowerShell too, because on Windows Claude may use the PowerShell tool:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|PowerShell",
"hooks": [
{
"type": "command",
"command": "jq -c '{time: (now | todate), session: .session_id, cwd, tool: .tool_name, command: .tool_input.command}' >> ~/.claude/command-audit.jsonl"
}
]
}
]
}
}{"time":"2026-10-11T09:36:29Z","session":"abc123","cwd":"C:\\proj","tool":"Bash","command":"npm test"}
{"time":"2026-10-11T09:36:30Z","session":"abc123","cwd":"C:\\proj","tool":"PowerShell","command":"Get-ChildItem"}6. Inject git context at session start
A SessionStart hook’s plain stdout becomes context. With no matcher it also runs after /clear, a resume and compaction, so the branch and recent commits stay current. Keep it fast: Claude’s first reply waits for it.
#!/bin/bash
# .claude/hooks/session-context.sh: plain stdout becomes context for Claude.
echo "Branch: $(git branch --show-current)"
echo "Uncommitted changes:"
git status --short | head -n 20
echo "Recent commits:"
git log --oneline -5{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-context.sh"
}
]
}
]
}
}7. Ask before every git push
No script needed. The if field narrows the handler to git push, and the JSON answer ask forces a confirmation prompt:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git push *)",
"command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"git push: check the branch and remote first\"}}'"
}
]
}
]
}
}Are Claude Code hooks safe?
Hooks are as safe as the commands in them. Command hooks run with your full user permissions, so treat one in someone else’s repository like a script you’re about to run.
- Trust. Interactive sessions hold back settings-file hooks until you accept the workspace trust dialog.
-pand SDK runs have no dialog, so hooks committed to a cloned repository run at once. - Off switch.
claude --settings '{"disableAllHooks": true}'turns off all but managed hooks for one run. To drop a single hook, delete its entry. - Scripts. Quote variables (
"$file"), use$CLAUDE_PROJECT_DIRinstead of relative paths, and skip.env,.git/and keys. - One-way power. A hook’s
denyblocks a call even inbypassPermissionsmode, but itsallowcan’t override a deny rule. Admins can setallowManagedHooksOnly.
Claude Code hooks on Windows
Hooks run in Git Bash when it’s installed, otherwise in PowerShell (this error means Claude Code found neither). Watch for:
- Backslash paths.
file_patharrives asC:\project\src\index.tseven in Git Bash. Normalise it, as example 2 does, before matching/src/or/.env. - No jq. Git Bash lacks it: we got
jq: command not founduntilwinget install jqlang.jq. - `Bash|PowerShell` matchers. Where the PowerShell tool is enabled, Claude routes shell commands through it, and a
Bash-only hook never fires. - `$env:CLAUDE_PROJECT_DIR` in PowerShell hooks; bare
$CLAUDE_PROJECT_DIRis an empty local variable there. Git Bash runs#!/bin/bashscripts withoutchmod.
Without Git Bash, write the hook in PowerShell. This version of example 2 needs no jq and gave the same results in Windows PowerShell 5.1. Exec form (args) runs it with no shell quoting:
# .claude/hooks/protect-files.ps1: the same rule without Git Bash or jq. Exit 2 = block.
$hook = [Console]::In.ReadToEnd() | ConvertFrom-Json
$file = $hook.tool_input.file_path -replace '\\', '/'
if ($file -match '/\.env\.example$') { exit 0 }
if ($file -match '/(\.git|secrets)/|/\.env(\..+)?$|/package-lock\.json$') {
[Console]::Error.WriteLine("Blocked: $file is protected. Ask the user to change it by hand.")
exit 2
}
exit 0{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.ps1"
]
}
]
}
]
}
}Debugging: why isn’t my hook running?
First test the script alone: echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh; echo $?. Then use Claude Code’s tools: /hooks shows whether it loaded, Ctrl+O shows blocks and “hook error” notices in the transcript, and claude --debug logs every run to ~/.claude/debug/<session-id>.txt (not the terminal). --debug-file <path> picks the path; /debug starts logging mid-session.
| Symptom | Likely cause | Fix |
|---|---|---|
Not in /hooks | Invalid JSON or wrong file | No comments or trailing commas; check the path |
| Never fires | Matcher spelling or case | Bash, not bash |
| Doesn’t block | Script exits 1 | Exit 2, or exit 0 with a JSON decision |
| JSON ignored | Shell profile prints first | Find Hook output does not start with { in the log |
| Field ignored | Field at the wrong level | Nest it in hookSpecificOutput |
| Claude never stops | Stop hook always blocks | Exit 0 when stop_hook_active is true |
Hooks that call MCP tools need the server configured first: the MCP config generator writes the entry and MCP servers in Claude Code covers connecting it. Checks that should run in their own context belong in Claude Code subagents, which can carry hooks too.
FAQ
Questions people ask
Where do I put Claude Code hooks?
Add a hooks object to a settings file: ~/.claude/settings.json for every project on your machine, .claude/settings.json in a repository to share hooks with your team, or .claude/settings.local.json for personal hooks in one project. Hooks from all levels merge. Run /hooks inside Claude Code to see which loaded and where each came from.
What is the difference between hooks and CLAUDE.md?
CLAUDE.md is context: Claude reads it and usually follows it, but nothing enforces it. A hook is code that Claude Code runs every time its event fires, whatever Claude decides. Use CLAUDE.md for conventions and explanations, and hooks for what must always happen, like formatting after an edit, or must never happen, like editing a secrets file.
How do I block a command with a Claude Code hook?
Add a PreToolUse hook with the matcher Bash (plus PowerShell on Windows). Read the command from tool_input.command on stdin; to block it, write the reason to stderr and exit with code 2, and Claude sees the reason. Or exit 0 and print JSON with permissionDecision set to deny. Exit code 1 does not block.
Do Claude Code hooks run inside subagents?
Yes. Hooks from settings files, managed policy and plugins also fire for a subagent’s tool calls, and the input carries agent_id and agent_type so a script knows which agent called. A subagent’s own frontmatter can define hooks that run only while it’s active; a Stop hook there becomes SubagentStop.
Can a hook make Claude keep working instead of stopping?
Yes. A Stop hook that exits 2, or prints {"decision": "block", "reason": "…"}, sends Claude back to work with the reason as its next instruction. Check stop_hook_active in the input to avoid a loop; Claude Code ends the turn anyway after eight blocks in a row. The built-in /goal command does this without configuration.
Do Claude Code hooks work on Windows?
Yes. Command hooks run in Git Bash when it’s installed, otherwise in PowerShell, and "shell": "powershell" picks PowerShell for one hook. File paths arrive with backslashes, Git Bash has no jq until you install it, and shell hooks should match Bash|PowerShell, because Claude may run commands through the PowerShell tool.
Try it
Tools from this guide
Keep reading