# Hooks for Coding Agents: Rules the Model Can't Skip

> How Claude Code and Codex hooks work, the settings.json shape, and a worked hook that runs your checks before the agent commits.

- Source: https://www.zarifautomates.com/blog/hooks-for-coding-agents
- Published: 2026-09-26
- Updated: 2026-09-26
- Pillar: Agents & AI Engineering
- Tags: Claude Code, codex, AI Coding, ai coding tools, Developer Tools
- Author: Zarif

---

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](https://code.claude.com/docs/en/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](https://code.claude.com/docs/en/hooks). 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](https://code.claude.com/docs/en/hooks) 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:

```json
{
  "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:

```json
{
  "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.

```bash
#!/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:

```bash
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:

```json
{
  "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:

```bash
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:

```bash
#!/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 `PreToolUse` hook on `Edit|Write` that exits 2 when the path matches `.env`, `package-lock.json`, or `.git/`.
- **Desktop notification when Claude needs you.** A `Notification` hook that calls `osascript` on macOS.
- **Re-inject context after compaction.** A `SessionStart` hook with a `compact` matcher 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](https://developers.openai.com/codex/hooks), 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:

```toml
[[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:

```bash
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](https://cursor.com/docs/agent/hooks)). 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](/blog/claude-md-and-agents-md-for-coding-agents) covers that side, and [Claude Code features that matter after the first demo](/blog/claude-code-creator-power-features-boris-cherny) 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](/blog/git-workflows-with-coding-agents). Next: [Subagents and parallel work with coding agents](/blog/subagents-and-parallel-work-with-coding-agents).


