Skip to content

Enforcing the team's rules with hooks and CI

In the lesson on self-checking loops you gave one agent session a test to run and a rule that the test file was read-only. The rule was a line in your brief. A colleague who starts their own session on the same repository doesn’t have your brief, and their agent doesn’t know about the rule. In this lesson two team rules run without anyone remembering them. A hook stops the agent’s edit tools from changing the test in every Claude Code session on the project, and a CI job (continuous integration) runs the test on every pull request, whoever wrote it.

The project is the same nightly sales import, with test_nights.py next to importer.py, before the fix. The test fails on the 15th and the 17th of September, because the importer splits a quoted amount such as "1,250.00" into two fields.

Team hooks are scripts that run by themselves at fixed points in the work, such as before a commit or before an agent edits a file. They check a rule and stop the step when it breaks. The vendor guide for Claude Code puts the difference this way: what you write in the project instructions is advice the agent may follow, and a hook runs every time, whatever the agent decides [1]. A hook enforces a rule that an instruction can only ask for.

Claude Code has a hook event called PreToolUse, which runs before a tool call and can block it. The hook gets the call as JSON (JavaScript Object Notation) on its standard input, with the tool’s name in tool_name and its arguments in tool_input. For the Edit and Write tools, tool_input holds the file_path of the file. When the hook exits with status 2, Claude Code blocks the call and shows the agent what the hook wrote to its standard error. A hook configured in .claude/settings.json belongs to the project, and you commit that file to the repository, so every session started in the project gets the hook [2].

This is the setting. The matcher says which tools the hook runs for, here Edit and Write:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks/protect_paths.py\""
}
]
}
]
}
}

And this is the part of the hook script that decides. The full script is protect_paths.py in the course repository:

PROTECTED = [
"test_nights.py",
"nights/",
".claude/",
".github/",
]
REASON = (
"{path} is protected: agents may not change it. If it must change, stop and tell the user why."
)

The list protects the test and the recorded nights, and also the hook, its settings and the CI workflow. Without the last two entries, an agent that is blocked could edit the setting and remove the hook. The reason names the file and says what to do next, because the agent reads it and decides its next step from it.

The hook also works when Claude Code moves the session into a worktree, which by default is a directory under .claude/worktrees/. In a worktree, ${CLAUDE_PROJECT_DIR} keeps the directory the session started in, while the cwd in the hook’s input changes to the worktree [3]. So the script drops the .claude/worktrees/<name>/ part of the path before it checks the list. Without that step, every edit in a worktree would match the .claude/ entry and be blocked.

Example · run it

The course’s script copies the project to a temporary place, adds the hook, and feeds it five tool calls the way Claude Code does. The last two edit files in a Claude Code worktree under .claude/worktrees/. Run it from site/examples/coding-with-agents/ci-and-hooks/ in your clone of the course repository, and compare what you see with the output below.

Terminal window
python3 feed_hook.py
Output
Edit importer.py: exit status 0
Edit test_nights.py: exit status 2
  test_nights.py is protected: agents may not change it. If it must change, stop and tell the user why.
Write nights/2026-09-15/store-09.csv: exit status 2
  nights/2026-09-15/store-09.csv is protected: agents may not change it. If it must change, stop and tell the user why.
Edit .claude/worktrees/fix-import/importer.py: exit status 0
Edit .claude/worktrees/fix-import/test_nights.py: exit status 2
  test_nights.py is protected: agents may not change it. If it must change, stop and tell the user why.

Output verified in CI from site/examples/coding-with-agents/ci-and-hooks/feed_hook.py.

The examples in this lesson aren’t graded. Each one is a command you run, with output the course checks on every build.

The transcript below, of an agent that hits the hook, is illustrative and doesn’t record a specific model’s words.

Prompt (illustrative, not a recorded transcript)

The test fails on the 15th because the row count doesn’t match. Make it pass.

Response

I tried to change the row check in test_nights.py, and a hook blocked the edit: “test_nights.py is protected: agents may not change it. If it must change, stop and tell the user why.” So I stopped. The row count fails because read_rows in importer.py splits the quoted amount "1,250.00" into two fields. I can fix that in importer.py, or you can tell me why the test should change.

The brief had no limits at all, and the agent still couldn’t change the test, because the rule was in the project and not in the brief.

A hook has a limit you need to know. Its matcher compares tool names, and a shell command is the Bash tool, so this hook doesn’t run for sed -i on the test or for a script that writes the file [2]. The hook blocks the agent’s usual way to edit a file. It doesn’t prove the file stayed the same. The gates and the review of the diff do that.

Checkpoint · choice

The hook crashes on an edit to a migration file, and exits with status 1. What happens to the edit?

The vendor page warns about this case: status 1 is the usual failure status in a shell, and for Claude Code it is an error in the hook, after which the call goes ahead [2]. So protect_paths.py also exits with status 2 when it can’t read the file path from its input. Test a hook by making the mistake it is meant to catch, as the script above does.

