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.

Steps in this part
  1. Copy the hooks block from settings.json into your .claude/settings.json
  2. Save guard-migrations.ps1, format-cs.ps1 and test-on-stop.ps1 in .claude/hooks/
  3. Test each script by hand first: pipe a saved JSON into it and check the exit code. A path under Data/Migrations should give exit code 2
  4. Ask Claude for a small change to a .cs file. You should see a format-cs.ps1 line in .claude/hooks/logs/hooks.log
  5. Ask it to edit a migration file. You should see Blocked by .claude/hooks/guard-migrations.ps1
  6. 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
  7. 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 run times over eight sessions on this machine, from the scripts' own log
HookRunsFastestMedianSlowest
Migration guard, per edit180.27 s0.29 s0.32 s
Format, per edit of a .cs file155.43 s5.68 s6.02 s
Tests, per stop85.92 s6.15 s6.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.