Claude Code Hooks: Run a Check After Every Edit

You can write “run the tests after every change” in CLAUDE.md and Claude will mostly do it. Mostly is the problem. A hook is a shell command that Claude Code runs itself at a fixed point in the session, after an edit, before a command, when it is waiting for you, and it runs every time, whatever the model decided to do that turn.

This post builds one hook end to end on Windows: after every file Claude writes, parse it if it is a .ps1 and hand any syntax errors straight back to Claude. Then the 2 other hooks worth having on day 1. The script runs in Windows PowerShell 5.1 and PowerShell 7; the configuration is from the current docs, linked at the end. If you have not met settings.json yet, Install Claude Code on Windows, Native and in WSL introduces it.

How a Hook Is Wired

Hooks live under a hooks key in a settings file: the user file for every project, .claude\settings.json in a repo for the team, or .claude\settings.local.json for you alone in one project. The shape is event, then matcher group, then 1 or more handlers:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "powershell.exe",
            "args": [
              "-NoProfile",
              "-ExecutionPolicy", "Bypass",
              "-File", "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-ps1.ps1"
            ]
          }
        ]
      }
    ]
  }
}

The events you will use first: PreToolUse runs before a tool call and can block it, PostToolUse runs after one succeeds, Notification fires when Claude is waiting on you, Stop when Claude finishes a response, and SessionStart when a session starts or resumes. The matcher is the tool name, exact or |-separated; anything with other characters is read as a regex.

The command plus args form above spawns the program directly with no shell in between, and the docs recommend it for any hook that uses a path placeholder. The alternative, a single command string, is run through Git Bash on Windows when it is installed, or PowerShell when it is not. ${CLAUDE_PROJECT_DIR} is the project root where the session started, which is why the hook does not care what directory Claude has changed into. -NoProfile keeps the hook fast and -ExecutionPolicy Bypass lets it run a local script whatever your execution policy is.

The Script: Parse Every .ps1 Claude Writes

Claude Code sends the hook JSON on stdin: the event name, the tool, its input and, for PostToolUse, its result. For the file tools tool_input.file_path is always absolute and on Windows always has backslashes. The script reads that, and if the file is PowerShell, runs it through the parser and reports the errors. Save it as .claude\hooks\check-ps1.ps1 in the project:

$hook = [Console]::In.ReadToEnd() | ConvertFrom-Json
$path = $hook.tool_input.file_path
if (-not $path -or [IO.Path]::GetExtension($path) -ne '.ps1') { exit 0 }

$tokens = $null; $errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors) | Out-Null
if ($errors.Count -eq 0) { exit 0 }

$msg = ($errors | ForEach-Object { "line $($_.Extent.StartLineNumber): $($_.Message)" }) -join '; '
@{ decision = 'block'; reason = "PowerShell parse errors in $path. $msg" } | ConvertTo-Json -Compress
exit 0

Exit 0 with no output means the hook has nothing to say. Exit 0 with that JSON on stdout puts the reason next to the tool result, so Claude sees “line 1: The string is missing the terminator” immediately after the write and fixes it in the same turn. The tool has already run by the time a PostToolUse hook fires, so this is feedback, not a block; to stop something before it happens you want PreToolUse, below.

Test it from a shell before you trust it. Pipe a fake payload in and check you get the JSON back for a broken file and nothing for a good one:

# a deliberately broken script
Set-Content .\bad.ps1 -Value 'Write-Host "unclosed'

# the payload Claude Code would send, piped into the hook
$payload = @{ hook_event_name = 'PostToolUse'; tool_name = 'Write'
              tool_input = @{ file_path = (Resolve-Path .\bad.ps1).Path } } | ConvertTo-Json -Compress
$payload | powershell -NoProfile -ExecutionPolicy Bypass -File .\.claude\hooks\check-ps1.ps1
# {"decision":"block","reason":"PowerShell parse errors in ...bad.ps1. line 1: The string is missing the terminator: \"."}

Then in a session, type /hooks and confirm it is listed under PostToolUse. Ask Claude to write a small .ps1 with a deliberate mistake and watch it get the error back. Press Ctrl+O to open the transcript view if you want to see the hook’s outcome; a hook that succeeds quietly shows nothing in the conversation.

Narrow It With if

A matcher filters on the tool name. The if field filters on the arguments too, using the same syntax as permission rules, so the hook process only spawns when the call matches. With it, the script above does not need its own extension check:

{
  "type": "command",
  "if": "Edit(*.ps1)",
  "command": "powershell.exe",
  "args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-ps1.ps1"]
}

