A Claude Code hook is a command that Claude Code runs on its own at a set point in a session: before a tool runs, after a file is edited, when Claude finishes responding. You set hooks in a settings file, and they run every time their event fires, whether Claude remembers your instructions or not. A hook can block the action it's attached to, so it's how you turn "please don't do that" into "that can't happen."
Most guides to Claude Code hooks list the nine events that existed when hooks first shipped, and none of them agree on how many there are now. As of September 30, 2026 (Claude Code 2.1.286), Anthropic's hooks reference lists 33. This guide covers all of them, the two rules that make a hook silently do nothing, and six working examples you can paste in today. Every event, field and quote below comes from Anthropic's documentation, linked where it's used.
I run several Claude Code sessions on client sites every day, and hooks are what keep them from committing to the wrong branch or stopping with the tests red. These are the ones I'd set up first.
What are Claude Code hooks?
Claude Code has a few ways to shape what Claude does. CLAUDE.md and skills are instructions that Claude reads and, usually, follows. A hook is different: it's code that Claude Code runs, outside the model. Anthropic's features overview puts the rule simply: "If a rule must hold every time, make it a hook."
A hook has three parts:
- An event: when it runs, such as
PreToolUse, before any tool call. - A matcher: which occurrences it runs on, such as only the
Bashtool. - A handler: what runs. Usually a shell command, but a hook can also call a URL, an MCP tool, or ask a model a yes-or-no question.
The command receives the event's details as JSON on standard input, and answers with an exit code and, if it needs to, JSON on standard output.
Your first hook in two minutes
Start with one that can't break anything: a desktop notification when Claude needs you. Add this to ~/.claude/settings.json (this is Anthropic's own macOS example from the hooks guide):
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}If your settings file already has content, add the "hooks" key alongside what's there. Then type /hooks in Claude Code to confirm it's listed. The /hooks menu is read-only: to change a hook, edit the JSON or ask Claude to.
If no notification appears on a Mac, Script Editor probably lacks permission to show them. Run osascript -e 'display notification "test"' once, then turn on Allow Notifications for Script Editor in System Settings > Notifications.
To be told only when Claude has gone quiet, not on every permission prompt, change the matcher to "idle_prompt", which fires when "Claude finished responding about 60 seconds ago and you haven't typed since."
Where do hooks go?
In any settings file, and in a few other places (hooks reference):
| Location | Applies to | Shared with your team? |
|---|---|---|
~/.claude/settings.json | All your projects | No |
.claude/settings.json | One project | Yes, commit it |
.claude/settings.local.json | One project | No, kept out of git |
| Managed policy settings | Your whole organization | Set by an admin |
A plugin's hooks/hooks.json | Wherever the plugin is enabled | Yes, with the plugin |
| A skill's or subagent's frontmatter | While that skill or subagent is in use | Yes, with the file |
Hooks from different files add up rather than replace each other: "user, project, and local settings add their own hooks without removing managed ones." So a hook in your personal settings still runs in a project that has its own hooks.
Which events can a hook run on?
All 33, as listed on September 30, 2026. The right-hand column says whether a hook that exits with code 2 stops the action (hooks reference).
| Event | When it runs | Exit 2 blocks it? |
|---|---|---|
SessionStart | A session begins or resumes | No |
Setup | Claude Code starts with --init-only, or --init or --maintenance in -p mode | No |
UserPromptSubmit | You submit a prompt, before Claude sees it | Yes |
UserPromptExpansion | A command you typed expands into a prompt | Yes |
PreToolUse | Before a tool call runs | Yes |
PermissionRequest | A tool call needs a permission decision | No, use its JSON decision |
PermissionDenied | Auto mode denies a tool call | No |
PostToolUse | After a tool call succeeds | No, the tool already ran |
PostToolUseFailure | After a tool call fails | No |
PostToolBatch | After a batch of parallel tool calls, before the next model call | Yes |
Notification | Claude Code sends a notification | No |
MessageDisplay | While Claude's reply is displayed | No |
SubagentStart | A subagent starts | No |
SubagentStop | A subagent finishes | Yes, it keeps working |
TaskCreated | A task is being created | Yes |
TaskCompleted | A task is being marked complete | Yes |
Stop | Claude finishes responding | Yes, Claude keeps working |
StopFailure | The turn ends on an API error | No |
TeammateIdle | An agent-team teammate is about to go idle | Yes |
InstructionsLoaded | A CLAUDE.md or .claude/rules/*.md file is loaded | No |
ConfigChange | A configuration file changes during a session | Yes |
CwdChanged | The working directory changes | No |
DirectoryAdded | A directory is added with /add-dir | No |
FileChanged | A file you've told it to watch changes | No |
WorktreeCreate | A worktree is being created | Yes, any non-zero exit |
WorktreeRemove | A worktree is being removed | Yes, any non-zero exit |
PreCompact | Before the conversation is compacted | Yes |
PostCompact | After compaction completes | No |
PreModelSwitch | Before a model switch you asked for | Yes |
PostModelSwitch | After the model changes | No |
Elicitation | An MCP server asks you for input | Yes |
ElicitationResult | After you answer an MCP server's request | Yes |
SessionEnd | A session ends | No |
The list keeps growing: DirectoryAdded arrived in version 2.1.219 in July, and PreModelSwitch and PostModelSwitch in 2.1.251 in August (changelog). Check the reference before relying on a count, including this one.
Most useful hooks use five of them. PreToolUse guards what Claude is about to do, PostToolUse tidies up after an edit, Stop checks the work before Claude hands it back, SessionStart loads context, and Notification tells you when you're needed.
How a hook is set up
Every hook follows the same shape: an event, a list of matcher groups, and a list of handlers in each:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(rm *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm.sh"
}
]
}
]
}
}The matcher is not always a regular expression. How it's read depends on the characters in it:
"*",""or no matcher at all matches everything.- Only letters, digits,
_,-, spaces,,and|means exact names:Bashmatches only the Bash tool, andEdit|Writematches either. - Anything else makes it a JavaScript regular expression, matched anywhere in the name.
Edit.*matchesNotebookEdittoo; write^Edit$for an exact match.
That catches people with MCP tools, which are named mcp__<server>__<tool>. A matcher of mcp__memory contains only exact-match characters, so it's compared as an exact string "and matches no tool." Write mcp__memory__.* to match every tool from that server.
The if field filters by arguments. A matcher sees only the tool's name; if uses permission rule syntax, like "Bash(git commit *)" or "Edit(*.ts)", to run a hook only for particular commands or files. It works on tool events only, and each handler takes one rule. Claude Code checks each part of a compound command, so Bash(git *) matches npm test && git push as well as a plain git push.
$CLAUDE_PROJECT_DIR is the project root where the session started. Use it to point at scripts, so a hook still finds them after Claude changes directory. One catch: if Claude moves into a worktree, $CLAUDE_PROJECT_DIR stays at the original root, while the cwd field in the hook's input follows Claude.
There are five handler types: command, http, mcp_tool, prompt and agent. Start with command. The docs mark agent hooks as experimental.
Exit codes: the rule that makes hooks silently fail
A command hook answers with its exit code:
- Exit 0: no objection. The action goes ahead.
- Exit 2: block. On events that can block, the action stops and whatever the script printed to standard error goes to Claude as the reason.
- Any other code: a non-blocking error. The action goes ahead anyway.
That last one is the trap. Anthropic's reference: "Without valid JSON on stdout, Claude Code treats exit code 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code. If your hook is meant to enforce a policy, use exit 2." A guard script that fails with exit 1 looks like it works and guards nothing.
For finer control, a hook can exit 0 and print JSON instead. A PreToolUse hook can answer allow, deny, ask or defer, and when several hooks disagree, "precedence is deny > defer > ask > allow." The second trap lives here: the decision has to sit inside hookSpecificOutput.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Use the staging database, not production."
}
}Put permissionDecision at the top level and "Claude Code ignores the misplaced fields without reporting an error." Run claude --debug and search the log for Hook JSON output had unrecognized keys to catch it.
Six hooks that stop real mistakes
Save scripts in .claude/hooks/, make each one executable with chmod +x, and test it by piping sample JSON into it before you trust it. The scripts use jq to read their input.
1. Keep Claude out of secrets and lockfiles
Anthropic's own example. Save this script as .claude/hooks/protect-files.sh in your project:
#!/bin/bash
# protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Normalize Windows backslash separators so the patterns below match
FILE_PATH="${FILE_PATH//\\//}"
PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
exit 2
fi
done
exit 0And register it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}This covers Claude's file tools, not a shell command that writes the same file. To keep Claude from reading .env at all, add a deny rule in your permission settings as well.
2. No commits on main
When several sessions share a repository, one commit on main can land in the middle of someone else's work. This one is mine, built from the documented pieces. Save it as .claude/hooks/no-commit-on-main.sh in your project:
#!/bin/bash
# Blocks git commit on main or master.
INPUT=$(cat)
DIR=$(echo "$INPUT" | jq -r '.cwd')
BRANCH=$(git -C "$DIR" branch --show-current 2>/dev/null)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
echo "Blocked: don't commit on $BRANCH. Create a branch or a worktree first." >&2
exit 2
fi
exit 0{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-commit-on-main.sh"
}
]
}
]
}
}The if rule means the script runs only for git commit, not for every shell command. It reads the branch from Claude's working directory, so a command that changes directory first (cd other && git commit) can slip past it. Anthropic calls if "best-effort," so pair this with a git pre-commit hook, which git runs whoever makes the commit.
3. Tests pass before Claude says it's done
A Stop hook runs when Claude finishes responding, and exit 2 sends it back to work with your message. This one is mine too. It runs the tests only when files have changed, and it uses the stop_hook_active check Anthropic recommends so it can't loop. Save it as .claude/hooks/tests-before-stop.sh in your project:
#!/bin/bash
# Sends Claude back to work when files changed and the tests fail.
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # Claude already went back once; let it stop
fi
cd "$CLAUDE_PROJECT_DIR" || exit 0
if [ -z "$(git status --porcelain 2>/dev/null)" ]; then
exit 0 # nothing changed
fi
if ! OUTPUT=$(npm test --silent 2>&1); then
echo "The tests fail. Fix them before you finish:" >&2
echo "$OUTPUT" | tail -n 20 >&2
exit 2
fi
exit 0{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tests-before-stop.sh"
}
]
}
]
}
}Swap npm test for your project's test command. Keep it fast, since it runs at the end of every reply that follows a change. Without the stop_hook_active check, Claude Code steps in "after it blocks eight times in a row without progress."
4. Format every file Claude edits
Anthropic's example, for projects that use Prettier:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}A PostToolUse hook can't undo an edit, only act after it, so it suits cleanup, not guarding.
5. Restore context after compaction
When a long conversation is compacted, details get lost. A SessionStart hook with the compact matcher runs right after, and anything it prints is added to Claude's context. Anthropic's example uses a reminder; I use the current state of the repository:
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "git status --short --branch && git log --oneline -5"
}
]
}
]
}
}6. Know when Claude needs you
The notification hook from the start of this guide, with the idle_prompt matcher, so it fires only when Claude has been waiting a minute.
Hooks and permissions
Hooks and permission rules work together, and the hooks guide spells out how:
- A hook's deny always wins. When a hook denies a tool call, the tool is blocked "even in
bypassPermissionsmode or with--dangerously-skip-permissions." Changing the permission mode doesn't get around it. - A hook's allow doesn't. "Hooks can tighten restrictions but not loosen them past what permission rules allow." A hook can't approve something a deny rule forbids.
- For a hard rule on a command, prefer a permission rule. The
iffilter is best-effort, and Anthropic says to "use the permission system rather than a hook to enforce a hard allow or deny." Use hooks for what permission rules can't express: checking the branch, running the tests, reading a file's contents.
Are Claude Code hooks safe?
A hook is a program running as you. Anthropic's warning: "Command hooks execute shell commands with your full user permissions. They can modify, delete, or access any files your user account can access. Review and test all hook commands before adding them to your configuration."
Two situations deserve care:
- A repository you cloned can bring its own hooks in
.claude/settings.json. In an interactive session, Claude Code holds back every hook, even your own, until you accept the folder's trust dialog. Read the repository's.claude/folder before you accept. - Scripted runs skip that dialog. With
claude -p, Claude Code "never shows the dialog and treats the folder as trusted, so hooks committed in a repository's.claude/settings.jsonrun in a folder you've never trusted." Before scripting Claude Code over someone else's repository, review its settings, or pass--settings '{"disableAllHooks": true}'to turn hooks off for that run, whatever the repository's settings say.
Plugins can carry hooks as well, so check a plugin's hooks/hooks.json before installing it. The Claude Code skills guide has the full checklist.
Hooks vs skills and CLAUDE.md
| Runs | Use it for | |
|---|---|---|
CLAUDE.md | Read at the start of every session | Rules and context Claude should always have |
| Skill | Loaded when relevant, or when you type /name | A procedure Claude follows when the task calls for it |
| Hook | Every time its event fires, as code | Anything that must happen without exception |
A rule in CLAUDE.md that says "never commit to main" is a request. The hook above is a guarantee. Start with instructions, and promote a rule to a hook the second time Claude breaks it. Skills can also carry their own hooks in their frontmatter, active for the rest of the session once the skill is used. The Claude Code skills guide covers skills in full.
If you run more than one agent on a repository, the Agent Setup Pack takes the commit guard further: each agent gets its own git worktree, a detector names the other sessions working there, and git itself refuses a commit in the shared checkout. It works across Claude Code, Codex and Gemini CLI. The verification skill is free if you'd like to try one of our skills first.
Troubleshooting Claude Code hooks
The hook never runs. Run /hooks and check it's listed under the right event. Matchers are case-sensitive, and mcp__server without __.* matches nothing. Check the settings file is valid JSON: no trailing commas, no comments. File edits are normally picked up automatically; if not, restart the session.
It runs but doesn't block. Check the exit code: only 2 blocks. Test the script directly:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
echo $?Its JSON is ignored. Look for decision fields at the top level instead of inside hookSpecificOutput. Or something printed before the JSON, often an echo in your shell profile: hooks run in a non-interactive shell, so wrap each such line in if [[ $- == *i* ]]; then ... fi to keep it out of hooks.
"command not found". Point at scripts with $CLAUDE_PROJECT_DIR or an absolute path, make them executable, and install jq if the script uses it.
Claude won't stop. A Stop hook that always exits 2 keeps sending Claude back. Check stop_hook_active as in example 3.
You need to see everything. claude --debug-file /tmp/claude.log writes each hook's matches, exit codes and output to that file.
Frequently asked questions
What are hooks in Claude Code?
Commands that Claude Code runs automatically at points in a session, such as before a tool call or when Claude finishes. They're set in settings files and can block the action they're attached to.
How many hook events are there?
33 on September 30, 2026, per Anthropic's hooks reference. New ones arrive every few months.
Can I turn hooks off?
Set "disableAllHooks": true in a settings file. There is no switch for a single hook: remove it from the file. Hooks set by your organization's managed settings keep running unless turned off there.
Do hooks work in claude -p and CI?
Yes, and they run without a trust prompt, which is why you should review a repository's hooks before scripting Claude Code over it.
Does Codex have hooks?
Codex has its own lifecycle hooks, which it manages with a /hooks command. This guide covers Claude Code's.




