# A Claude Code Stop hook that runs your type checker

> A Claude Code Stop hook tested on Windows (Python v3.12 with pyright) that type-checks changed files before the agent finishes, without looping.

A Stop hook runs when Claude Code tries to end its turn; if your script exits with code 2, the agent keeps working and sees why. Check `stop_hook_active` so the hook doesn't loop, and scope it to changed files.

We ran this script on Windows with Python v3.12 and pyright: it exits 2 on a type error, and exits 0 on clean code and when `stop_hook_active` is set. The `tsc` branch is not tested by us. The hook facts below come from Anthropic's Claude Code hooks reference (`code.claude.com/docs/en/hooks`).

Instructions in a prompt or a skill ("always run the type checker before you finish") are advice, and agents don't always follow advice. A hook isn't advice. It runs whether the agent remembers or not.

## Step 1: write the check

Any command works: tests, a type checker, a linter. Here's a small Python script that finds the files that differ from your last commit, runs `pyright` on the Python ones and `tsc` on a TypeScript project, and holds Claude if either reports an error. Save it as `.claude/hooks/check_before_stop.py`:

```python
import json, shutil, subprocess, sys

def run(*cmd):
    # shutil.which finds npx.cmd and pyright.cmd on Windows
    return subprocess.run([shutil.which(cmd[0]) or cmd[0], *cmd[1:]], capture_output=True, text=True)

event = json.load(sys.stdin)

# Claude is already continuing because of this hook: let it stop,
# so a check that can't pass never traps it in a loop.
if event.get("stop_hook_active"):
    sys.exit(0)

changed = run("git", "diff", "--name-only", "HEAD").stdout.split()
py = [f for f in changed if f.endswith(".py")]
ts = [f for f in changed if f.endswith((".ts", ".tsx"))]

problems = []
if py:
    r = run("pyright", *py)
    if r.returncode != 0:
        problems.append(r.stdout[-2000:])
if ts:
    r = run("npx", "tsc", "--noEmit")
    if r.returncode != 0:
        problems.append(r.stdout[-2000:])

if problems:
    print("The type checker reports errors after your edits. Fix them, or tell the user why:",
          file=sys.stderr)
    print("\n".join(problems), file=sys.stderr)
    sys.exit(2)  # 2 = don't stop; stderr goes to Claude

sys.exit(0)
```

Three details matter:

- **Exit code 2** is the signal. For a Stop hook it prevents Claude from stopping and continues the conversation. Exit 0 lets it stop.
- **stderr is the message.** Write what's wrong and what to do, in plain words. Claude reads it as feedback.
- **`stop_hook_active`** is in the hook's input. It's true when Claude is already continuing because of a stop hook. Check it, or a check that can never pass will hold Claude again and again.

The script scopes the work to files that differ from your last commit, so a session that touched nothing costs nothing. `git diff` does not list brand-new untracked files; add `git ls-files --others --exclude-standard` if you want those too. `tsc` checks the whole project from its `tsconfig.json`, not single files.

## Step 2: register the hook

In `.claude/settings.json` (shared with your team) or `.claude/settings.local.json` (just you):

```json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "python .claude/hooks/check_before_stop.py" }
        ]
      }
    ]
  }
}
```

Restart the session, ask Claude to make a change that breaks a type, and watch it get held at the end of the turn.

On Windows, use `py` instead of `python` in the command if `python` isn't on your PATH. Hooks run through a shell; Claude Code's hooks reference covers the `shell` setting (bash or PowerShell) if your command doesn't start.

## Step 3: keep it fair

A stop hook that fires on everything gets switched off. Three rules keep it useful:

- **Block only on things that are definitely wrong.** A type error is definite. A style warning isn't.
- **Hold once, then let go.** That's what the `stop_hook_active` check is for.
- **Say what to fix, not just "failed".** The more specific the message, the faster Claude fixes it.

## What type checkers miss

Type checkers only see calls whose types they know. Tests only cover what's tested. Neither tells you *which callers your edit didn't update*: a function called through an alias, or passed around as a value, or simply in a file nobody tested.

That's the gap LLMGRAPH's stop check goes after. Its Claude Code plugin registers a stop check for you: when Claude ends a turn after editing, it holds Claude once if calls are certain to break, and names them by file and line:

```text
LLMGRAPH: 8 calls break because of your edits:
src/requests/models.py:526 PreparedRequest.prepare_body: misses "strict"
src/requests/models.py:575 PreparedRequest.prepare_content_length: misses "strict"
...
Fix them, or tell the user why each is fine.
```

It follows the same rules as above: it blocks only on certain, never on weaker labels; it says how many more calls aren't confirmed yet; and `npx -y llmgraph-mcp hook off` switches it off. Keep your type checker in your own Stop hook; the stop check does not replace it. The details are on [the stop check](https://beta.llmgraph.in/docs/stop-check.md); the broader picture is in [when your agent says done](https://beta.llmgraph.in/guides/agent-says-done-but-code-is-broken.md).

[See real answers, unedited](https://beta.llmgraph.in/docs/real-answers.md) · Windows x64; Python and TypeScript/JavaScript.