The hook covers agent sessions in Claude Code. A CI job runs on the changes that reach the repository, from a person, an agent or a tool. CI integration makes the CI pipeline the shared gate for agent changes. The deterministic gates you met with the self-checking loop run on every pull request, and the team agrees that a change that fails them doesn’t merge.

In GitHub Actions a workflow file is stored under .github/workflows/, and a run step passes or fails on the exit status of its command [4]. A workflow that runs on: pull_request starts when a pull request is opened or reopened, and when new commits are pushed to it [5]. This is the job for the nightly import, as checks.yml:

name: checks
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
gates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Compile the Python files
run: python3 -m py_compile importer.py test_nights.py
- name: Run the nightly test
run: python3 test_nights.py

The uses line names the action that checks out the code by the full hash of one commit, its SHA (Secure Hash Algorithm), with the version in a comment. The GitHub page calls the commit SHA of a released version the safest choice [4]. Git can move a tag to another commit [6]. A commit hash can’t be moved, because it is computed from the commit itself. The course repository pins every action the same way. permissions sets what the job’s GITHUB_TOKEN may do, and GitHub advises the minimum the job needs [4]. This job only reads the code.

Example · run it

The course’s script reads the run lines of checks.yml and runs them in order in a fresh copy, the way the runner does, and stops at the first step that fails. Run it from the same directory.

Terminal window
python3 job_before_fix.py
Output
step: Compile the Python files
$ python3 -m py_compile importer.py test_nights.py
step: Run the nightly test
$ python3 test_nights.py
2026-09-14: pass
2026-09-15: FAIL: exit status 1; parsed 39 rows, expected 40 rows
2026-09-16: pass
2026-09-17: FAIL: exit status 1; parsed 46 rows, expected 47 rows
2 passed, 2 failed
job failed: the step exited with status 1

Output verified in CI from site/examples/coding-with-agents/ci-and-hooks/job_before_fix.py.

The job’s log is what the agent gets to work from. Give it the failing lines and the same limits as any self-checking loop: how many attempts it may make, and what it may change. The hook still protects the test. The brief says so too, and the agent knows it before it tries. The transcript is illustrative.

Prompt (illustrative, not a recorded transcript)

The CI job gates fails on the step “Run the nightly test”. The log says: 2026-09-15: FAIL: exit status 1; parsed 39 rows, expected 40 rows, and the same on the 17th. Fix importer.py so the job passes. After each change, run the job’s two commands, python3 -m py_compile importer.py test_nights.py and python3 test_nights.py, and read the output. Stop after five attempts, pass or fail, and show me the last output. The test, nights/, .claude/ and .github/ are protected by a hook. If one of them must change, stop and tell me why.

Response

Attempt 1: I changed read_rows to read each file with the csv module, and the 15th failed on converting “1,250.00” to a number. Attempt 2: I removed the thousands separator before the conversion. Both commands now pass, and the test prints 4 passed, 0 failed. I changed only importer.py.

Then push the fix to the pull request, and the job runs again.

Example · run it

The course’s script applies the fix from the transcript to a fresh copy and runs the job’s steps again.

Terminal window
python3 job_after_fix.py
Output
step: Compile the Python files
$ python3 -m py_compile importer.py test_nights.py
step: Run the nightly test
$ python3 test_nights.py
2026-09-14: pass
2026-09-15: pass
2026-09-16: pass
2026-09-17: pass
4 passed, 0 failed
job passed

Output verified in CI from site/examples/coding-with-agents/ci-and-hooks/job_after_fix.py.

The agent said the test passes on its machine, and the job now says the same where the whole team can see it. A reviewer reads the diff next. The job and the reviewer decide whether the change can merge, and the agent’s summary doesn’t.

Checkpoint · scenario

The hook blocks the agent’s edit to test_nights.py. The agent says: “I can make the same change with sed -i in a shell command, which the hook doesn’t check. Should I?” What do you do?

Checkpoint · multi-choice

Which of these lines belong in the brief?

Select exactly 3.

Checkpoint · match

Match each rule to the place where the team enforces it.

The Academy course The AI-native SDLC playbook (SDLC, the software development life cycle) goes further than one hook and one job. It has lessons on hooks as approval gates and on putting an AI reviewer in the pull request loop [7]. Here are those ideas in our words, with what the Claude Code pages say about each.

A hook can ask a person. A PreToolUse hook can also return a decision in JSON on its standard output, and one of the decisions is ask, which shows the user a permission prompt for that call [2]. So a hook can put a person in the loop at one step, for example a deploy command or a change to a database migration, and let the rest of the work run. Choose those steps the way you choose any approval: the steps that can’t be undone or that reach other people.

