Claude Code hooks: how to turn agent rules into guarantees
Claude Code hooks are shell commands that Claude Code itself runs on certain events: before and after a tool call, when a prompt is submitted, when a session starts, and before the agent stops. They are configured in settings.json, and exit code 2 blocks the action and returns an error message to the agent, so a hook turns a rule from CLAUDE.md into a guarantee the model cannot forget.
What Claude Code hooks are
A hook is a command that is run not by the model but by Claude Code itself at a given moment of the agent's work. The agent is about to run rm -rf — a hook fires and decides whether to let the command through. The agent edited a file — a hook runs the formatter. The agent wants to finish — a hook runs the tests and does not let it stop while they are red.
The main difference from instructions in CLAUDE.md: an instruction is text that the model takes into account with some probability, while a hook is deterministic code. It fires every time, regardless of how long the context is, what the agent "decided", or whether it forgot the rule at the hundredth minute of the session. That is why hooks are the main way to turn a wish into a guarantee.
Why rules in CLAUDE.md are not enough
A typical picture: the agent finished a feature, the unit tests are green, and the chat says confidently "Done". But nobody ran the end-to-end scenario, the linter was not run, and the migration failed halfway. CLAUDE.md says "run the tests before finishing". The model is not lying on purpose — it simply assesses itself, and that self-assessment is unreliable.
Hence the harness principle: whatever can be checked mechanically is checked by a mechanism, not by the model. The rule "run the linter" becomes a hook that runs the linter. The rule "do not touch the production config" becomes a hook that blocks writes to that file. The text instruction stays, but it only explains to the agent why it was stopped.
Events: when hooks fire
Each hook is attached to an event in the session lifecycle. The main events:
| Event | When it fires | Typical use |
|---|---|---|
PreToolUse | before a tool call (Bash, Edit, Write and others) | blocking dangerous commands, protecting files |
PostToolUse | after a tool call succeeds | formatter, linter, quick tests after an edit |
UserPromptSubmit | when the user has sent a prompt, before it is processed | adding context, rejecting a prompt that contains a secret |
Stop | when the agent is about to finish its response | not letting it stop until the checks pass |
SubagentStop | when a subagent finishes its work | checking the subagent's result |
SessionStart | when a session starts or resumes | loading state: git status, the plan, the progress journal |
SessionEnd | when a session ends | cleanup, writing logs |
Notification | when Claude Code sends a notification (for example, it is waiting for permission) | a sound or a message to your phone |
PreCompact | before the context is compacted | saving what matters before compaction |
The most useful for reliability are PreToolUse, PostToolUse and Stop: they close the three weak spots of an agent — dangerous actions, unchecked edits and premature "done".
Where hooks are configured: settings.json
Hooks are described in Claude Code settings files:
~/.claude/settings.json— your personal settings, for all projects;.claude/settings.jsonin the repository — shared by the team, committed to git;.claude/settings.local.json— local to the project, not committed to git.
The structure is: event → list of groups with a matcher → list of commands.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint.sh",
"timeout": 60
}
]
}
]
}
}
matcher is a tool name or a regular expression over tool names: Bash, Edit|Write, .*. It is needed for events tied to tools; for Stop or UserPromptSubmit it can be omitted. The CLAUDE_PROJECT_DIR variable points to the project root, so it is convenient to keep scripts in .claude/hooks/ and call them through it. You can see the current hooks with the /hooks command inside Claude Code.
How a hook receives data and responds
Input: JSON on stdin
Claude Code passes the hook a JSON object with details of the event: the session identifier, the working directory, the event name and, for tools, tool_name and tool_input. For Bash, tool_input.command holds the command; for Edit and Write, tool_input.file_path holds the file path. It is convenient to read it with jq.
{
"session_id": "abc123",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf build/" },
"cwd": "/home/dev/project"
}
Output: exit code
| Code | What happens |
|---|---|
0 | success, work continues; for UserPromptSubmit and SessionStart stdout is added to the agent's context |
2 | blocking error: stderr is passed to the agent, and the action is stopped (for PreToolUse the tool call is cancelled; for Stop the agent keeps working) |
| other | non-blocking error: stderr is shown to the user, work goes on |
Exit code 2 is the key mechanism. The agent receives the text from stderr as feedback and can correct itself: rewrite the command, fix the test, leave the file alone. So write to stderr not just "error", but the clear reason and what to do instead.
Besides the exit code, a hook can print a JSON decision to stdout, for example "decision": "block" with a "reason". This is for fine-tuning, but for most tasks exit code 2 and a message on stderr are enough.
Example 1: a linter and tests after every edit
A hook on PostToolUse with the matcher Edit|Write fires after a file has changed. The tool has already run and the edit cannot be undone, but exit code 2 returns the linter output to the agent, and it fixes the error right away instead of half an hour later.
#!/usr/bin/env bash
# .claude/hooks/lint.sh
file=$(jq -r '.tool_input.file_path // empty')
case "$file" in
*.py)
if ! out=$(ruff check "$file" 2>&1); then
echo "The linter found errors in $file:" >&2
echo "$out" >&2
exit 2
fi ;;
*.php)
if ! out=$(php -l "$file" 2>&1); then
echo "$out" >&2
exit 2
fi ;;
esac
exit 0
Do not hang a full ten-minute test run here: the hook fires on every edit. After an edit, run quick checks (syntax, linter, tests of one module), and keep the heavy ones for the Stop hook.
Example 2: blocking dangerous commands
A hook on PreToolUse with the matcher Bash sees the command before it runs. Exit code 2 cancels the call, and the agent receives an explanation.
#!/usr/bin/env bash
# .claude/hooks/guard.sh
cmd=$(jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -Eq 'rm -rf /|git push (-f|--force)|DROP (TABLE|DATABASE)'; then
echo "The command is blocked by project policy: $cmd. A human performs dangerous operations." >&2
exit 2
fi
exit 0
Files are protected the same way: a hook on Edit|Write checks file_path and blocks writes to .env, production configs, generated code or migration files that have already been applied. Note that a regular-expression search can be bypassed — the agent may write the command differently. A hook is a strong safeguard, but not a replacement for access rights and a sandbox.
Example 3: the agent cannot say "done" while the tests are red
The Stop hook fires when the agent is about to finish. If the tests fail, exit code 2 prevents it from stopping, and the test output goes to the agent as its next task. This is a mechanical version of the Definition of Done.
#!/usr/bin/env bash
# .claude/hooks/done.sh
input=$(cat)
# protection against an infinite loop: the hook has already extended the work once
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
if ! out=$(npm test --silent 2>&1); then
echo "Tests failed, the work is not finished:" >&2
echo "$out" | tail -n 40 >&2
exit 2
fi
exit 0
The stop_hook_active field is true if the agent is already continuing work because of a stop hook. Without this check, the hook can drive the agent around in circles forever — exactly the situation where the agent "works all night in the wrong direction". In autonomous loops, add a further attempt limit and a condition under which a human is called.
Example 4: load the state at the start of a session
On SessionStart, the hook's stdout goes into the context. It is useful to print the current branch, uncommitted changes and the next task from the plan, so that the agent does not start from zero.
#!/usr/bin/env bash
echo "Branch: $(git branch --show-current)"
git status --short | head -n 20
[ -f PROGRESS.md ] && tail -n 15 PROGRESS.md
exit 0
How hooks turn rules into guarantees
It is useful to split harness rules into three levels. The first is text in CLAUDE.md: it explains how to work, but is followed probabilistically. The second is executable checks: tests, end-to-end scenarios, architectural rules written as a script that fails on a violation. The third is hooks, which run these checks automatically at the right moment, without the model's involvement.
A practical rule: if the agent broke the same rule twice, do not add one more warning in capital letters to CLAUDE.md. Ask whether the violation can be checked by a script. If it can, write the script and attach it to a hook. Then the text instruction becomes shorter and the behavior more stable.
| Rule in text | Guarantee through a hook |
|---|---|
| "Format the code to the standard" | PostToolUse runs the formatter on the changed file |
| "Do not force-push" | PreToolUse on Bash blocks the command, exit 2 |
| "Do not say done while the tests are red" | Stop runs the tests and does not let the agent finish |
| "Do not edit .env" | PreToolUse on Edit|Write checks the path |
| "Remember where you stopped" | SessionStart prints the plan and git status |
Common mistakes
- Exit code 1 instead of 2. The script fails with code 1, the agent learns nothing, and the action goes through. Only code 2 blocks.
- A message on stdout. With code 2 the agent receives stderr. Write with
>&2. - A slow hook on a frequent event. A full test run in PostToolUse makes every edit take a minute. Keep the heavy checks for Stop.
- An endless Stop. No
stop_hook_activecheck and no limit — the agent loops forever. - Relative paths. The hook runs in the session's working directory; use
$CLAUDE_PROJECT_DIR. - An unchecked third-party hook. A hook runs with your permissions without confirmation. Read the scripts from someone else's repository before you trust them.
Summary
Claude Code hooks move the most important rules from text into code: PreToolUse stops dangerous actions, PostToolUse checks every edit, Stop does not let the agent report readiness too early, and SessionStart returns the project memory to the agent. Start with two or three hooks for rules the agent has already broken, and keep the stderr messages clear — then the agent corrects its own mistakes, and you check the result rather than the process. We walk through validation, readiness checks and autonomous loops step by step in the course.
FAQ
Do I need to restart Claude Code after changing hooks?
Changes in settings.json are most reliably applied in a new session; you can view the current hook configuration with the /hooks command.
Can hooks be written in something other than bash?
Yes, a hook is any command: a Python or Node.js script or a binary. The only requirements are reading JSON from stdin and returning the correct exit code.
Does the agent see the output of a successful hook?
Usually not: with code 0 the output does not enter the context. The exceptions are UserPromptSubmit and SessionStart, where stdout is added to the agent's context.
Do hooks work for subagents?
Yes, tool calls made by subagents pass through the same PreToolUse and PostToolUse hooks, and there is a separate SubagentStop event for their completion.
Is it safe to commit hooks to the repository?
Yes, .claude/settings.json and the scripts in .claude/hooks/ are a common practice for a team; keep personal hooks in settings.local.json or ~/.claude/settings.json.
This topic is covered in depth with practice in the course “Harness Engineering: Engineering Reliability for AI Agents”.
- Agent Says 'Done' but the Feature Is Broken: Verification Gap and Definition of Done
- All Tests Pass, Feature Is Broken: E2E Tests and Architecture Rules for AI Agents
- Autonomous AI Coding Agent for Hours: Loop Engineering and Stop Conditions