Why hooks when you already have CLAUDE.md
The model reads CLAUDE.md and usually follows it. Usually. The Claude Code docs say plainly that it is context, not enforced configuration, and point to a PreToolUse hook for hard rules.
Mistakes here get expensive. "An AI agent deleted our production database" hit 860 points and over a thousand comments on Hacker News (2026-04-26), and in July 2025 a Replit agent wiped Jason Lemkin's database. A line saying "never touch prod" will not stop that. A hook that exits 2 on DROP or rm -rf will.
The other everyday job: run the formatter or linter after every edit without hoping the agent remembers.
Hook events: when hooks fire
There are 33 events. These cover most needs:
EventFiresTypical usePreToolUsebefore a tool callblocking, argument checksPostToolUseafter a successful callformatter, linter, testsUserPromptSubmitbefore your message is processedadd context, filter inputSessionStart / SessionEndsession start and endload state, clean upStop / SubagentStopagent or subagent finishednotifications, final checksPreCompactbefore context compactionsave what mattersInstructionsLoadedinstruction files loadedaudit what entered contextThe rest (permissions, tasks, worktrees, model switches and more) are in the hooks reference.
Example: block rm -rf with PreToolUse
In the project .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/no-rm.sh", "timeout": 30 }
]
}
]
}
}The script, .claude/hooks/no-rm.sh:
#!/bin/sh cmd=$(jq -r '.tool_input.command') case "$cmd" in *"rm -rf"*) echo "Blocked: rm -rf is not allowed here" >&2; exit 2 ;; esac exit 0
Treat it as a sketch and test it. The mechanics, per the docs: the command gets the event JSON on stdin (tool_name, tool_input, plus common fields such as cwd). Exit code 2 blocks the call and stderr goes to Claude as the reason. Exit 0 means "no decision" and the action proceeds; Claude never sees stderr from a hook that exits 0.
A matcher made of letters, digits, | and commas is an exact match: Edit|Write catches exactly those two tools. Any other character turns it into a regex, such as mcp__memory__.*.
Where hooks live and how levels merge
~/.claude/settings.jsonfor every project;.claude/settings.jsonfor the project, committed;.claude/settings.local.jsonfor you only, not in git;- organization managed settings;
hooks/hooks.jsoninside a plugin;- skill or subagent frontmatter.
Levels add up rather than override: a user hook and a project hook on the same event both run. To see what is actually configured and where it came from, type /hooks; it opens a read-only hook browser.
Handler types and timeouts
TypeWhat it doesDefault timeoutcommandshell command600 s (30 s on UserPromptSubmit)httpPOST with the event JSON as body600 smcp_toolcalls an MCP server tool600 sprompta model decides from a prompt30 sagentexperimental, an agent runs the check60 sUse command for anything you want enforced: a deterministic script does not change its mind. prompt helps when a rule is hard to express as a regex, but then you are back to model judgment.
Hooks in Codex, Cursor and Gemini CLI
All three have hooks. None reads the Claude Code format directly.
- Codex:
hooks.jsonor[hooks]tables inconfig.toml, under~/.codex/or the repo.codex/. Event names match Claude Code:PreToolUse,PostToolUse,Stop,PreCompactand others. Unmanaged hooks must be reviewed and trusted via/hooks. Docs. - Cursor:
.cursor/hooks.jsonand~/.cursor/hooks.json, camelCase events such asbeforeShellExecution,afterFileEdit,beforeMCPExecution. Docs. - Gemini CLI: a block in
settings.json, 11 events with their own names:BeforeTool,AfterTool,BeforeModel. Docs.
The handler script usually ports; the config around it has to be rewritten. Ready-made hooks are in the Hooks section.