Claude Code is genuinely useful out of the box, but the moment you want it to do something every single time — format your code after an edit, block a dangerous command, remind itself of your project's conventions — you hit a wall. You can ask it to, but "ask nicely and hope" is not automation. Hooks are how you make a behavior guaranteed instead of hoped-for.
A hook is a command that Claude Code runs automatically at a specific moment in its lifecycle. It is deterministic: the harness executes it, not the model, so it happens whether or not Claude "remembers" to. This is the difference between a preference and a rule.
Where hooks live
Hooks are configured in your settings JSON, at one of three scopes:
~/.claude/settings.json— applies to all your projects.claude/settings.json— one project, shared with your team via git.claude/settings.local.json— one project, not committed
The structure groups hooks by the event that triggers them:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}
That single block means: after any Edit or Write tool call, run Prettier on the file that was just changed. No more asking Claude to format — it is formatted, every time, by the harness.
The events worth knowing
There are a couple dozen hook events, but a handful cover the vast majority of real use:
PreToolUse— fires before a tool runs, and it can block the call. This is your safety gate.PostToolUse— fires after a tool succeeds. This is your side-effect slot: formatting, linting, logging.UserPromptSubmit— fires when you submit a prompt, before Claude sees it. Its stdout gets added to Claude's context, so you can inject information into every turn.SessionStart— fires when a session begins or resumes. Also great for injecting context; it supports amatcherofstartup,resume,clear, orcompact.Stop— fires when Claude finishes responding.
The matcher field filters which tool (or which sub-case) the hook applies to. For tool events it is a regex on the tool name — Edit|Write, or mcp__github__.* to match a whole MCP server's tools. Matchers are case-sensitive: Edit is not edit.
How a hook talks to Claude
Every hook receives a JSON payload on stdin describing what is happening:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "npm test" }
}
That is why so many hook examples pipe through jq — it is the quickest way to pull a field like .tool_input.command out of the payload.
The hook talks back through its exit code:
- Exit 0 — all good, proceed. For
UserPromptSubmitandSessionStart, anything the hook prints to stdout is injected into Claude's context. - Exit 2 — block the action. Whatever you write to stderr becomes the reason Claude sees.
- Any other code — the action proceeds, but the error is logged.
So a PreToolUse hook that exits 2 and prints "don't touch production config" to stderr will genuinely stop the tool call and tell Claude why. That is real enforcement, not a suggestion.
Three hooks worth stealing
1. Format on every edit. The PostToolUse + Prettier example above. Set it once, never think about formatting again.
2. Block dangerous commands. A PreToolUse hook on Bash that inspects .tool_input.command, and exits 2 if it matches something you never want run unattended — rm -rf, a DROP TABLE, a write to .env:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command", "command": "jq -r '.tool_input.command' | grep -qE 'rm -rf|DROP TABLE' && echo 'blocked: destructive command' >&2 && exit 2 || exit 0" } ] }
]
}
}
3. Re-inject context after compaction. When a long session compacts, Claude can lose the thread. A SessionStart hook with the compact matcher can print your project's key conventions — or the last few git commits — straight back into context, so the compacted session picks up oriented instead of amnesiac.
The gotchas that will bite you
A few things that cost people an afternoon:
PostToolUsefires after execution — you cannot undo the tool call from it. It is for side effects only. If you want to prevent something, that isPreToolUse.Stophooks fire on every response, not just at the end of a task. If you build a "keep working until X" hook, remember it runs constantly and gate it accordingly.- An unconditional
echoin your shell profile will break JSON-producing hooks. Hooks run in a non-interactive shell that still sources your profile; a strayechopollutes stdout. Wrap profile output inif [[ $- == *i* ]]; then ... fi. jqis a hidden dependency. Most examples assume it.brew install jq(orapt-get install jq) first.- Command hooks must be executable if you point at a script —
chmod +x.
Why this matters
The mental shift hooks create is from coaching your AI to constraining it. Anything you find yourself repeating — "please run the tests," "format that," "don't edit the migration files" — is a candidate to stop being a request and start being a rule the harness enforces for you. You are not making Claude smarter; you are making the environment around it reliable. And reliability, not cleverness, is usually what turns a neat demo into a tool you trust with real work.