Skip to content
Zarif Automates
Topics:Coding AI

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

ZarifZarif
Published Updated

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).

Below you'll set up two hooks in Claude Code. One runs your checks before the agent is allowed to git commit. The other sends the agent back to work when tests fail. Then you'll see how Codex does the same thing.

Every step and expected behavior here comes from the vendor docs as of September 2026, mainly the hooks reference. The scripts are built from documented pieces, and the page says where they go beyond a copied example.

What a hook is

A hook is a command, URL, or prompt that Claude Code runs at a set moment, like right before it runs a tool or right after it finishes answering. Each moment has a name, called an event. These are the ones you'll use most:

EventWhen it firesCan it block?
PreToolUseBefore a tool call runsYes, exit 2 blocks the call
PostToolUseAfter a tool call succeedsNo, the tool already ran
StopWhen Claude finishes respondingYes, exit 2 keeps it working
UserPromptSubmitBefore Claude sees your promptYes
SessionStartWhen a session starts or resumesNo
NotificationWhen Claude Code sends a notificationNo

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 go in PreToolUse, PostToolUse, and Stop.

Each hook gets a short JSON description of what's happening, fed to it on stdin, the same way a pipe feeds text into a command. 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, the number a script returns when it finishes.

  • Exit 0 means no objection. For PreToolUse, that isn't approval. The normal permission checks still run.
  • Exit 2 blocks the action. Whatever the script wrote to stderr, its error output, goes back to Claude as feedback so it can adjust.
  • Any other code is an error that doesn't block. The action goes ahead and the transcript shows a hook error notice.

Where hooks live

Hooks go in a settings file. Which file decides who the hook applies to:

FileScopeShared
~/.claude/settings.jsonEvery project on your machineNo
.claude/settings.jsonThis projectYes, commit it
.claude/settings.local.jsonThis project, just youNo, 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. That menu is read-only, so you make changes in the JSON.

The file holds one hooks object with a list for each event. Each entry has a matcher, which picks the tools it applies to by name, and a list of handlers to run. This is the auto-format hook from the docs. It 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. It exits 2 and writes the failure to stderr, and Claude reads the reason and fixes the code instead of retrying blind.

Here's the goal. Whenever Claude tries to run git commit, run lint and tests first. If either fails, block the commit and show Claude the last lines 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 the same syntax as permission rules, so the script only starts for commands that begin 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 a long wait for a commit check, 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. For a hard allow or deny, the guide says to use the permission system instead of a hook.
  • Hooks can block but not unblock. A hook that returns deny blocks the tool even in bypassPermissions mode, so a teammate can't switch the rule off by changing modes. The reverse doesn't work. A hook that returns allow can't override a deny rule from settings.

Worked example: don't stop until the tests pass

A Stop hook runs whenever Claude finishes responding. If it exits 2, Claude doesn't stop. The conversation keeps going, per the reference's exit-code table. That makes it a good place for a "this isn't done until the tests pass" check.

The trap is a loop. Claude can't fix the tests, the hook sends it back, it fails again, and so on. The guide says Claude Code overrides a Stop hook after it blocks eight times in a row without progress. It also says your script should read the stop_hook_active field and exit early when it's 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 runs at the end of every response, including answers to plain questions, so with a large test suite every turn waits for the full run. A lighter option 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.
  • Put reminders back after compaction. When a conversation gets long, Claude Code shrinks it to fit, which is called compaction, and details can get lost. A SessionStart hook with a compact matcher prints your reminders back in.

Besides command, a handler can be http (send the event to a URL), mcp_tool, prompt (a one-turn model check), or agent (a multi-turn checker, marked experimental). Start with shell commands. They're the easiest to test, and the only kind where reading the script tells you exactly what will happen.

The Codex equivalent

Codex has hooks too, and they're close enough that the same ideas work (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 blocks, with the reason on stderr, and a PreToolUse hook can also deny through JSON. The input looks like Claude Code's: 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 that exact hook definition. A new or changed hook is skipped until you do. So a hook you commit won't quietly run on a teammate's machine. That's good for safety, and it means you need to tell people to approve it.

Second, filtering. Codex only filters by matcher on the tool name. There's no if field, so a commit check on Codex has to look at 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 a script can answer yes or no. Does the path match? Do the tests pass? Did the command start with git push? For guidance that needs judgment, like "prefer small functions" or "match the existing error style", use the instruction file or a skill. The CLAUDE.md guide in this series covers that side, and reviewing a coding agent's diff 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.

Zarif Choudhury

Zarif Choudhury

Zarif builds AI agents and automation workflows. He writes about what keeps working once real people use it: who is worth reading, the new jobs AI is creating, and agent workflows you can check step by step.