Claude Code hooks: how they work, with examples you can copy
Claude Code hooks run your commands at points in a session. The events, the settings.json format, exit codes, and six tested examples to copy into your project.
A hook is a command Claude Code runs at a fixed point in a session: before a tool call, after a file edit, when Claude stops, when it needs you. You put it in settings.json; it gets the event as JSON on stdin and answers with an exit code or JSON on stdout. Unlike an instruction in CLAUDE.md, a hook runs every time.
How a hook is written
Three levels: the event, a matcher group, and the handlers to run. This one runs a script before every Edit or Write:
{ "hooks": { "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" } ] } ] } }
- The matcher filters the event: a tool name for tool events, a source like
startuporcompactforSessionStart. Plain names separated by|or commas are an exact list; anything else is a regular expression, soEdit.*also matchesNotebookEdit. Leave it out to match everything. - The type is
commandfor a shell command;http,mcp_tool,promptandagentexist too. - The timeout is in seconds; a command hook gets 600 by default.
- CLAUDE_PROJECT_DIR holds the project root, so a script path works from any folder.
Hooks in every settings file run together: user, project, local, managed and plugins. Claude Code picks up edits to the files on its own; if a change does not show, restart the session.
Hook events
Claude Code has 33 events. The ones most hooks use:
| Event | When | Exit 2 |
|---|---|---|
SessionStart | a session starts or resumes | does not block |
UserPromptSubmit | you send a prompt | blocks it |
PreToolUse | before a tool call | blocks it |
PermissionRequest | a call needs permission | use JSON |
PostToolUse | after a tool call | tells Claude |
Notification | Claude needs you | does not block |
Stop | Claude finishes | keeps it working |
SubagentStop | a subagent finishes | keeps it working |
PreCompact | before compaction | blocks it |
SessionEnd | the session ends | does not block |
What a hook receives and returns
On stdin every hook gets session_id, transcript_path, cwd and hook_event_name, and most get permission_mode; tool events add tool_name and tool_input. Read fields with jq.
- Exit 0: carry on. Stdout is read as JSON if it is a JSON object; on
SessionStartandUserPromptSubmit, plain text is added to Claude's context. - Exit 2: block, where the event allows it. Stderr goes to Claude as the reason.
- Any other code: an error that does not block. A policy hook that exits 1 with nothing on stdout lets the action through; we tested that on 2.1.289 and again on 2.1.299. Two exceptions: if stdout is valid JSON, the exit code is ignored and the JSON decides, so a
denyprinted beforeexit 1still blocks; and from Claude Code 2.1.295,"onFailure": "block"on acommandorhttphook makes a failure (exit 1, a crash, a timeout, a missing script) block the way exit 2 does.onFailurehas no effect onStop,SubagentStop,TaskCompleted,TeammateIdleor background hooks.
For more than yes or no, print JSON. On PreToolUse, hookSpecificOutput.permissionDecision is allow, deny, ask or defer, and when several hooks answer, deny wins.
Example 1: a desktop notification when Claude needs you
In ~/.claude/settings.json, so it works in every project. Phone and Telegram alerts: Claude Code notifications. On Linux, use notify-send 'Claude Code' 'Claude Code needs your attention' instead.
{ "hooks": { "Notification": [ { "matcher": "", "hooks": [ { "type": "command", "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'" } ] } ] } }
Example 2: format every file Claude edits
A PostToolUse hook on Edit|Write runs Prettier on the file just written. Swap in gofmt -w, ruff format or your own formatter.
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" } ] } ] } }
Example 3: block edits to .env and lockfiles
Save as .claude/hooks/protect-files.sh, run chmod +x on it, and register it with the PreToolUse block at the top of this page.
#!/bin/bash # PreToolUse on Edit|Write: refuse to touch secrets and lockfiles. file=$(jq -r '.tool_input.file_path // empty') for pattern in ".env" "package-lock.json" ".git/"; do if [[ "$file" == *"$pattern"* ]]; then echo "Blocked: $file matches protected pattern '$pattern'" >&2 exit 2 fi done exit 0
Asked to write .env, Claude got the block and said so: Blocked: …/.env matches protected pattern '.env'. No file was created.
Example 4: stop force pushes, with a reason
This one answers in JSON instead of an exit code. Save it as .claude/hooks/block-force-push.sh and register it under PreToolUse with the matcher Bash.
#!/bin/bash # PreToolUse on Bash: deny force pushes, with a reason Claude reads. cmd=$(jq -r '.tool_input.command // empty') force='(--force|[[:space:]]-f([[:space:]]|$))' if [[ "$cmd" =~ git[[:space:]]+push.*$force ]]; then jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Force push is blocked. Push without --force."}}' fi exit 0
A deny from a hook holds even with --dangerously-skip-permissions, which makes this the way to keep a few rules when prompts are off; see skipping permissions.
Example 5: log every command Claude runs
{ "hooks": { "PostToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt" } ] } ] } }
Example 6: don't let Claude stop while the tests fail
A Stop hook that exits 2 sends Claude back to work with the reason. Check stop_hook_active: it is true when Claude is already continuing because of a stop hook, and without the check a test that never passes keeps it going until Claude Code's cap of eight forced continues.
#!/bin/bash # Stop: keep Claude working while the tests fail. if [ "$(jq -r '.stop_hook_active')" = "true" ]; then exit 0; fi if ! npm test >/tmp/claude-stop-tests.log 2>&1; then echo "Tests fail; see /tmp/claude-stop-tests.log. Fix them first." >&2 exit 2 fi exit 0
Register it under Stop with no matcher; Stop ignores matchers. Asked only to say hello in a folder whose test failed, Claude read the reason, made the test pass and stopped.
When a hook does not run
- Look at what loaded:
/hookslists every hook Claude Code loaded and the file it came from. It is read-only; edit the JSON to change one. - Read the debug log:
claude --debugwrites each hook run, its exit code and output to~/.claude/debug/. - Matchers are case-sensitive:
bashmatches nothing; the tool isBash. - The script is not executable, or a path has a space and is not quoted.
- An untrusted folder: in an interactive session, hooks wait until you accept the trust dialog.
- Hooks are switched off:
disableAllHooks: truein any settings file turns them all off.
Command hooks run with your user's full permissions. A repository's .claude/settings.json can carry hooks, and in -p runs the folder is treated as trusted, so read a cloned repo's .claude/ before running it headless.
Caprock is built on hooks
Caprock, the free app that records every coding agent on your machine, gets live activity through one small program it registers on ten events. In the app, when the hooks are missing, the Install hooks button on the Home screen adds them (since 0.70.1); without the app, caprock up asks before adding it. It adds its entries next to yours, backs up settings.json first, and caprock hooks uninstall removes only its own.
$ caprock hooks status settings: /Users/ds/.claude/settings.json shim: /Users/ds/Library/Application Support/caprock/caprock-hook (exists: true) installed: [SessionStart UserPromptSubmit PreToolUse PostToolUse Stop SubagentStop SessionEnd PreCompact StopFailure PermissionRequest] missing: []
The hook posts each event to Caprock on 127.0.0.1; most events return in under a second, inside a five-second timeout, so it never holds up a keystroke. If Caprock is not running, it exits quietly and the session carries on. On SessionStart it answers with the JSON output above: additionalContext saying where the previous session in this repository left off. On by default; turn it off in settings.
Every event lands in Caprock's record. On my machine, in the 24 hours to 4 October 2026:
- 6,233
- tool calls seen by PreToolUse
- 2,063
- Stop and SubagentStop events
- 215
- prompts, from UserPromptSubmit
Questions people ask
- What are Claude Code hooks?
- Commands, HTTP calls, MCP tools or prompts that Claude Code runs at fixed points in a session, such as before a tool call or when Claude finishes. They are set in settings.json under hooks.
- Where do Claude Code hooks go?
- In ~/.claude/settings.json for every project, .claude/settings.json to share with a repository, or .claude/settings.local.json for yourself. Hooks from all of them run; they merge rather than override.
- How does a hook block a tool call?
- A PreToolUse hook exits with code 2 and writes the reason to stderr, or prints JSON with permissionDecision set to deny. Claude sees the reason. Exit code 1 does not block on its own; it does when the hook prints a JSON deny, or, from Claude Code 2.1.295, when the hook sets onFailure to block.
- Do hooks run with --dangerously-skip-permissions?
- Yes. A PreToolUse hook that denies a call blocks it in every permission mode, bypassPermissions included.
- Why is my hook not running?
- Check /hooks to see what Claude Code loaded, the matcher's case and spelling, that the script is executable, and claude --debug, which writes hook runs to ~/.claude/debug/. A hook in a folder you have not trusted waits for the trust dialog.
Sources
- Claude Code docs — Hooks reference, read 20 October 2026
- Claude Code changelog — 2.1.295, onFailure for command and HTTP hooks
- Claude Code docs — Get started with hooks, read 20 October 2026
- Claude Code docs — Settings (file locations and precedence), read 20 October 2026
- Claude Code docs — Permission modes (hooks and bypass mode), read 20 October 2026
- Caprock source — the events its hook registers
- Caprock source — the hook shim
- Caprock source — the SessionStart reply
- Caprock changelog — 0.70.1, Install hooks from the dashboard
Try it
The record of every coding agent you run, the sessions it did not start included. Free, open source; nothing leaves your machine.
Notarized by Apple. One file for Apple silicon and Intel, macOS 13 or later.
Or run it yourself:
Paste in Terminal: it installs Caprock and opens it.Or with Homebrew: brew install --cask dspv/tap/caprock-app