Guides
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:
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):
{
"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_activecheck 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:
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; the broader picture is in when your agent says done.
See real answers, unedited · Windows x64; Python and TypeScript/JavaScript.