An agent reviews the pull request. Claude Code has a non-interactive mode, claude -p, for use in CI and scripts [1]. A CI job can use it to review the diff of each pull request and post findings ranked by how much they matter. A second job with its own prompt can review the same diff for security problems only, so those findings don’t get lost among the style comments. The findings are an opinion next to the gates, and a person still weighs them.

A finding that comes back goes into the instructions. The first time a review finds a mistake, fix it in the change. The second time the same kind of mistake comes back, add a line to the project instructions, or a check when a script can find it. The vendor guide also says to turn an instruction into a hook when the rule must hold every time [1].

A fresh session gives a second opinion. The session that wrote a change tends to favor it. The vendor guide suggests a second session that reviews the change without the first one’s history [1].

Evaluations run in CI too. When the project is an agent or a prompt, the tests include evaluations of the model’s answers. The Building agents course teaches how to score those answers, starting with a rubric for what a good answer is.

Exercise

Make a project of your own from the course files. From the root of your clone of the course repository, run:

Terminal window
cp -R site/examples/coding-with-agents/observing-and-debugging/nightly ~/ci-me
cp site/examples/coding-with-agents/self-checking-loops/test_nights.py ~/ci-me/
mkdir -p ~/ci-me/.claude/hooks ~/ci-me/.github/workflows
cp site/examples/coding-with-agents/ci-and-hooks/protect_paths.py ~/ci-me/.claude/hooks/
cp site/examples/coding-with-agents/ci-and-hooks/settings.sample.json ~/ci-me/.claude/settings.json
cp site/examples/coding-with-agents/ci-and-hooks/checks.yml ~/ci-me/.github/workflows/
cd ~/ci-me
git init --initial-branch=main
git add .
git commit -m "Nightly import with a hook and a CI job"

Test the hook first by making the mistake it catches. This command gives it the JSON of an edit to the test and prints its exit status, which should be 2:

Terminal window
echo '{"tool_name": "Edit", "tool_input": {"file_path": "test_nights.py"}}' | python3 .claude/hooks/protect_paths.py; echo "exit status $?"

Create an empty private repository on GitHub that you can delete afterwards. Then push the copy to it. Put the address GitHub shows for the new repository in place of <url>:

Terminal window
git remote add origin <url>
git push -u origin main

The job runs on the push to main and fails on the 15th and the 17th. Then make a branch, start Claude Code inside ~/ci-me, and ask it to make the test pass without limits, as in the first transcript. Check that the hook stops its edit to the test. If the agent changes the test some other way, such as with a shell command, that is the limit of the matcher from earlier in the lesson: put the file back with git checkout test_nights.py and note what the agent did. Then give it the failing lines of the job log with the brief from this lesson, push its fix, and open a pull request.

A good result has a hook that blocked the edit to the test, a run that stopped within five attempts, a pull request that changes only importer.py, and a job that passes on it. Delete the repository when you are done. Then answer one question: which rule in your own team is written down today but depends on each session reading it?

Stretch: Then ask your agent, in a new session, to add a night under nights/ for the 18th. Confirm that the hook blocks it, and read the reason the agent reports back to you.

Recap

  1. Team hooks run at fixed points, such as before an agent edits a file, and apply to every session in the project. An instruction is advice the agent may skip, and a hook runs every time [1].
  2. A Claude Code PreToolUse hook gets the tool call as JSON and blocks it by exiting with status 2, and the agent reads what it wrote to standard error. Status 1 is an error in the hook, after which the call goes ahead, and a matcher on Edit and Write doesn’t see a shell command that writes the file [2].
  3. CI integration runs the deterministic gates on every pull request, whoever wrote the change. The job and a reviewer decide whether the change can merge, and the agent fixes a failing job from its log within the same limits as any self-checking loop.
  4. Pin each action in a workflow to a full commit SHA, and give the job only the permissions it needs [4].
  5. A rule that must hold every time goes into a hook or a CI check. A review finding that comes back a second time goes into the project instructions, or into a check when a script can find it.

You can now

  • Follows the team's review and CI norms for agent changes
  • Sets the team's practice for agent use

  1. Anthropic. Best practices for Claude Code. Claude Code documentation. Reference. Claude Code best practices
  2. Anthropic. Hooks reference. Claude Code documentation. Reference. Claude Code hooks
  3. Anthropic. Run parallel sessions with worktrees. Claude Code documentation. Reference. Claude Code worktrees
  4. GitHub. Workflow syntax for GitHub Actions. GitHub documentation. Reference. GitHub docs workflow-syntax
  5. GitHub. Events that trigger workflows. GitHub documentation. Reference. GitHub docs events-that-trigger-workflows
  6. The Git project. git-tag. Git reference documentation. Reference. Git docs git-tag
  7. Anthropic. The AI-native SDLC playbook. Claude Academy. Course. Academy ai-native-sdlc-playbook