Claude Code hooks: a practical guide
Updated 24 October 2026
Hooks are commands Claude Code runs at fixed points in a session: before a tool runs, after a file is edited, when Claude finishes, when it needs you. An instruction in CLAUDE.md is a request Claude can forget. A hook runs every time. This guide covers where hooks go, what they receive, how they answer back, and eight recipes you can paste today.
Hooks at a glance
| Question | Answer |
|---|---|
| Where do they go? | The "hooks" key in ~/.claude/settings.json, .claude/settings.json or .claude/settings.local.json |
| How are they shaped? | Event → matcher group → one or more handlers |
| What does my script get? | A JSON object on stdin |
| How do I block something? | Exit 2 and write the reason to stderr |
| How do I return a decision? | Exit 0 and print one JSON object to stdout |
| How do I see what loaded? | Type /hooks in a session |
| How do I debug one? | Start with claude --debug-file /tmp/claude.log and read the log |
1. How a hook is configured
Every hook has three levels. You pick an event, such as PostToolUse. Under it you add a matcher group that narrows when it fires, such as “only for Edit and Write”. Inside that group you list one or more handlers, the things that actually run:
.claude/settings.json{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "echo \"file changed\" >> /tmp/claude-edits.log",
"timeout": 30
}
]
}
]
}
}
A handler’s type is one of five:
command: runs a shell command. This is what you’ll use most, and what every recipe below uses.http: POSTs the same JSON to a URL and reads the decision from the response body.mcp_tool: calls a tool on an MCP server you’ve configured.prompt: sends the event to a Claude model for a yes or no judgment.agent: starts a subagent that can read files before it decides. Anthropic marks this one experimental.
A command hook without args runs through a shell: sh -c on macOS and Linux, Git Bash on Windows, or PowerShell when Git Bash isn’t installed. Add "args": [] and Claude Code runs command directly as an executable, with no shell and no quoting problems. That’s the safer choice when the path contains ${CLAUDE_PROJECT_DIR}.
2. Where hooks live
| Location | Applies to | Shared? |
|---|---|---|
~/.claude/settings.json | Every project on your machine | No |
.claude/settings.json | One project | Yes, commit it |
.claude/settings.local.json | One project, just you | No, kept out of git |
| Managed policy settings | Everyone your organization deploys it to | Admin-controlled |
Plugin hooks/hooks.json | While the plugin is enabled | Yes, ships with the plugin |
| Skill or subagent frontmatter | After the skill is invoked, or while the subagent runs | Yes, lives in the file |
A few rules that save confusion later:
- Hooks add up, they don’t override. A hook in your user file and a different one in the project file both run. All matching hooks for an event run in parallel. If the exact same handler appears in two settings files, it runs once.
- Edits are picked up automatically. Claude Code watches the settings files. If a change doesn’t show in
/hooksafter a few seconds, restart the session. - Workspace trust comes first. In an interactive session, no hook from any settings file runs until you accept the trust dialog for that folder. In
claude -pthere’s no dialog and the folder counts as trusted, so a repository’s committed hooks run straight away. - Turning them off.
"disableAllHooks": trueturns off every hook you can control. There’s no switch for a single hook, so delete its entry instead. For one run, useclaude --settings '{"disableAllHooks": true}'. - Company machines. If
/hookssaysOnly hooks from managed settings run here, your admin setallowManagedHooksOnlyand your own hooks won’t run.
3. Every hook event
This is the full list from Claude Code’s hooks reference as of October 2026. Some of the newer events need a recent version, so run claude update if one doesn’t fire. “Can block” means exit code 2 (or a JSON decision) stops or changes what happens.
| Event | Fires when | Matcher filters on | Can block? |
|---|---|---|---|
SessionStart | A session starts or resumes | startup, resume, clear, compact, fork | No, adds context |
Setup | Only with --init-only, or --init/--maintenance with -p | init, maintenance | No |
InstructionsLoaded | A CLAUDE.md or .claude/rules/*.md file loads | Load reason | No |
UserPromptSubmit | You send a prompt, before Claude sees it | None | Yes, rejects the prompt |
UserPromptExpansion | A slash command expands into a prompt | Command name | Yes |
MessageDisplay | Claude’s reply streams to the screen | None | No, can change what’s displayed |
PreToolUse | Before any tool call | Tool name | Yes |
PermissionRequest | Claude Code is about to ask you for permission | Tool name | Allow or deny through JSON only |
PermissionDenied | Auto mode denies a tool call | Tool name | No, can allow a retry |
PostToolUse | A tool call succeeded | Tool name | No, stderr goes to Claude |
PostToolUseFailure | A tool call failed | Tool name | No, stderr goes to Claude |
PostToolBatch | A batch of parallel tool calls finished | None | Yes, stops the loop |
Notification | Claude Code sends a notification | permission_prompt, idle_prompt and others | No |
SubagentStart | A subagent starts | Agent type | No |
SubagentStop | A subagent finishes | Agent type | Yes, keeps it working |
TaskCreated | A task is created with TaskCreate | None | Yes |
TaskCompleted | A task is marked complete | None | Yes |
Stop | Claude finishes responding (not on Esc) | None | Yes, keeps Claude working |
StopFailure | The turn ends on an API error, instead of Stop | Error type, such as rate_limit | No |
TeammateIdle | An agent team teammate is about to go idle | None | Yes |
ConfigChange | A settings or skills file changes mid-session | user_settings, project_settings and others | Yes, except managed policy |
CwdChanged | The working directory changes, for example after cd | None | No |
DirectoryAdded | You add a directory with /add-dir | How it was added | No |
FileChanged | A watched file changes on disk | File names to watch, such as .envrc|.env | No |
WorktreeCreate | Claude Code creates a worktree; replaces the default git behavior | None | Yes, failure stops creation |
WorktreeRemove | A worktree your WorktreeCreate hook made is removed | None | Yes |
PreCompact | Before the context is compacted | manual, auto | Yes |
PostCompact | After compaction | manual, auto | No |
PreModelSwitch | Before a model switch you asked for | Target model name | Yes |
PostModelSwitch | After the model changes | Target model name | No, adds context |
Elicitation | An MCP server asks you for input | MCP server name | Yes, declines |
ElicitationResult | You answered an MCP server, before it’s sent | MCP server name | Yes |
SessionEnd | The session ends | clear, resume, logout, prompt_input_exit, other | No |
Two timing details catch people out. Stop fires every time Claude finishes a reply, not only when a big task is done. And Notification with permission_prompt waits until you haven’t typed for about six seconds; PermissionRequest runs the moment Claude asks.
4. Matchers and the if field
How Claude Code reads a matcher depends on the characters in it:
"","*"or no matcher: matches everything.- Only letters, digits,
_,-, spaces,,and|: an exact name or a list of exact names.Edit|WriteandEdit, Writeboth match exactly those two tools. - Anything else: a JavaScript regular expression, not anchored.
Edit.*also matchesNotebookEdit; write^Edit$if you mean only Edit.
Matchers are case-sensitive, so bash never matches the Bash tool. MCP tools are named mcp__<server>__<tool>. To match a whole server, write mcp__github__.*: plain mcp__github is treated as an exact name and matches nothing. Events with no matcher support, such as Stop and UserPromptSubmit, silently ignore one.
For tool events you can narrow further per handler with if, which uses permission rule syntax and checks the arguments too. The script only starts when it matches, which saves a process per tool call:
.claude/settings.json{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git push *)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-push.sh",
"args": []
}
]
}
]
}
}
if holds exactly one rule, with no && or lists. It’s only evaluated on PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied. On any other event, a handler with if never runs. The filter is best-effort, so don’t rely on it as a security boundary.
5. What your hook receives
A command hook gets one JSON object on stdin. Every event includes session_id, transcript_path, cwd and hook_event_name, and most include permission_mode. Each event adds its own fields. A PreToolUse hook for a Bash command gets something like this:
{
"session_id": "abc123",
"transcript_path": "/home/you/.claude/projects/.../transcript.jsonl",
"cwd": "/home/you/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 fields you’ll reach for most:
tool_input.commandfor Bash, andtool_input.file_pathfor Read, Edit and Write. File paths are always absolute, with backslashes on Windows.last_assistant_messageonStopandSubagentStop: Claude’s final reply. Use it rather than reading the transcript, which can lag behind.notification_typeandmessageonNotification.agent_idandagent_typewhen the hook fires inside a subagent. Tool hooks from your settings run for subagents’ tool calls too.
Your script also gets CLAUDE_PROJECT_DIR in its environment: the folder the session started in. It doesn’t follow Claude into a worktree or after a cd; the cwd field does. Hooks run without a controlling terminal, so writing to /dev/tty fails.
6. Exit codes
| Exit code | Meaning | What happens |
|---|---|---|
| 0 | Success | Claude Code reads your stdout as JSON if it’s a single JSON object, and applies it. Plain text is logged, except on SessionStart, UserPromptSubmit, UserPromptExpansion and PostModelSwitch, where it’s added to Claude’s context |
| 2 | Blocking error | On events that can block, the action stops and your stderr becomes the reason Claude sees |
| Anything else | Non-blocking error | The action goes ahead and you see a hook error notice with the first line of stderr |
The one to remember: exit 1 does not block. It’s the normal Unix failure code, so scripts that “fail closed” with exit 1 let everything through. Use exit 2 for policy hooks.
A few events behave differently. PermissionRequest ignores exit 2 entirely; deny through JSON instead. On PostToolUse the tool has already run, so exit 2 just shows your stderr to Claude. On Stop, exit 2 sends Claude back to work with your stderr as the instruction. Notification, SessionEnd and the other side-effect events can’t block anything.
7. JSON output and decisions
For anything more than block or allow, exit 0 and print one JSON object, and nothing else, to stdout. Fields every event accepts:
continue:falsestops Claude entirely, withstopReasonshown to you.systemMessage: a warning shown to you.terminalSequence: an escape sequence Claude Code writes to the terminal for you, limited to the bell, window titles and notification sequences.
Then each event has its own decision fields. The three you’ll use:
PreToolUse: allow, deny, ask or defer{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Use the staging database, not production"
}
}
PermissionRequest: allow or deny on your behalf{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "deny",
"message": "Not during a release freeze"
}
}
}
Stop, PostToolUse, UserPromptSubmit and others{
"decision": "block",
"reason": "The build is failing. Fix it before you finish."
}
Things to know about decisions:
- When several
PreToolUsehooks disagree,denybeatsdefer, which beatsask, which beatsallow. - An
allowfrom a hook doesn’t override a deny rule in your permission settings. Adenyfrom a hook holds in every mode, including--dangerously-skip-permissions. deferonly works inclaude -p. Interactive sessions ignore it.additionalContextinsidehookSpecificOutputpasses text to Claude without blocking. Write it as plain facts (“This repo uses pnpm”); text that reads like a system command can trip Claude’s prompt-injection defenses.- Putting
permissionDecisionat the top level instead of insidehookSpecificOutputis silently ignored. So is JSON preceded by anything else on stdout.
8. Timeouts, async hooks and failures
- Default timeouts: 600 seconds for
command,httpandmcp_toolhooks, lowered to 30 onUserPromptSubmitand the model switch events and to 10 onMessageDisplay. Prompt hooks get 30, agent hooks 60. AllSessionEndhooks share 1.5 seconds unless you set a longertimeout, up to 60. Set"timeout"in seconds on any handler. - A timed-out hook decides nothing. On
PreToolUsethe tool call carries on through the normal permission flow. - Async:
"async": trueruns a command hook in the background so Claude doesn’t wait. It can’t block anything, and any JSON it prints reaches Claude on the next turn. Good for sounds, logging and slow test runs. - Fail closed: by default a hook that crashes, can’t start or times out lets the action through. On Claude Code v2.1.295 or later,
"onFailure": "block"on acommandorhttphook makes a failure block instead. Use it on hooks that guard something.
9. Security notes
- Command hooks run with your full user permissions. Read every hook before you add it, especially ones in a repository you just cloned.
- Before you script
claude -pover someone else’s repository, check its.claude/folder or pass--settings '{"disableAllHooks": true}', because-pskips the trust dialog. - Quote every variable (
"$file", not$file), and build JSON withjqrather than string concatenation. - Reference scripts by absolute path or
${CLAUDE_PROJECT_DIR}. Relative paths break as soon as Claude changes directory. - A hook is a guardrail, not a sandbox. A Bash command can reach a file your Edit hook protects. For hard limits, use permission deny rules or sandboxing too.
10. Eight recipes
The scripts use jq to read the input: brew install jq on macOS, sudo apt-get install jq on Debian and Ubuntu. Put project scripts in .claude/hooks/ and make them executable with chmod +x. If a settings file already has a "hooks" key, add the new event inside it rather than adding a second one.
Format every file Claude edits
Runs Prettier on each file after Claude writes it. It assumes Prettier is in your project’s dependencies. --ignore-unknown stops it failing on file types it can’t format, and xargs -0 keeps paths with spaces in one piece:
.claude/settings.json{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -j '.tool_input.file_path' | xargs -0 npx prettier --write --ignore-unknown"
}
]
}
]
}
}
Swap in ruff format, gofmt -w or rustfmt for other languages. A file a Bash command rewrites isn’t covered; a FileChanged hook catches those.
Block dangerous shell commands
Denies rm -rf on the root or home folder, force pushes, git reset --hard and dropping database tables, and tells Claude why. Everything else goes through the normal permission flow:
.claude/hooks/block-dangerous.sh#!/bin/bash
cmd=$(jq -r '.tool_input.command // empty')
if printf '%s' "$cmd" | grep -Eq 'rm -rf +(/|~|\$HOME)/?\*?( |$)|git push( .*)? (-f|--force)( |$)|git reset --hard|DROP (TABLE|DATABASE)'; then
jq -n --arg reason "Blocked by block-dangerous.sh: $cmd" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $reason
}
}'
fi
exit 0
.claude/settings.json{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-dangerous.sh",
"args": [],
"onFailure": "block"
}
]
}
]
}
}
Pattern matching on shell text is easy to get around (bash -c, variables, scripts Claude writes first), so treat this as a seatbelt. --force-with-lease is deliberately allowed. Drop the onFailure line on versions before v2.1.295.
Protect .env files and keys
Stops Claude’s file tools from reading or editing .env, .env.local and the like, private keys, and anything in .git/. .env.example stays editable. Exit 2 sends the message to Claude, so it asks you instead of retrying:
.claude/hooks/protect-files.sh#!/bin/bash
path=$(jq -r '.tool_input.file_path // empty')
path="${path//\\//}" # Windows backslashes to slashes
[ -z "$path" ] && exit 0
name=$(basename "$path")
case "$name" in
.env.example|.env.sample) exit 0 ;;
.env|.env.*|*.pem|*.key|id_rsa*|id_ed25519*)
echo "Blocked: $name is a protected file. Ask the user to change it." >&2
exit 2 ;;
esac
case "$path" in
*/.git/*)
echo "Blocked: don't touch files inside .git/" >&2
exit 2 ;;
esac
exit 0
.claude/settings.json{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.sh",
"args": []
}
]
}
]
}
}
This only sees Read, Edit and Write. cat .env through Bash gets past it, so pair it with deny rules such as "permissions": { "deny": ["Read(./.env)"] }.
Get a desktop alert the moment Claude needs permission
PermissionRequest fires as soon as Claude asks, with no six-second wait. A hook that prints nothing doesn’t approve or deny, so the prompt still waits for you:
~/.claude/settings.json{
"hooks": {
"PermissionRequest": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs permission\" with title \"Claude Code\"'",
"async": true
}
]
}
]
}
}
On Linux, use notify-send 'Claude Code' 'Claude Code needs permission'. More options, and why alerts go missing, are in How to get Claude Code notifications.
Run the tests before Claude says it’s done
A Stop hook that runs your tests when there are uncommitted changes. If they fail, exit 2 sends Claude back with the last 40 lines of output. The stop_hook_active check lets Claude stop on the second attempt, so a test it can’t fix doesn’t loop forever:
.claude/hooks/stop-tests.sh#!/bin/bash
input=$(cat)
# Already continuing because of this hook: let Claude stop.
[ "$(jq -r '.stop_hook_active' <<<"$input")" = "true" ] && exit 0
cd "$CLAUDE_PROJECT_DIR" || exit 0
# No uncommitted changes (or not a git repo): skip the test run.
[ -z "$(git status --porcelain 2>/dev/null)" ] && exit 0
if ! output=$(npm test 2>&1); then
echo "Tests are failing. Fix them before you finish:" >&2
printf '%s\n' "$output" | tail -n 40 >&2
exit 2
fi
exit 0
.claude/settings.json{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/stop-tests.sh",
"args": [],
"timeout": 300
}
]
}
]
}
}
Even without the check, Claude Code overrides a Stop hook after eight blocks in a row with no tool call in between. Replace npm test with whatever your project runs, and keep it fast: this runs at the end of every reply while changes are uncommitted.
Log every command Claude runs
Appends one JSON line per Bash command to a log file, with the time, session and folder. PreToolUse logs everything Claude tries, including commands you then deny; use PostToolUse to log only the ones that ran:
~/.claude/settings.json{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -c '{time: (now | todate), session: .session_id, cwd, command: .tool_input.command}' >> ~/.claude/bash-log.jsonl"
}
]
}
]
}
}
Give Claude the git state at the start of a session
Plain text a SessionStart hook prints is added to Claude’s context, so the current branch and changed files are there before your first prompt:
~/.claude/settings.json{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "git status --short --branch 2>/dev/null | head -n 30"
}
]
}
]
}
}
Play a sound when Claude finishes
~/.claude/settings.json{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "afplay /System/Library/Sounds/Glass.aiff",
"async": true
}
]
}
]
}
}
That’s macOS. Linux, Windows, WSL, different sounds per event and spoken alerts are in Play a sound when Claude Code finishes or needs you.
11. Debugging a hook
- Check it loaded. Type
/hooks. Each hook is labeled with where it came from; select one to see its full command and file. Pick All events at the bottom to browse events with nothing configured. - Run it by hand. Pipe sample JSON into the script, as in the example after this list, and check the exit code.
- Look at the transcript. Press
Ctrl+O. A successful hook shows nothing. A blocked action shows your message. A non-blocking error showshook errorwith the first line of stderr. - Read the debug log. Start Claude Code with
claude --debug-file /tmp/claude.logand runtail -f /tmp/claude.login another terminal.claude --debugwrites to~/.claude/debug/<session-id>.txtinstead and prints nothing to the terminal. Already in a session? Type/debug.
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | .claude/hooks/block-dangerous.sh
echo $?
Strings worth searching the log for:
Hook output does not start with {: something printed before your JSON, usually anechoin~/.bashrcor~/.zshrc. Wrap it inif [[ $- == *i* ]]; then … fi.Hook JSON output had unrecognized keys: a field at the wrong level.No such file or directoryor exit code 127: the script path is wrong orjqisn’t installed.Hooks:lines ending in a duration (v2.1.296 or later): how long each hook took, and whether it timed out.
Set CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose to also see how matchers were evaluated.
12. Common mistakes
- Exiting 1 to block. Only exit 2 blocks.
- A second
"hooks"key in the same file. Put every event inside one"hooks"object. - Broken JSON. Settings files don’t allow comments or trailing commas.
- Wrong case in the matcher.
bash,editandwritematch nothing. - A matcher on an event that doesn’t support one.
Stopignores it and fires anyway. - Script not executable, or referenced by a relative path. Run
chmod +xand use${CLAUDE_PROJECT_DIR}. - Expecting a Notification hook to be instant.
permission_promptwaits for about six seconds of no typing. UsePermissionRequest. - A Stop hook that always blocks. Check
stop_hook_active, or Claude keeps going until the eight-block cap. - Writing to
/dev/ttyfor a bell or a notification. Hooks have no terminal; returnterminalSequenceinstead. - Two hooks rewriting the same tool input. Hooks run in parallel and the last to finish wins, so the result is random.
- Hooks that never run in a new folder because you haven’t accepted the trust dialog yet.
See hooks at work: Agent Pets
Agent Pets is built on these same hooks. It uses Claude Code’s PermissionRequest hook, so it hears about a prompt the moment Claude asks, and lets you answer from Chrome instead of the terminal:
- Each Claude Code, Codex, OpenCode and omp session becomes a pet walking along the bottom of every page. When one needs permission, its pet bounces; click it and answer Allow, Always or Deny.
- A capsule over each pet says Thinking, Your Turn or Done, and you can read the latest reply and send the next message from any tab.
npx getagentpets init adds its hooks next to the ones you already have. It runs entirely on your computer, and the terminal prompt keeps working as usual.
Get Agent Pets — was $25, now $6Get Agent Pets — $25
Questions
Where do I put Claude Code hooks?
In the "hooks" key of a settings file: ~/.claude/settings.json for every project, .claude/settings.json to share them with a project, or .claude/settings.local.json for yourself in one project. Plugins ship theirs in hooks/hooks.json.
How do I block a command with a Claude Code hook?
Add a PreToolUse hook with the matcher Bash. Either exit with code 2 and write the reason to stderr, or exit 0 and print JSON with permissionDecision set to "deny". Exit code 1 doesn’t block anything.
Why isn’t my Claude Code hook running?
Type /hooks to see what Claude Code loaded. If your hook isn’t listed, check the file path and the JSON. If it is listed, check the matcher (it’s case-sensitive), that the script is executable, and the debug log from claude --debug.
Can a hook approve permission prompts for me?
Yes. A PermissionRequest hook can return decision.behavior "allow", and a PreToolUse hook can return permissionDecision "allow". Deny rules in your settings still win, and a hook that prints nothing approves nothing.
What’s the difference between PreToolUse and PermissionRequest?
PreToolUse runs before every matching tool call, whether or not it needs approval. PermissionRequest runs only when Claude Code is about to show you a permission prompt.
Sources: Claude Code’s documentation on the hooks reference, automating actions with hooks, settings files and precedence and permissions; the jq manual.