Guides
Claude Code Hooks Guide: Block, Format, Verify
Checked 2026-10-31
Loading poll...
A Claude Code hook is a command, HTTP call, MCP tool call, prompt or agent that runs automatically at a fixed point in Claude's lifecycle, whatever Claude decides. Use hooks for rules with no exceptions: format after every edit, block .env, refuse to stop until tests pass. This guide shows the config and the traps. Facts checked 31 October 2026.
What you will have at the end
- A first hook, wired and verified with
/hooks - An auto-format hook that runs after edits
- A protected-files hook that blocks edits with a reason Claude can read
- A
Stopcheck that keeps Claude working until a command passes - A way to debug a hook that does nothing
Hooks are one piece of a larger setup, which our best practices guide lays out. If you are still deciding between a hook, a skill and a CLAUDE.md line, the table in the next section settles it.
When a hook is the right tool
The memory docs say it plainly: CLAUDE.md is context, not enforced configuration, and an instruction that must run at a specific point should be a hook. Hooks run as shell commands at fixed lifecycle events and apply regardless of what Claude decides to do.
You want | Use |
|---|---|
Claude to usually prefer a style | A line in CLAUDE.md |
A repeatable procedure you trigger | A skill |
Something to happen every time, with no exceptions | A hook |
A tool or path to be off limits | A deny rule, plus a hook if you want a custom message |
The events you will actually use
Claude Code fires many events. Matching hooks run in parallel when an event fires. These are the ones that matter on day one.
Event | Fires | Can block? |
|---|---|---|
| A session begins or resumes | No; stdout is added to context |
| A prompt is submitted, before Claude sees it | Yes |
| Before a tool call runs | Yes |
| When a permission decision is needed | Yes, by deciding |
| After a tool call succeeds | Cannot undo the tool |
| Claude Code sends a notification | No |
| A subagent starts or finishes | By event |
| Claude finishes responding | Yes, keeps Claude going |
| Around context compaction | By event |
| A CLAUDE.md or rules file loads | No |
| A session ends | No |
The full list is longer, with events for file changes, worktrees, config changes, MCP elicitation and model switches. See the hooks reference for each event's input and output.
Handler types
Most hooks use "type": "command", which runs a shell command. Four other types exist.
Type | What it does | Default timeout |
|---|---|---|
| Runs a shell command | 10 minutes |
| POSTs the event data to a URL | 10 minutes |
| Calls a tool on a configured MCP server | 10 minutes |
| Single-turn LLM evaluation | 30 seconds |
| Multi-turn check with tool access (experimental) | 60 seconds |
Override a timeout with the timeout field, in seconds. Claude Code lowers the default to 30 seconds for UserPromptSubmit and the model-switch events. SessionEnd hooks share a 1.5-second budget unless your settings raise it.
Step 1: Add your first hook
Open ~/.claude/settings.json and add a Notification hook. This one pings you when Claude needs input (the command is for macOS; the docs give Linux and Windows equivalents).
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}If the file already has a hooks key, add Notification as a sibling of the existing event keys. Each event name is a key inside the one hooks object, and replacing the whole object deletes your other hooks.
Verify with /hooks, which lists every configured hook grouped by event. Then press Shift+Tab until the status bar shows manual mode, ask for something that needs permission, and switch windows. You should get the notification.
You can also ask Claude to write a hook for you by describing what you want.
Where hooks live
Location | Scope | Shareable |
|---|---|---|
| All your projects | No |
| One project | Yes, commit it |
| One project | No |
Managed policy settings | Whole organization | Yes, admin controlled |
Plugin | When the plugin is enabled | Yes |
Skill or subagent frontmatter | While that skill or subagent is active | Yes |
Set "disableAllHooks": true to turn them off. Hooks in managed settings still run unless it is also set there. The file watcher normally picks up edits made while Claude Code runs.
Step 2: Format code after every edit
PostToolUse with an Edit|Write matcher runs after Claude edits a file. This reads the edited path from the JSON on stdin and runs Prettier on it. Put it in the project's .claude/settings.json.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}It needs jq installed. Success is silent, so to test, ask Claude to add a line with single-quoted strings, then open the file. With Prettier defaults the hook rewrites them to double quotes. If a Bash command rewrites a file, PostToolUse on Edit|Write will not see it; the docs point to a FileChanged hook for that case.
Step 3: Block edits to protected files
A PreToolUse hook can stop a tool call before it runs. Claude receives your message, so it can adjust instead of retrying blindly. Save this as .claude/hooks/protect-files.sh.
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
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 0Make it executable with chmod +x .claude/hooks/protect-files.sh, then register it.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}Ask Claude to add a comment to .env. The edit is blocked and the Blocked: message goes back to Claude. Using $CLAUDE_PROJECT_DIR keeps the path right when Claude has changed directory.
Exit codes and JSON output
Hooks talk to Claude Code through stdin, stdout, stderr and exit codes. Every event passes JSON on stdin with session_id, cwd and hook_event_name, plus fields for the event. For a Bash call, tool_input.command holds the command.
Exit | Meaning |
|---|---|
0 | No objection. On |
2 | Block, with the reason on stderr. What happens depends on the event |
Anything else | Usually a non-blocking error; the action proceeds |
Exit 2 cannot block every event. For SessionStart and others it shows stderr to the user and carries on. The reference lists exit-2 behavior per event.
For finer control, exit 0 and print JSON. A PreToolUse hook can return a decision.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Use rg instead of grep for better performance"
}
}The decisions are deny, ask (show the normal prompt), allow (skip the prompt, though deny and ask rules still apply) and, in non-interactive mode, defer. Pick one approach per hook: exit 2 with stderr, or exit 0 with JSON. Other events use other shapes: PostToolUse and Stop use a top-level decision: "block", and UserPromptSubmit can add context through hookSpecificOutput.additionalContext. Nest that field correctly, or Claude Code silently ignores it.
Narrow a hook with matchers and if
Without a matcher a hook fires on every occurrence of its event. A matcher filters by tool name, and matchers are case-sensitive. The if field goes further, using permission-rule syntax, so the hook process only starts when the tool name and arguments both match.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-git-policy.sh"
}
]
}
]
}
}For Bash, each subcommand is checked, so npm test && git push matches git *. When Claude Code cannot tell which commands an input runs, it runs your hook anyway. That makes if best-effort. It is a way to save process starts, not a security filter, so use permission rules for hard limits. if works only on tool events: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied. On any other event it stops the hook from running.
Step 4: Keep Claude working until a check passes
A Stop hook fires whenever Claude finishes responding. Exit 2 refuses to let the turn end and sends stderr back as the reason.
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "pnpm tsc --noEmit 1>&2 || exit 2" }
]
}
]
}
}Two limits protect you from loops. After eight blocks in a row with no tool call between them, Claude Code overrides the next block and ends the turn, and CLAUDE_CODE_STOP_HOOK_BLOCK_CAP raises the cap. And a well-written Stop hook reads stop_hook_active from its input and exits 0 if it is already true. Stop does not fire on user interrupts, and an API error fires StopFailure instead.
Step 5: Re-inject context after compaction
Compaction can drop details. A SessionStart hook with a compact matcher runs after each compaction, and its stdout is added to Claude's context.
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use Bun, not npm. Run bun test before committing.'"
}
]
}
]
}
}Replace echo with something dynamic, like git log --oneline -5. For text you want in every session, a CLAUDE.md line is simpler.
Step 6: Add context to every prompt
UserPromptSubmit runs before Claude processes a prompt. Its stdout is added to Claude's context, and JSON with additionalContext does the same in a structured way. This is useful for facts that change day to day, such as a deploy freeze, which a static CLAUDE.md line would get out of date.
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Current branch: release-42. Deploy freeze until Friday."
}
}A small script can print that JSON after reading the branch name from git. Keep it fast. This event has a 30-second default timeout, and a slow hook delays every prompt you type.
Test a hook without Claude
You can run a hook script by hand by piping sample input into it. That is faster than asking Claude to trigger it and shows the exact exit code.
echo '{"tool_name":"Write","tool_input":{"file_path":"/repo/.env"}}' \
| .claude/hooks/protect-files.sh; echo "exit: $?"You should see the Blocked: message and exit: 2. Try a harmless path next and expect exit: 0 with no output. Do this for both cases before you commit the hook. Then watch it once inside Claude Code, because the real input has more fields than your sample.
A worked example, with figures labelled as an example: a team of ten adds the formatter and the protector. Suppose each person used to spend a minute per day fixing formatting in review comments. Ten people over twenty working days is about 200 minutes a month of review noise, and the formatter removes most of it. The point is not the number; it is that a hook costs minutes to write and removes a whole class of review comment.
Hooks and permission modes
A hook in settings cannot loosen what permission rules deny. A hook that returns deny blocks the tool in every permission mode, including bypassPermissions, because PreToolUse hooks fire before any permission-mode check. That makes hooks a fit for policy that users cannot switch off by changing modes. An allow from a hook skips the prompt but does not override deny rules, and cannot suppress certain prompts, such as reads from network paths.
Debugging
Symptom | Check |
|---|---|
Hook never runs |
|
"hook error" notice | The first stderr line is shown; run |
JSON has no effect | Shell profile output before the JSON breaks parsing; guard |
Skill hook missing from |
|
Script not executable |
|
Stop hook ends early | You hit the block cap; read |
Other limits to keep in mind: command hooks cannot trigger slash commands or tool calls, PostToolUse cannot undo what a tool already did, and several PreToolUse hooks that rewrite input do not merge. Avoid more than one rewriter per tool.
Security notes
Hooks run shell commands with your user permissions, so treat a hook file like code. Read any hook you copy from the internet before you enable it. A project hook in .claude/settings.json is committed, which means a pull request can change what runs on a teammate's machine, so review changes to that folder as carefully as a build script. Never put secrets in a hook command. Read them from the environment inside the script.
Quote your variables in scripts, and parse input with jq rather than string tricks. A file path or command string comes from the model, so it can contain spaces, quotes or odd characters. A script that builds a shell command from that text without quoting is a bug waiting for the right input.
Our take
Start with exactly two hooks: one formatter and one protector for secrets. Both are cheap and obviously right. Add a Stop check only when you have a command that returns a clear pass or fail. Hooks that guess, such as a regex trying to spot "dangerous" Bash commands, tend to be wrong in both directions.
Keep each script small, test it by piping sample JSON into it, and commit the project ones so the team shares them. Treat permission rules as the real lock and hooks as the explanation that makes Claude adjust.
Checklist
- The hook appears in
/hooksunder the intended event - The matcher is spelled to match the tool name exactly
- Scripts are executable and use
$CLAUDE_PROJECT_DIR - Blocking hooks write a reason to stderr
- A
Stophook readsstop_hook_active - You tested the allow case and the block case
- Project hooks are committed in
.claude/settings.json
Related pages
Questions
What is a Claude Code hook?
A command, HTTP call, MCP tool call, prompt or agent that Claude Code runs automatically at a fixed point in its lifecycle, such as before a tool runs or when a turn ends.
How does a hook block an action?
A command hook exits with code 2 and writes the reason to stderr, or exits 0 and prints JSON with a deny decision. On PreToolUse, a deny blocks the tool even in bypassPermissions mode.
Where do I put hooks?
In ~/.claude/settings.json for all projects, .claude/settings.json to share with a team, .claude/settings.local.json for private ones, managed settings for an organization, or in plugin, skill and subagent frontmatter.
Why is my hook not firing?
Run /hooks to confirm it is listed under the right event, and check that the matcher matches the tool name exactly, because matchers are case-sensitive.
Loading poll...