One if names one tool, so a hook that should fire for both Edit and Write needs 2 handlers, or keep the broad matcher and the check inside the script. if only works on the tool events: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied.

Get a Notification When Claude Is Waiting

The hook most people set first. On Windows the docs’ example pops a message box, which can open behind the terminal, so test it once:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [
          {
            "type": "command",
            "command": "powershell.exe -Command \"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); [System.Windows.Forms.MessageBox]::Show('Claude Code needs your attention', 'Claude Code')\""
          }
        ]
      }
    ]
  }
}

An empty matcher fires on every notification type. permission_prompt fires when a prompt has waited about 6 seconds and idle_prompt when Claude finished about 60 seconds ago and you have not typed. This one goes in ~\.claude\settings.json since it is about you, not the project.

Block Edits to Files That Must Not Change

A PreToolUse hook sees the call before it runs and can refuse it with exit code 2. The message you write to stderr goes back to Claude as the reason, so it adjusts instead of retrying. The same exec form, a different script:

# .claude\hooks\protect-files.ps1
$hook = [Console]::In.ReadToEnd() | ConvertFrom-Json
$path = $hook.tool_input.file_path.Replace('\', '/')
$protected = @('/.env', '/web.config', '/infra/', '/.git/')
foreach ($pattern in $protected) {
    if ($path -like "*$pattern*") {
        [Console]::Error.WriteLine("Blocked: $path matches protected pattern '$pattern'")
        exit 2
    }
}
exit 0
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "powershell.exe",
            "args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.ps1"]
          }
        ]
      }
    ]
  }
}

The .Replace is the detail the docs call out for Windows: the path arrives as C:\project\web.config, and a pattern written with forward slashes never matches it unless you normalise first. Exit codes: 0 means carry on, 2 means block on the events that can block, anything else is a non-blocking error that Claude Code shows as a hook error notice while the action goes ahead.

Other Considerations

Hooks run with a 600 second timeout by default for command hooks, and a timeout field on the handler changes it. A slow check blocks the turn, so keep PostToolUse hooks short or add "async": true for one that only needs to run, not report.

A hook is a shell command with your rights. A project you cloned can carry hooks in its committed .claude\settings.json, which is why Claude Code shows the workspace trust dialog first. Read the hooks block of a repo you did not write before trusting it, and do not run claude -p in a folder you have not looked at, because -p skips the dialog.

Turn them all off with "disableAllHooks": true in a settings file, or start a session with claude --bare, which skips hooks along with everything else that loads from settings.

.cmd and .bat shims cannot be spawned in exec form. The wrappers npm puts in node_modules\.bin are not executables. Run the underlying script with node in args, or use the single-string command form so a shell handles it.

Prefer a hook over a CLAUDE.md line when the step must not be skipped. The docs put it plainly: for something that has to run at a specific point, such as before every commit or after each edit, write it as a hook. CLAUDE.md is for the rest.

Frequently Asked Questions

The hook never fires. What do I check?

Run /hooks and confirm it appears under the right event. Matchers are case sensitive and match the tool name exactly, so edit matches nothing. Then check the event: PreToolUse is before, PostToolUse is after a success.

Where do I see what the hook printed?

Nowhere, if it succeeded quietly. Ctrl+O opens the transcript view, where a block message or a hook error notice shows. For exit codes and stderr, start with claude --debug-file C:\Temp\claude.log and tail the file.

Does a hook run through Git Bash or PowerShell on Windows?

A single command string runs through Git Bash when it is installed, otherwise PowerShell; add "shell": "powershell" to force it. The command plus args form spawns the program directly and sidesteps the question, which is why the examples here use it.

Can a PostToolUse hook stop the edit?

No, the edit has already happened. It can add a reason Claude sees next to the result, as the parse hook does, or replace what Claude sees with updatedToolOutput. To prevent an action, use PreToolUse and exit 2.

Can I make the hook run a formatter instead of a check?

Yes, that is the docs’ own first example: a PostToolUse hook on Edit|Write that passes tool_input.file_path to Prettier. Swap the parser call in the script for whatever formats your language and exit 0.


Related

If SQL Server is part of your day job, my current work lives over at sqldba.blog: production DBA scripts, an error library and the SSMS guide.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

Looking for SQL Server?

This site started as a SQL Server blog in 2017 and the archive is still here. The new SQL Server writing, and all the scripts, moved to a site of their own.

sqldba.blogScripts, error library, wait types, SSMS
Browse by topic