Every codebase has its small rituals – run the formatter after every edit, keep your hands off that config file, log what you ran & when. Claude Code is happy to follow these, right up until it is not. I kept adding them to CLAUDE.md & Claude kept treating them as gentle suggestions, which, to be fair, is exactly what they are. Instructions in a markdown file compete with everything else in the model’s context & on any given turn they can lose.
Hooks are the fix for that; they are not suggestions but commands set in stone which have to be run when they are set to be run without exceptions. Its a bit of your own code that Claude Code runs at a fixed point in its lifecycle, whether the model likes it or not. No persuasion, no prompt engineering, no hoping. The event fires, your script runs.
If you have written custom code in WordPress plugin or theme, you already know this pattern. For example: if you add add_action( 'save_post', ... ) then it will not ask WordPress nicely to run the callback you specified – WordPress calls it because that is what the hook system does. Claude Code hooks work the same way, except the events are things like “a tool is about to run” or “the session just started” and instead of a callback you point them at a script or CLI command.
So what does a hook actually look like?!
A hook has three pieces, nested inside each other in the JSON settings file.
First, the event – the moment in the lifecycle you care about. Second, the matcher – a filter that narrows down when the event should count for you. Third, the handler – the thing that runs.
Here is the smallest useful example. Every time Claude is about to run a shell command, append that command to a log file:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.command' >> ~/.claude/bash-log.txt"
}
]
}
]
}
}PreToolUse is the event. "Bash" is the matcher, so this only fires for shell commands & not for file edits or web fetches. The handler is a plain shell command.
The jq bit is the part worth pausing on. When a hook fires, Claude Code pipes a JSON blob describing the event to your script on stdin. For a PreToolUse event on Bash, that blob carries session_id, cwd, permission_mode, tool_name & a tool_input object holding the actual command. Your script reads stdin, picks out what it needs & gets on with its job. That is the whole contract on the way in.
Where do these live?!
You can define hooks in same settings files you already use for permissions:
~/.claude/settings.json– applies to every project on your machine.claude/settings.json– one project, & safe to commit so your whole team gets it.claude/settings.local.json– one project, git-ignored, just for you
Hooks from these files merge rather than overwrite each other, so a project hook does not wipe out your personal ones. There is also a /hooks slash command inside Claude Code that shows you everything currently registered & which file it came from. Its quite handy when you are wondering why something fired.
A hook that earns its keep
Logging is fine for learning the shape of things, but the first hook most people actually want is the formatter. I use Laravel for my projects & I have a fix command in my composer.json which runs php vendor/bin/pint --parallel --dirty. This command runs Laravel Pint on changed files which have not been staged or committed. (The --dirty flag means Pint picks up every changed file rather than just the one Claude edited, which works in a hook’s favour – if Claude touched three files in a turn, all three get formatted.) Similarly I have check:fix command defined in my package.json to fix formatting for all the Typescript files. You can swap in whatever your stack uses. Drop this into .claude/hooks/format.sh in your project:
#!/bin/bash
file=$(jq -r '.tool_input.file_path // empty')
case "$file" in
*.ts|*.tsx)
cd "$CLAUDE_PROJECT_DIR" && npm run check:fix
;;
*.php)
cd "$CLAUDE_PROJECT_DIR" && composer run fix
;;
esac
exit 0Make it executable with chmod +x .claude/hooks/format.sh, then wire it up:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh",
"args": []
}
]
}
]
}
}Two things here. Edit|Write matches either tool, so the hook fires whether Claude edited an existing file or created a new one. And ${CLAUDE_PROJECT_DIR} resolves to the project root where the session started, which saves you from hardcoding paths that break the moment Claude changes directory. Setting "args": [] alongside it means the command runs without a shell, so a path with spaces in it will not blow up on you.
Talking back to Claude
So far the hooks have run & stayed quiet. The more interesting half is what a hook can say on the way out.
The blunt instrument is the exit code. Exit 0 means “nothing to report, carry on”. Exit 2 means “block this” and on PreToolUse that stops the tool call dead. Whatever you wrote to stderr becomes the reason Claude sees, so it knows why it got refused & can try something else.
The precise instrument is JSON on stdout.
Let’s say you never want Claude touching your .env file in a project. So you’d do something like this in .claude/hooks/protect-env.sh:
#!/bin/bash
file=$(jq -r '.tool_input.file_path // empty')
if [[ "$file" == *".env"* ]]; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "The .env file is off limits. Use .env.example instead."
}
}'
fi
exit 0and then update Claude settings file in that project as this:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|Read",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-env.sh",
"args": []
}
]
}
]
}
}permissionDecision takes allow, deny or ask. Note what happens when the file is not .env – the script prints nothing & exits with 0, which is not the same as approving the call. Silence means the normal permission flow applies. A hook can veto, but it cannot rubber stamp by accident.
There is a third thing a hook can return & it can be quite useful one. additionalContext pushes a string straight into Claude’s context window:
#!/bin/bash
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
[ -z "$branch" ] && exit 0
jq -n --arg branch "$branch" '{
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: ("The current git branch is " + $branch + ".")
}
}Hang that on SessionStart & Claude knows which branch you are on before you have typed a word. The same field works on UserPromptSubmit, PostToolUse & a handful of others. Anything that changes – the branch, the deploy target, open tickets, whether the last CI run passed – belongs here rather than in CLAUDE.md, which is better kept for the rules that never move.
Write that context as statements of fact, not as orders. “This repo uses bun test” reads as information. Something phrased like a system instruction can trip Claude’s prompt injection defences & get shown to you as a warning instead of being used.
The bits that will bite you
Exit code 1 does not block anything. Unix instinct says 1 means failure, but Claude Code treats anything other than 2 as a non blocking error & carries on with the tool call. Get this wrong on a guard hook & you have a gate that quietly does nothing. Write exit 2 when you mean to block a tool call.
A hook that cannot start also fails open. Mistype the path in settings.json & you will see a small error notice in the transcript & then the tool call goes through anyway. Worth watching for on the first run.
Your stdout has to be only the JSON object. If your shell profile prints a banner or a version string on start, that noise ends up in front of your JSON & the parse fails silently.
And the important thing to keep in mind is that hooks are arbitrary code running automatically with your credentials & your environment. There is no sandbox. Anything you paste in from an article or a blog post – this one included – gets the same access you have. Read it before you use it & be very careful with hooks that arrive inside a repo you cloned from somewhere. Basically, do not blindly trust and use hooks you have not written. Always check and verify what they do before you use them.
Where to go from here
PreToolUse & PostToolUse will cover most of what you want on day one. Once those feel natural there is a long list waiting – SessionStart & SessionEnd, UserPromptSubmit, Stop for when Claude finishes a turn, SubagentStart & SubagentStop, PreCompact before the context gets squeezed, FileChanged for watching files on disk. Handlers do not have to be shell scripts either. A handler can POST to an HTTP endpoint, call a tool on a connected MCP server, or hand the decision to a small model prompt.
Start with the formatter. It takes five minutes, it removes an entire category of nagging from your CLAUDE.md. Once you have watched it fire on every single edit without being asked, the rest of the event list starts looking a lot more interesting.
The full event reference is at code.claude.com/docs/en/hooks & it is one of those docs pages worth reading from start to finish.