Requested versus enforced
Everything else you write for Claude Code is a request. CLAUDE.md rules are followed but not guaranteed. Skills run when invoked. Subagents do what their prompt says.
Hooks are different in one respect that changes where they belong: they run. A hook is a shell command, HTTP endpoint, LLM prompt, or agent bound to a lifecycle event, and it fires whether or not anyone remembered.
That gives you a clean rule for deciding what goes where:
| The rule is… | Put it in |
|---|---|
| A convention, with a reason worth reading | CLAUDE.md |
| A procedure you run sometimes | A skill |
| Mechanical and unconditional | A hook |
| Serious if missed, in an organisation | Managed settings |
A formatter that runs after every edit does not need anybody to follow a rule. Moving that class of thing out of CLAUDE.md also makes the file shorter, which makes everything left in it work better.
The shape of a hook
Hooks live in a hooks object in settings:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npm run lint:fix"
}
]
}
]
}
}
Read it as three nested levels: the event (PostToolUse), a list of matchers filtering when to fire, and for each, a list of handlers to run.
The detail that trips people up on their first hook: input arrives as JSON on stdin, not as arguments. That is why the example pipes through jq to pull out the file path.
The events you will actually use
There are a great many events — session lifecycle, subagent lifecycle, task lifecycle, config changes, compaction, worktrees, MCP elicitation. Five cover almost every practical use:
| Event | Fires | Can block |
|---|---|---|
PreToolUse |
Before a tool call executes | Yes |
PostToolUse |
After a tool call succeeds | No |
PostToolUseFailure |
After a tool call fails | No |
UserPromptSubmit |
Before Claude processes your prompt | Yes |
SessionStart |
Session begins or resumes | No |
PreToolUse is the one that provides safety, because it is the one that can stop something happening. PostToolUse is the one that provides automation.
Beyond those, worth knowing they exist: Stop and StopFailure when a turn ends, SubagentStart and SubagentStop, PreCompact and PostCompact, FileChanged for watching files on disk, and Notification for permission and idle prompts.
Matchers
What matcher filters on depends on the event. For tool events it is the tool name:
| Matcher | Matches |
|---|---|
"*" or omitted |
Everything |
Bash |
Only the Bash tool |
Edit|Write |
Either — , works as well as |
|
mcp__.*__write.* |
Regex, for anything with other characters |
Plain strings of letters, digits, underscores, hyphens and spaces are exact matches. Anything else is treated as a regular expression — which means a matcher with a stray . or * behaves differently than you may intend.
Other events match on other things: SessionStart on startup, resume, clear, compact or fork; SubagentStart on the agent type; FileChanged on filenames such as .envrc|.env.
What a hook receives
Every hook gets a common set of fields on stdin:
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/current/directory",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"agent_id": "...",
"agent_type": "..."
}
Tool events add the part you usually want:
{
"tool_name": "Bash",
"tool_input": { "command": "npm test", "description": "Run tests" },
"tool_use_id": "toolu_01ABC123...",
"permission_decision": "allow"
}
agent_id and agent_type are worth noting: a hook can tell whether it is running inside a subagent and behave differently, which is how you exempt exploration from a rule you want on the main thread.
Exit codes, and the one that blocks
This is the part to get right, because the semantics are not what most people assume.
| Exit code | Meaning |
|---|---|
| 0 | Success. Stdout starting with { is parsed as JSON output; other stdout goes to the debug log — except on UserPromptSubmit, UserPromptExpansion and SessionStart, where it reaches Claude's context. |
| 2 | Blocks the action on events that support it. The message comes from the JSON decision reason, or from stderr. |
| Anything else | Generally ignored. Only JSON output controls the decision. Invalid JSON is a non-blocking error and the action proceeds. |
Two consequences worth internalising:
Exit 1 does not block anything. A hook that fails with a normal error code lets the action through. If you want to stop something, you must exit 2.
Exit 2 cannot be overridden. Even valid JSON saying permissionDecision: "allow" loses to exit 2.
Blocking works on PreToolUse, UserPromptSubmit, UserPromptExpansion, Stop, SubagentStop, TeammateIdle, TaskCreated, TaskCompleted, ConfigChange, PreCompact, Elicitation, ElicitationResult and WorktreeCreate. Note that WorktreeCreate is the exception that blocks on any non-zero exit.
Richer control through JSON output
Rather than exiting 2, a hook can print a decision:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Writes to prod config need review"
}
}
Three fields make this more useful than a bare block:
-
permissionDecisionReason— Claude sees why, so it can adapt rather than retry the same thing. -
additionalContext— inject text into Claude's context. OnSessionStart, this is how you feed in the current branch, open incidents, or today's deploy freeze. -
updatedInput— rewrite the tool input rather than rejecting it. Available onPreToolUse. This is the advanced move: silently add--dry-run, redirect a path, cap a limit.
Four hooks worth having
1. Format after every edit
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"hooks": [{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs -r npx prettier --write"
}]
}]
}
}
Formatting becomes a property of the system rather than a rule anyone follows. Delete the corresponding line from your CLAUDE.md afterwards.
2. Block writes to files that should never change
#!/bin/bash
input=$(cat)
path=$(jq -r '.tool_input.file_path // empty' <<<"$input")
case "$path" in
*/dist/*|*/node_modules/*|*.lock|*/migrations/*)
echo "Blocked: $path is generated or immutable. Change the source." >&2
exit 2
;;
esac
exit 0
Wire it to PreToolUse with matcher Write|Edit. This is the class of rule that gets ignored as documentation and enforced perfectly as a hook.
3. Refuse dangerous shell commands
#!/bin/bash
input=$(cat)
cmd=$(jq -r '.tool_input.command // empty' <<<"$input")
if [[ "$cmd" == *"rm -rf /"* ]] || [[ "$cmd" == *"DROP DATABASE"* ]] \
|| [[ "$cmd" == *"git push --force"* ]]; then
echo "Blocked: destructive command. Run it yourself if you mean it." >&2
exit 2
fi
exit 0
Keep the list short and specific. A hook that blocks half of what you legitimately do gets disabled, which is worse than not having it.
4. Inject session context
{
"hooks": {
"SessionStart": [{
"hooks": [{
"type": "command",
"command": "echo \"Branch: $(git branch --show-current). Uncommitted: $(git status --porcelain | wc -l) files.\""
}]
}]
}
}
On SessionStart, plain stdout reaches Claude's context. Every session starts knowing where it is — without that costing a line in CLAUDE.md forever.
Handlers other than shell commands
A handler does not have to be a script. Five types exist, and two are genuinely interesting:
| Type | What it does |
|---|---|
command |
A shell command. The default and usually the right one. |
http |
POSTs the hook input to a URL. For centralised policy or audit logging. |
mcp_tool |
Calls an MCP tool, with input templating like ${tool_input.file_path}. |
prompt |
Asks a model to judge. "Evaluate whether this is safe." |
agent |
Runs a full agent to decide. |
The prompt and agent types let you write policy that needs judgement rather than pattern matching — "is this migration reversible?" is not a regex. Use them sparingly: they add latency to every matching tool call, and a slow hook makes the whole session feel slow.
Useful options on any handler: timeout, async for fire-and-forget, once to run a hook a single time per session, and statusMessage so the user sees what is happening rather than an unexplained pause.
Where hooks live
| Location | Scope | Shareable |
|---|---|---|
~/.claude/settings.json |
All your projects | No |
.claude/settings.json |
This project | Yes — commit it |
.claude/settings.local.json |
This project, you only | No, gitignored |
| Managed policy settings | Organisation-wide | Admin-controlled |
Plugin hooks/hooks.json
|
Where the plugin is enabled | Yes, bundled |
| Skill or subagent frontmatter | Session scope | Yes, in the file |
Use ${CLAUDE_PROJECT_DIR} to reference scripts relative to the project root, so a committed hook works on everyone's machine regardless of where they cloned it.
For organisations, the important row is managed policy settings: hooks defined there cannot be disabled from outside managed settings. disableAllHooks turns off everything else, but not those. That is what makes a hook an actual control rather than a strong suggestion — see Claude Code for teams and enterprise.
Debugging, and the silent failure
The characteristic hook failure is that nothing happens and nothing tells you. A matcher with a typo never fires, and a hook that never fires is indistinguishable from a hook that ran and found nothing wrong.
So test every hook the same way you should test any check: trigger it deliberately and confirm it fires. Ask Claude to edit a file your formatter hook watches. Ask it to write to a path your blocking hook should refuse. If the block does not happen, the hook is not working — do not assume it is.
Claude Code records which hooks matched, their exit codes, and their output in the debug log. That is the first place to look, and usually the only place you need to.
Two failure modes that account for most of the rest: forgetting stdin — a script reading $1 gets nothing, because input arrives as JSON on standard input — and exiting 1 expecting a block, which lets the action straight through.
Where to go next
Hooks are the enforcement layer. CLAUDE.md holds the rules and reasons — see four worked examples — skills hold procedures, subagents isolate work, plugins distribute all of it, and GitHub Actions runs it in CI. If you are new to the tool, start with getting started in the terminal.
Sources and further reading
- Claude Code: hooks reference — every event, matcher, input field, exit code and output field
- Claude Code: settings — where hooks are configured
- Claude Code: security — managed policy settings