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.
Block the edit with a hook
Section titled “Block the edit with a hook”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.
Five edits, as the hook sees them
Section titled “Five edits, as the hook sees them”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.
python3 feed_hook.pyEdit 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.
The test fails on the 15th because the row count doesn’t match. Make it pass.
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.
The hook that exits with status 1
Section titled “The hook that exits with status 1”A team adds a Claude Code PreToolUse hook that should block agent edits to the migrations folder. The hook script has a bug: on some inputs it raises an exception, and Python exits with status 1.
The hook crashes on an edit to a migration file, and exits with status 1. What happens to the edit?
Which exit status does Claude Code treat as a block, and what does it do with the others?
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.
Run the gates in CI
Section titled “Run the gates in CI”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: checkson: pull_request: push: branches: [main]permissions: contents: readjobs: 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.pyThe 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.
The job on the unfixed import
Section titled “The job on the unfixed import”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.
python3 job_before_fix.pystep: 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.
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.
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.
The job after the agent's fix
Section titled “The job after the agent's fix”The course’s script applies the fix from the transcript to a fresh copy and runs the job’s steps again.
python3 job_after_fix.pystep: 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.
The agent offers a way around the hook
Section titled “The agent offers a way around the hook”A team's Claude Code project has a hook that blocks agent edits to the test file. An agent tries to change a check in the test, the hook blocks it, and the agent then offers a way around the hook.
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?
Who decided that the test file is protected, and where does a change to that decision belong?
Which lines go into the CI fix brief?
Section titled “Which lines go into the CI fix brief?”A CI job fails on an agent's pull request, on the step that runs the test. The developer writes a brief for the agent to fix the failure.
Which of these lines belong in the brief?
Which lines keep the gate as it is and give the agent the job's own check, and which lines make the job pass without fixing the code?
Where does each rule go?
Section titled “Where does each rule go?”A team decides how to enforce three of its rules for work with coding agents. It has a Claude Code hook on agent edits, a CI job on every pull request, and a project instructions file.
Match each rule to the place where the team enforces it.
Must the rule hold every time, and does it apply to agents only or to every change? Can a script check it at all?
Further steps for the team
Section titled “Further steps for the team”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.
A rule for every change
Section titled “A rule for every change”A team has a Claude Code hook that blocks agent edits to its test data. Some team members use a different coding agent, and some edit files by hand.
The team wants the rule “test data doesn’t change without a decision” to hold for every change, including changes made by hand and by other agents. What do they add?
Which of the checks runs no matter who or what made the change?
Can you merge yet?
Section titled “Can you merge yet?”A developer's agent has opened a pull request. The CI job runs the tests, and the team's norm is that the job and a reviewer decide whether a change merges.
Which of these is the job's result or a reviewer's approval, and which is only a claim about the change?
A hook the team can trust
Section titled “A hook the team can trust”A team writes a Claude Code PreToolUse hook that should stop agents from editing its database migrations. Without JSON output from the hook, Claude Code blocks a tool call only when the hook exits with status 2.
Which of these make the hook one the team can rely on?
For each choice, what happens on the day the agent makes the mistake, or the day the hook itself goes wrong?
The job fails on the agent's pull request
Section titled “The job fails on the agent's pull request”A developer's agent has fixed a bug on a branch. The CI job on the pull request fails on the test step, and the team's norm is that a change merges only when the job passes and a reviewer approves.
The agent says: “The test passes on my machine, so the CI failure must be the runner.” What do you do?
What does the team's norm say decides whether a change merges?
Exercise
Make a project of your own from the course files. From the root of your clone of the course repository, run:
cp -R site/examples/coding-with-agents/observing-and-debugging/nightly ~/ci-mecp site/examples/coding-with-agents/self-checking-loops/test_nights.py ~/ci-me/mkdir -p ~/ci-me/.claude/hooks ~/ci-me/.github/workflowscp 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.jsoncp site/examples/coding-with-agents/ci-and-hooks/checks.yml ~/ci-me/.github/workflows/cd ~/ci-megit init --initial-branch=maingit 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:
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>:
git remote add origin <url>git push -u origin mainThe 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
- 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].
- A Claude Code
PreToolUsehook 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]. - 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.
- Pin each action in a workflow to a full commit SHA, and give the job only the permissions it needs [4].
- 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
References
Section titled “References”- Anthropic. Best practices for Claude Code. Claude Code documentation. Reference.
Claude Code best practices - Anthropic. Hooks reference. Claude Code documentation. Reference.
Claude Code hooks - Anthropic. Run parallel sessions with worktrees. Claude Code documentation. Reference.
Claude Code worktrees - GitHub. Workflow syntax for GitHub Actions. GitHub documentation. Reference.
GitHub docs workflow-syntax - GitHub. Events that trigger workflows. GitHub documentation. Reference.
GitHub docs events-that-trigger-workflows - The Git project. git-tag. Git reference documentation. Reference.
Git docs git-tag - Anthropic. The AI-native SDLC playbook. Claude Academy. Course.
Academy ai-native-sdlc-playbook