Hooks for Coding Agents: Rules the Model Can't Skip
An instruction file asks the agent to run the tests before committing. A hook makes it happen. Claude Code's hooks guide puts the difference plainly: hooks give "deterministic control" so certain actions always happen rather than relying on the model to choose them (Claude Code hooks guide, read September 26, 2026).
This page covers what a hook is in Claude Code, the settings shape, a worked hook that runs your checks before any git commit the agent attempts, a Stop hook that sends it back to work when tests fail, and the Codex equivalent. Every step and every expected behavior comes from the vendor docs as of September 2026, mainly the hooks reference. The scripts are assembled from documented pieces and are marked where they go beyond a verbatim example.
What a hook is
A hook is a command, URL, or prompt that Claude Code runs at a fixed point in its loop. The event names what point. The ones you'll use most:
| Event | When it fires | Can it block? |
|---|---|---|
PreToolUse | Before a tool call runs | Yes, exit 2 blocks the call |
PostToolUse | After a tool call succeeds | No, the tool already ran |
Stop | When Claude finishes responding | Yes, exit 2 keeps it working |
UserPromptSubmit | Before Claude sees your prompt | Yes |
SessionStart | When a session starts or resumes | No |
Notification | When Claude Code sends a notification | No |
The full list in the hooks reference runs past thirty events, including SubagentStop, PreCompact, WorktreeCreate, and SessionEnd. Start with the table above. Most useful rules live in PreToolUse, PostToolUse, and Stop.
Each hook receives JSON on stdin describing the event. For a Bash call, a PreToolUse hook gets something like this, per the guide:
{
"session_id": "abc123",
"cwd": "/Users/sarah/myproject",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
}
}
The hook answers with its exit code. Exit 0 means no objection, and for PreToolUse that does not approve the call, the normal permission flow still applies. Exit 2 blocks the action, and whatever the script wrote to stderr goes back to Claude as feedback so it can adjust. Any other code is a non-blocking error: the action proceeds and the transcript shows a hook error notice.
Where hooks live
Hooks go in a settings file. The scope follows the file:
| File | Scope | Shared |
|---|---|---|
~/.claude/settings.json | Every project on your machine | No |
.claude/settings.json | This project | Yes, commit it |
.claude/settings.local.json | This project, just you | No, gitignored |
Skills, subagents, and plugins can also carry hooks in their own files. For a team rule, use .claude/settings.json so everyone who clones the repo gets it. Run /hooks inside Claude Code to see what loaded. The menu is read-only, so edits happen in the JSON.
The shape is one hooks object with an array per event. Each entry has a matcher that filters by tool name, and a list of handlers. This is the documented auto-format hook, which runs Prettier on every file Claude edits:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
The docs' examples parse JSON with jq, so install it first (brew install jq on macOS).
Worked example: checks before the agent commits
Git's own pre-commit hook still works when an agent commits. What it can't do is explain itself to the agent. A Claude Code hook can: exit 2, write the failure to stderr, and Claude reads the reason and fixes the code instead of retrying blind.
The target behavior: whenever Claude tries to run git commit, run lint and tests first. If either fails, block the commit and show Claude the tail of the output.
Step 1. Write the script. Save this as .claude/hooks/check-before-commit.sh. The input parsing and exit-2 pattern follow the guide's protect-files example. The npm run commands are placeholders for your project's own checks.
#!/bin/bash
# Runs before any Bash call the if filter lets through (git commit).
# Exit 2 blocks the commit; stderr becomes Claude's feedback.
cat > /dev/null # the if filter already matched git commit
cd "$CLAUDE_PROJECT_DIR" || exit 0
if ! OUTPUT=$(npm run lint 2>&1); then
echo "Commit blocked: lint failed. Fix these before committing:" >&2
echo "$OUTPUT" | tail -n 30 >&2
exit 2
fi
if ! OUTPUT=$(npm test 2>&1); then
echo "Commit blocked: tests failed. Fix these before committing:" >&2
echo "$OUTPUT" | tail -n 30 >&2
exit 2
fi
exit 0
Step 2. Make it executable. The guide says hook scripts must be executable on macOS and Linux:
chmod +x .claude/hooks/check-before-commit.sh
Step 3. Register it. Add this to .claude/settings.json. The if field uses permission-rule syntax, so the script only spawns for commands that start with git commit, not for every Bash call:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-before-commit.sh",
"timeout": 300
}
]
}
]
}
}
The timeout is in seconds. Command hooks default to ten minutes, which is long for a commit gate, so set one that matches how long your checks take.
Step 4. Test it by hand first. The troubleshooting section recommends piping sample JSON into the script and checking the exit code:
echo '{"tool_name":"Bash","tool_input":{"command":"git commit -m test"}}' \
| CLAUDE_PROJECT_DIR=$(pwd) .claude/hooks/check-before-commit.sh
echo $?
With a failing test in the repo, you should see the "Commit blocked" message and 2. With a clean repo, 0.
Step 5. Try it in a session. Break a test, ask Claude to commit, and watch. Per the docs, the commit never runs, and Claude receives the stderr text as feedback.
Two limits from the docs are worth knowing before you rely on this. The if filter is best-effort: when Claude Code can't tell which commands a Bash line runs, it runs the hook anyway, and the guide says to use the permission system rather than a hook for a hard allow or deny. And a hook that returns deny blocks the tool even in bypassPermissions mode, so a teammate can't switch the rule off by changing permission modes. The reverse doesn't hold: a hook that returns allow can't loosen a deny rule from settings.
Worked example: don't stop until the tests pass
A Stop hook fires whenever Claude finishes responding. Exit 2 from a Stop hook prevents Claude from stopping and continues the conversation, per the reference's exit-code table. That makes it a natural "definition of done" check.
The trap is a loop. The guide says Claude Code overrides a Stop hook after it blocks eight times in a row without progress, and that your script should read the stop_hook_active field and exit early when it is true. This version does that:
#!/bin/bash
# .claude/hooks/tests-must-pass.sh
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
cd "$CLAUDE_PROJECT_DIR" || exit 0
if ! OUTPUT=$(npm test 2>&1); then
echo "Tests are failing. Keep working until npm test passes:" >&2
echo "$OUTPUT" | tail -n 30 >&2
exit 2
fi
exit 0
Register it under "Stop" with no matcher. Use it with care. A Stop hook fires at the end of every response, including answers to plain questions, so on a large suite it adds the full test run to each turn. A lighter pattern is to run only the fast unit tests here and leave the full suite to CI.
A few more hooks worth copying
The guide includes ready-made versions of these. Each is a few lines:
- Block edits to protected files. A
PreToolUsehook onEdit|Writethat exits 2 when the path matches.env,package-lock.json, or.git/. - Desktop notification when Claude needs you. A
Notificationhook that callsosascripton macOS. - Re-inject context after compaction. A
SessionStarthook with acompactmatcher that prints reminders back into context.
Beyond command, handlers can be http (POST the event to a URL), mcp_tool, prompt (a one-turn model check), or agent (a multi-turn verifier, marked experimental). Start with shell commands. They are the easiest to test and the only kind whose behavior you can predict from the script alone.
The Codex equivalent
Codex has hooks too, and the shape is close enough that the ideas transfer (Codex hooks docs, read September 26, 2026). As of that date the Codex page lists events including PreToolUse, PermissionRequest, PostToolUse, UserPromptSubmit, Stop, SubagentStop, SessionStart, and SessionEnd.
Codex reads hooks from ~/.codex/hooks.json or ~/.codex/config.toml, and from a repo's .codex/hooks.json or .codex/config.toml. The TOML form of a PreToolUse hook on Bash looks like this, per the docs:
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = 'python3 ~/.codex/hooks/policy.py'
timeout = 30
Exit code 2 signals a block with the reason on stderr, and a PreToolUse hook can also deny through JSON. The input matches what you'd expect from Claude Code: tool_name, and for shell calls, the command in tool_input.command.
Two differences matter in practice. First, trust. The Codex page says that before a non-managed hook can run, you have to review and trust the exact hook definition, and a new or changed hook is skipped until you do. A hook you commit won't fire silently on a teammate's machine, which is good for safety and means you should tell people to approve it. Second, filtering. Codex filters only by matcher on the tool name, with no per-handler if field, so a commit gate on Codex checks the command inside the script:
COMMAND=$(jq -r '.tool_input.command // empty')
case "$COMMAND" in
"git commit"*) ;; # fall through to the checks
*) exit 0 ;;
esac
Cursor has its own hook system in .cursor/hooks.json, with events such as beforeShellExecution and afterFileEdit (Cursor hooks docs). The comparison page later in this series covers where the three differ.
When a hook is the wrong tool
Hooks are for rules with a yes-or-no answer a script can compute: does the path match, do the tests pass, did the command start with git push. For guidance that needs judgment, such as "prefer small functions" or "match the existing error style", the instruction file or a skill is the right place. The CLAUDE.md guide in this series covers that side, and Claude Code features that matter after the first demo covers verification habits more broadly.
If a rule would be expensive to break, make it a hook and keep a one-line mention in the instruction file so the agent knows why it got blocked.
Previous in the series: Git workflows with coding agents. Next: Subagents and parallel work with coding agents.
