A line in CLAUDE.md is a request. A hook is a program that Claude Code runs every time,
whatever the model decides. This part writes three for a .NET repository on Windows: one
that refuses hand edits to EF Core migrations, one that runs dotnet format on
every C# file Claude touches, and one that runs the tests when it says it is finished.
All three worked in real sessions. They also took between a quarter and well over half of
each session's time, and that belongs in the decision.
- Copy the
hooksblock from settings.json into your.claude/settings.json - Save guard-migrations.ps1, format-cs.ps1 and test-on-stop.ps1 in
.claude/hooks/ - Test each script by hand first: pipe a saved JSON into it and check the exit code. A path under
Data/Migrationsshould give exit code 2 - Ask Claude for a small change to a
.csfile. You should see aformat-cs.ps1line in.claude/hooks/logs/hooks.log - Ask it to edit a migration file. You should see
Blocked by .claude/hooks/guard-migrations.ps1 - Ask for a change that breaks a test and tell it not to touch the tests. You should see
Stop hook feedback:with the failures - Read the timings in the log and decide which hooks you can afford
How a hook works
A hook is registered in settings.json under an event. PreToolUse
runs before a tool call, PostToolUse after it, Stop when Claude
is about to end its turn. Claude Code starts your command and writes a JSON description
of the event to its standard input. The exit code is the answer: 0 lets things proceed,
and for PreToolUse exit code 2 blocks the call and gives the model whatever
the script wrote to standard error. The
hooks reference lists every event and
the JSON each one receives.
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "powershell.exe",
"args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-migrations.ps1"]
}
]
}
],
With args present, Claude Code starts the program directly, with no shell in
between. The logs confirmed it: every hook ran as Windows PowerShell 5.1. Unlike the allow
rules of Part 2, project hooks ran
in scripted sessions in a folder that had never been trusted.
Hook 1: nobody edits a migration by hand
$path = "$($call.tool_input.file_path)" -replace '\\', '/'
if ($path -match '/Data/Migrations/') {
[Console]::Error.WriteLine('Blocked by .claude/hooks/guard-migrations.ps1: files under Data/Migrations/ are generated by EF Core and are never edited by hand. ...')
exit 2
}
Asked to change a column length by editing the migration file, the session tried the edit and got the message back as the tool result:
PreToolUse:Edit hook error: [powershell.exe -NoProfile -ExecutionPolicy Bypass -File ${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-migrations.ps1]: Blocked by .claude/hooks/guard-migrations.ps1: files under Data/Migrations/ are generated by EF Core and are never edited by hand. Change the entity class or AppDbContext instead, then create a new migration from the repository root with: dotnet ef migrations add <Name> --project src/AppointmentDesk.Api
The message is written for the model: it says what to do instead. The session stopped,
explained why editing only the migration would leave the snapshot out of step, proposed
the dotnet ef migrations add command and asked before running it: "I haven't
tried to get around it." The guard itself took about 0.3 seconds per edit.
Write the guard to fail closed. If the script cannot read its input, it exits 2. The first version here did the opposite by accident, and a parsing error would have let every edit through.
Hook 2: format after every edit
$relative = $file.Substring($root.Length + 1)
Push-Location $root
$output = & dotnet format --include $relative 2>&1 | ForEach-Object { "$_" }
The first line is there because of a trap. The hook receives an absolute path, and
dotnet format --include with an absolute path exits 0 and formats nothing.
It wants a path relative to the folder it runs in. With that fixed, a deliberately messy
edit went in like this:
if(!ClinicHours.IsWithinOpeningHours(startsAt)){return ServiceError.Invalid(
and was on disk a few seconds later like this:
+ if (!ClinicHours.IsWithinOpeningHours(startsAt)) { return ServiceError.Invalid("startsAt", "The clinic is open 09:00-17:00 UTC; the last slot starts at 16:45."); }
The script tells Claude when it changed the file, through a JSON field called
additionalContext, and the model noticed: "The hook reformatted
AppointmentService.cs." Claude Code re-read the file itself, and no later edit failed
because of the change.
One configuration trap cost a run. The first version narrowed the hook with
"if": "Edit(*.cs)". Edits were formatted; a new file created with the Write
tool was not, and nothing warned about it. The published file has no if. The
script checks the extension itself.
Hook 3: run the tests when it stops
if ($call.stop_hook_active) {
Write-HookLog 'skipped: stop_hook_active=true'
exit 0
}
[ordered]@{
decision = 'block'
reason = "The Stop hook ran dotnet test from the repository root and it failed (exit code $code):`n$text"
} | ConvertTo-Json -Compress
When the tests fail, the script prints a JSON object with decision set to
block. Claude Code does not let the turn end and hands the reason to the
model. The first block is the loop guard from the documentation: if Claude is already
continuing because of a Stop hook, let it stop this time, or a test that cannot be fixed
keeps the session running forever.
Twice the hook had nothing to do. Asked to move the closing time to 18:00, the model updated the tests on its own: "The existing tests and README encode the old hours and would now be wrong, so I'll update them to match." Only when the prompt said to change the service file and nothing else did the tests fail at the stop. The model then received:
Stop hook feedback:
The Stop hook ran dotnet test from the repository root and it failed (exit code 1):
Failed AppointmentDesk.Tests.AppointmentServiceTests.BookAsync_OutsideClinicHours_ReturnsValidationError(hour: 8, minute: 45) [1 s]
Failed! - Failed: 2, Passed: 12, Skipped: 0, Total: 14, Duration: 1 s - AppointmentDesk.Tests.dll (net10.0)
It explained both failures, kept to its instruction not to touch the tests, and stopped; the guard let the second stop through. The hook does not make the tests pass. It makes sure the model ends its turn knowing they fail, and says so.
What they cost
| Hook | Runs | Fastest | Median | Slowest |
|---|---|---|---|---|
| Migration guard, per edit | 18 | 0.27 s | 0.29 s | 0.32 s |
| Format, per edit of a .cs file | 15 | 5.43 s | 5.68 s | 6.02 s |
| Tests, per stop | 8 | 5.92 s | 6.15 s | 6.55 s |
dotnet format loads the whole solution on every call, so it costs five to six
seconds even when it changes nothing. A session with one clean edit took 21.3 seconds, of
which the hooks took 11.9. Across the sessions, hooks were 26% to 60% of the time, and
Claude Code's own debug log flagged the format hook as slow on every edit. The
Stop hook also runs at the end of a turn in which nothing was edited. On this small
solution that is bearable. On a solution whose tests take two minutes, run the tests in a
hook only for the projects that changed, or leave them to CI.
What they did not do
The guard watches the Edit and Write tools. It does not see a shell command, and one
session said as much before declining to use one: "I could get around it with a shell
command, but that would defeat a rule your team set up on purpose, so I didn't." To cover
shell commands, add a second PreToolUse hook matching
Bash|PowerShell that inspects the command text.
Formatting did what the .editorconfig says and no more. It fixed indentation
and spacing and left two statements on one line, because the formatter's settings keep
single-line statements. One session described the hook as whitespace-only, which is wrong.
And one Windows detail, in case your paths or names are not plain ASCII. The hook processes ran with console code page 437, where the documentation's way of reading standard input turned the á in Tomás into two characters. The scripts read the input through a UTF-8 stream reader instead. Test your hooks by hand with the input they will really get.
Frequently asked
- How do I run dotnet format automatically after Claude Code edits a file?
- Register a PostToolUse hook with the matcher Edit|Write whose script reads the file path from the JSON on standard input and runs dotnet format --include with a path relative to the repository root. An absolute path formats nothing. Each run took 5.4 to 6 seconds here.
- How do I stop Claude Code from editing EF Core migration files?
- With a PreToolUse hook on Edit|Write that checks the path and exits with code 2 for anything under the Migrations folder. Claude Code blocks the edit and gives the model the script's error text, so write that text as an instruction: create a new migration with dotnet ef migrations add instead.
- Do Claude Code hooks run on Windows without Git Bash?
- Yes. A hook with a command and an args list is started directly, with no shell. These hooks name powershell.exe and a script file, and ran as Windows PowerShell 5.1 in every session, including scripted claude -p runs in a folder that had not been trusted.
Next: Part 4, a skill for EF Core migrations: the procedure this guard keeps pointing the model towards.