You’ve been working with Claude Code for an hour. It implemented a new endpoint, updated the validator, adjusted the mapping. Everything looks good. You type dotnet test and three tests fail.

Not because the code is wrong. Because Claude reformatted a file, changed a namespace, and broke a test assertion that depended on a specific string format. Small things. Things you would have caught immediately — if you’d run the tests after every edit instead of at the end.

This is exactly the problem hooks solve.


What are hooks?

Claude Code hooks are shell commands that run automatically at specific moments during a session. You don’t have to remember to ask “run the tests.” You configure it once, and it happens every time.

There are four hook types:

  • PreToolUse — runs before Claude executes a tool (like reading or editing a file)
  • PostToolUse — runs after a tool executes (perfect for formatting)
  • Stop — runs when Claude finishes a task (ideal for tests)
  • Notification — for custom notifications

You configure them in .claude/settings.json. That’s it. No plugins, no extensions, no third-party tools.


The Stop hook: run tests automatically

In an earlier post I wrote about the principle “trust, but always verify.” The Stop hook turns that principle into an automated check.

Here’s what I use in my .NET projects:

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "dotnet test --no-build --verbosity quiet 2>&1 | tail -5"
          }
        ]
      }
    ]
  }
}

Every time Claude finishes a task, dotnet test runs. The --no-build flag skips the build step (Claude’s edit already triggered a build in most setups). The tail -5 keeps the output short — you only see the summary.

If tests fail, Claude sees the output and can fix the issue immediately. No context switch. No manual step. The feedback loop is instant.

The matcher field is empty here, which means it runs on every Stop. You could also match on specific patterns if you only want it to trigger in certain situations.


PostToolUse: format after every edit

If you’re working in a team with a .editorconfig or dotnet format rules, you know the drill. Someone’s PR has formatting changes mixed in with logic changes. The diff is unreadable. The reviewer is annoyed.

With a PostToolUse hook, every file Claude touches gets formatted immediately:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "dotnet format --include \"$CLAUDE_FILE_PATH\" --verbosity quiet"
          }
        ]
      }
    ]
  }
}

The matcher here is "Edit|Write" — it only triggers when Claude edits or writes a file. Not when it reads or searches. The $CLAUDE_FILE_PATH variable gives you the exact file that was changed.

This means every file Claude touches is formatted according to your team’s rules. No exceptions. No “oops, I forgot to run the formatter.”


PreToolUse: guard before execution

PreToolUse hooks run before Claude does something. Think of them as guardrails.

A simple example: blocking edits to files you don’t want Claude touching.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"$CLAUDE_FILE_PATH\" | grep -q 'migrations/' && echo 'BLOCK: Do not edit migration files' && exit 1 || exit 0"
          }
        ]
      }
    ]
  }
}

If Claude tries to edit anything in the migrations/ folder, the hook blocks it. In a .NET project with EF Core, you absolutely don’t want AI-generated changes in your migration files.


Team vs. personal: settings.json vs. settings.local.json

This is where it gets practical for teams.

.claude/settings.json is checked into git. Every developer on the team gets the same hooks. The Stop hook that runs tests? That’s a team decision. The PostToolUse formatter? Team decision. You commit it, everyone benefits.

.claude/settings.local.json is personal. It’s not in git. This is where you put your own preferences — maybe a notification hook that sends you a Slack message, or a PreToolUse hook that matches your personal workflow.

The local file overrides the shared one for matching hooks. So the team sets the baseline, and you can adjust for your own setup.

For a .NET team, I’d recommend putting these in the shared settings.json:

  1. Stop hook: run dotnet test after every task
  2. PostToolUse hook: run dotnet format after every edit
  3. PreToolUse hook: block edits to migration files

Everything else — notifications, personal linting preferences, experimental hooks — goes in settings.local.json.


Honest limitations

Hooks are powerful, but they’re not magic.

They add time. Every Stop hook adds the execution time of dotnet test to every task completion. For a large solution with slow tests, this can be frustrating. Use --no-build and consider filtering to only run fast unit tests, not integration tests.

They’re shell commands. If your command fails silently or returns a non-zero exit code unexpectedly, things get confusing. Test your hook commands manually first.

They don’t replace code review. A green test suite doesn’t mean the code is good. It means the code doesn’t break existing behavior. Architecture decisions, naming, readability — that’s still on you.

Matcher patterns are simple. The matcher field matches tool names, not file patterns or project names. For more granular control, you’ll need to handle the logic in your shell command.


Start with one hook

You don’t need to set up everything at once. Start with the Stop hook that runs your tests. Live with it for a week. You’ll notice that Claude catches its own mistakes more often, and your feedback loop shrinks from “end of session” to “end of every task.”

Then add the formatter. Then the guardrails.

The goal isn’t to automate everything. It’s to automate the things you keep forgetting — so you can focus on the things that actually need your judgment.