Claude Code hooks execute handlers at documented points in the agent's lifecycle. For targeted .NET verification, they can make a small check happen after a relevant edit rather than rely on a reminder in every prompt. That is useful only when the event, input, failure behavior, and check scope are understood.
A hook is not a shortcut to universal correctness. A post-edit check cannot undo an edit that already happened. A targeted test cannot prove every integration contract. A local lifecycle event cannot stand in for an authorized remote CI result.
Information checked October 9, 2026 against the current Claude Code hooks reference. The Windows/PowerShell 7 handler below was exercised with synthetic event inputs in an isolated checkout. Its registration in a live Claude session was not exercised; the handler remains intentionally observational. Review the installed version and current schema before adopting it.
Choose hooks by the question they need to answer
The official reference distinguishes PreToolUse, which runs before a tool call and can block it, from PostToolUse, which runs after a successful tool call. It also documents other lifecycle events with their own inputs and decision contracts.
For a narrow test check after an edit, PostToolUse is a reasonable teaching example. The file has been changed, so verification can observe the new content. If the check fails, the hook can report that failure to Claude, but it cannot prevent the edit retroactively.
For an authorization decision before a command, that is the wrong event. A pre-tool permission boundary needs the documented PreToolUse behavior and the permission system. Do not copy a post-edit script into a pre-tool policy and assume its exit codes mean the same thing.
Likewise, a Stop hook has another purpose and different loop risks. Repeatedly preventing completion can keep the agent working without guaranteeing that it will fix an unavailable environment or an invalid hook. Event selection is part of design, not interchangeable plumbing.
| Hook event | Useful question | Important boundary |
|---|---|---|
PreToolUse |
Should this tool action be allowed before it runs? | Use its documented decision contract; a post-edit script is not an interchangeable policy. |
PostToolUse |
What does a check observe after a successful tool action? | Reporting a failure cannot undo the completed edit. |
Stop |
Is there a specific reason the session should not finish yet? | Repeated continuation does not repair a missing environment or prove correctness. |
The distinction resembles pipeline ordering and short-circuiting: what can be prevented depends on where an operation sits in the sequence. The analogy helps explain ordering, but the actual hook contract comes from Claude's reference.
Keep the example's scope intentionally small
Assume an existing Shipping solution uses an xUnit test project with the documented VSTest-style dotnet test --filter behavior. A small class, DeliveryEstimator, has dedicated regression tests. The hook should run those tests only when Claude's Edit or Write tool changes that class or its dedicated test file.
This example is not intended to cover changes to dependencies, project files, shared helpers, or other modules. Those changes require a wider selection through the repository's normal verification workflow. Narrowness is useful only when its blind spots are stated.
It also watches tool events, not every filesystem change. The current reference explicitly says an Edit|Write matcher does not fire when a Bash command or an external process rewrites the same file. That matters for formatters, generators, and scripts that perform their own edits. A claim that this configuration checks every change to the class would be unsupported.
There are two plausible paths. A post-edit check offers immediate feedback but can execute repeatedly during incomplete edits. A check at a deliberate handoff runs less often and sees a more coherent state, but it relies on another trigger. Neither makes full-solution work after every keystroke necessary.
The example favors immediate feedback for two explicit files. In a real repository that prohibits local builds or tests, do not enable it on a developer workstation. Place it only in an authorized disposable validation environment, or keep verification in remote CI instead.
Configure the actual event nesting
Hooks in project settings use the documented nesting: event name, matcher group, then handler array. The matcher below filters tool names, not file extensions. The script performs the narrower file selection.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "pwsh",
"args": [
"-NoProfile",
"-NonInteractive",
"-File",
"${CLAUDE_PROJECT_DIR}\\.claude\\hooks\\verify-delivery.ps1"
],
"timeout": 120
}
]
}
]
}
}
This JSON configuration was syntax-checked but not registered in a live session. It belongs in .claude\settings.json, merged with existing settings rather than replacing unrelated entries. It requires PowerShell 7's pwsh executable on the hook environment's path.
The current reference documents exec form when args is present: command names the executable, and each argument is passed separately without shell tokenization. The ${CLAUDE_PROJECT_DIR} placeholder is substituted by Claude Code. It is not an instruction to use shell expansion.
The timeout is an explicit budget for this synchronous check. A timeout is not a passing result. The configuration does not include async, so the agent waits for the handler under the documented behavior.
Do not add onFailure: "block" here expecting it to reject the original file edit. PostToolUse is after the edit. Newer failure-handling fields also have version requirements; a post-edit observational example does not need to rely on them.
Read the event from stdin, not invented arguments
Command hooks receive JSON on stdin. Common fields include cwd and hook_event_name; tool events also include tool_name and tool_input. For file Edit and Write calls, this example reads tool_input.file_path.
Save the following illustrative script as .claude\hooks\verify-delivery.ps1 in the authorized checkout. The specific file map is deliberately explicit. It never evaluates the incoming path as a command.
$ErrorActionPreference = 'Stop'
try {
$eventData = [Console]::In.ReadToEnd() | ConvertFrom-Json
}
catch [System.ArgumentException] {
[Console]::Error.WriteLine('Invalid hook JSON input.')
exit 2
}
if ($eventData.hook_event_name -ne 'PostToolUse') {
[Console]::Error.WriteLine('Unexpected hook event.')
exit 2
}
if ([string]::IsNullOrWhiteSpace($eventData.tool_name)) {
[Console]::Error.WriteLine('Hook input has no tool_name.')
exit 2
}
if ($eventData.tool_name -notin @('Edit', 'Write')) {
exit 0
}
if ([string]::IsNullOrWhiteSpace($eventData.tool_input.file_path)) {
[Console]::Error.WriteLine('File tool input has no file_path.')
exit 2
}
if ([string]::IsNullOrWhiteSpace($eventData.cwd)) {
[Console]::Error.WriteLine('Hook input has no cwd.')
exit 2
}
try {
$root = [IO.Path]::GetFullPath($eventData.cwd)
$changed = [IO.Path]::GetFullPath(
$eventData.tool_input.file_path, $root)
}
catch [System.ArgumentException] {
[Console]::Error.WriteLine('Invalid hook path input.')
exit 2
}
$project = Join-Path $root 'tests\Shipping.Tests\Shipping.Tests.csproj'
if (-not (Test-Path -LiteralPath $project -PathType Leaf)) {
[Console]::Error.WriteLine('Run this root-only hook from the approved solution root; verification project is unavailable.')
exit 2
}
$watched = @(
[IO.Path]::GetFullPath(
'src\Shipping\DeliveryEstimator.cs', $root),
[IO.Path]::GetFullPath(
'tests\Shipping.Tests\DeliveryEstimatorTests.cs', $root)
)
if ($changed -notin $watched) {
exit 0
}
$output = & dotnet test $project --filter `
'FullyQualifiedName~Shipping.Tests.DeliveryEstimatorTests' 2>&1
$result = $LASTEXITCODE
foreach ($line in $output) {
[Console]::Error.WriteLine([string]$line)
}
if ($result -ne 0) {
[Console]::Error.WriteLine(
"Targeted verification did not succeed; dotnet exit code: $result")
exit 2
}
@{
hookSpecificOutput = @{
hookEventName = 'PostToolUse'
additionalContext = 'The configured targeted command exited zero. Check discovered test count and retained runner output before claiming coverage.'
}
} | ConvertTo-Json -Depth 4 -Compress
exit 0
The script requires the hook's cwd to be the approved solution root. It checks for the expected project before accepting an unrelated-file no-op. A subdirectory invocation therefore reports unavailable verification instead of silently skipping a watched edit. This root-only contract is simpler than discovering a checkout automatically, but intentionally rejects subdirectory execution.
The current reference warns that CLAUDE_PROJECT_DIR can remain at the original root while cwd follows a session into a worktree. That is why the configuration uses the placeholder to locate the handler, while the handler reads the event's working directory to choose files and the test project.
A production workflow should decide explicitly whether this split is appropriate. A handler launched from one checkout while inspecting another can be useful, but only if both the script version and target checkout are recorded.
Before adoption, exercise the handler with representative event payloads in the authorized validation environment. Include a watched source edit, an unrelated file edit, a malformed payload, a missing project, and a test-command failure. Also confirm that a passing command's retained output identifies the intended tests. These cases validate the routing and reporting contract separately from the delivery estimator's behavior.
Then observe one real Claude tool edit to establish that the installed client supplies the expected payload and invokes the handler. Feeding a synthetic JSON object to the script can test its path selection, but it cannot prove client registration or lifecycle integration. Synthetic inputs were checked for this article: watched and unrelated files, subdirectory invocation, missing fields, malformed JSON, and failing tests produced the expected exits. No real Claude tool edit was performed.
Understand what the exit status means
For command hooks, the reference documents exit 0 as success with optional structured output. Exit 2 has event-specific behavior. On PostToolUse, it shows stderr to Claude; the tool already ran.
The script therefore maps a nonzero test-command result to exit 2 and emits a clear message. It does not forward exit 1 and assume that Claude will treat it as a blocking failure. The reference describes other exits, absent qualifying JSON, as non-blocking hook errors.
An unexpected event or missing required file input is reported explicitly. An unrelated valid file is an intentional no-op. Those are different situations: malformed relevant input is not silently converted into successful verification.
The success output uses the documented hookSpecificOutput with hookEventName: "PostToolUse" and additionalContext. It communicates the narrow observation that the command exited zero, not a declaration that every test passed or the change is correct.
Ordinary test output is written to stderr rather than mixed into structured stdout. That avoids corrupting the JSON response. The docs say stderr from an exit-0 hook is not shown to Claude, so retain runner or debug logs if the detailed passing output is needed for human review.
A zero exit is not proof of meaningful coverage
The example deliberately does not parse every possible test-runner output format. Such parsing would require a versioned output contract, reliable discovery information, and tests of the parser itself. A line of text that resembles “passed” is not a robust universal interface.
Before describing the result as coverage, inspect the discovered test count and skipped cases. Confirm that the runner executed the intended class and source revision. A filter selecting no tests should not be treated as evidence for the behavior.
The current Microsoft selective-test documentation describes FullyQualifiedName filters and framework-specific properties. That verifies the command syntax in the documented context, not that every .NET test platform uses identical arguments.
The assertion still matters. A hook can run a weak test perfectly. Testing a C# processing pipeline provides examples of distinct behavioral contracts that require deliberate scenarios rather than generic success checks.
This is the core limit: automation repeats the check you configured. It does not strengthen that check's assertions or discover every omitted behavior by virtue of being a hook.
Avoid hidden costs and repeated work
A targeted test command may build dependencies, restore packages, or execute fixture setup. These effects are part of the command's cost. Do not describe it as harmless simply because the filter is narrow.
Repeated edits can trigger repeated runs. If the class is edited several times while a change is incomplete, failures may be expected intermediate feedback. The workflow should avoid converting each intermediate failure into another full validation fan-out.
For a large solution, an explicit later check may be more appropriate. Headless automation concepts help frame the distinction between authoring and execution, although that article's client flags do not configure Claude hooks.
Parallelism also needs care. The reference says matching handlers run in parallel. Separate hooks that build the same project can contend for artifacts and produce confusing results. This example registers one handler; adding more should involve deliberate resource coordination.
Avoid using an async hook merely to hide latency. Background feedback arrives on a different timeline and cannot be assumed to gate completion. If the result must be part of the task's evidence, the workflow needs to collect and attribute it before the task is considered verified.
Keep CI as a separate evidence source
A hook observes its configured environment. CI observes the revision and environment of a workflow run. Connecting these requires explicit evidence, not an assumption that a local check will eventually be reflected remotely.
CI may run other target frameworks, operating systems, integration services, and browser tests. Unit and integration boundaries in a modular monolith explain why a small isolated check does not replace collaborating-module evidence.
If a repository requires remote validation, retain the workflow run identifier, source revision, selected checks, and artifacts. A hook could participate in a permitted reporting process, but it should not launch remote work after every edit without a cost and cancellation policy.
Hooks also are not mandatory execution controls in every launch mode. The current headless reference says bare mode skips automatic hook discovery. Managed policy and settings can affect which hooks run. Confirm the effective configuration before relying on any handler.
Frequently asked questions
Can a PostToolUse hook block a bad edit?
It cannot prevent an edit that already succeeded. The documented event runs afterward. Exit 2 reports stderr to Claude for this event; authorization before an action belongs to an appropriate pre-tool control and permission policy.
How does a command hook receive the changed file?
Claude Code sends event JSON on stdin. The example reads tool_input.file_path after checking the event and tool name. The matcher selects Edit or Write calls, while the script compares the path with an explicit file map.
Does exit 1 mean the hook blocks the operation?
Not generally. The current reference gives different semantics to success, exit 2, and other errors, with additional JSON and event-specific qualifications. Use the documented event contract rather than ordinary shell conventions alone.
Should the hook run the whole solution after every edit?
Not automatically. That can duplicate work and create noisy intermediate failures. Choose a check whose scope and cost match the event, then use broader verification at a deliberate handoff according to repository policy.
Does the example support every .NET test runner?
No. It assumes an existing xUnit project using the documented VSTest-style filter behavior. Other runners or repository wrappers require their own verified command. The script was exercised with synthetic inputs; actual Claude registration and invocation remain unverified.
Can this hook replace remote CI?
No. It supplies narrow feedback from its configured environment. Remote CI has its own source revision, dependencies, selected checks, and evidence. Report both accurately instead of treating one as proof that the other happened.
Targeted verification needs a defined contract
Claude Code hooks are useful when their trigger, input, selected check, and failure semantics are explicit. A small observational hook can shorten the feedback loop without becoming a broad policy engine.
The trustworthy claim remains bounded: this handler ran this check in this environment and produced this result. Everything beyond that needs additional evidence.

