A Claude Code hook is a command (or an HTTP request, or a prompt) that Claude Code itself runs when a lifecycle event happens: before a tool runs (PreToolUse), after it runs (PostToolUse), when the agent wants to end its turn (Stop), and about thirty others. The hook receives JSON on stdin and answers with an exit code and, optionally, JSON on stdout. It is deterministic code, not an instruction. It always runs, whatever the model decided.
This post is one of the hands-on guides in the Claude Code guide, which explains where hooks fit in the harness architecture (guides, sensors, the loop that turns errors into rules). This one covers only the how: three real, tested hooks in plain Python, with no jq.
What we will build:
- A
PreToolUsehook that denies edits to a database migration already tracked in git. - A
PostToolUsehook that runsruff formatandruff check --fixon every edited.pyfile and sends what is left back to Claude. - A
Stophook that does not let Claude end its turn with a failing test.
Create the folder structure
Project hooks live in .claude/settings.json, which goes into git and applies to the whole team. The scripts sit next to it:
mkdir -p .claude/hooks
touch .claude/settings.json
touch .claude/hooks/{immutable_migrations,ruff_after_edit,tests_before_stop}.pyIf the hook is just for you, use .claude/settings.local.json (not versioned) or ~/.claude/settings.json (applies to all your projects). Settings from different levels add up: a user hook does not erase a project hook. The full list of locations, including plugins and skill frontmatter, is in the hooks reference.
Register the hooks in settings.json
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/immutable_migrations.py"],
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/ruff_after_edit.py"],
"timeout": 60
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/tests_before_stop.py"],
"timeout": 300
}
]
}
]
}
}Three details that save time:
- The
matcherhas two readings. If it only has letters, digits,_,-, spaces, commas and|, it is an exact match:Edit|Writematches exactly those two tools. Any other character turns the matcher into an unanchored JavaScript regex. That is why the pattern to catch every tool of an MCP server ismcp__memory__.*, with the.*. - Use
args. Withargs, Claude Code runs the binary directly (exec form), without a shell, and each item becomes one argument. Withoutargs, the string goes tosh -c. The docs recommendargswhenever the command references a path placeholder like${CLAUDE_PROJECT_DIR}, because there are no quotes to escape. - Keep the
$schema. With it, your editor validates the file and completes the fields. I validated the file above against the SchemaStore schema before publishing.
The default timeout of a command hook is 600 seconds. Set a lower one. A PreToolUse guard that takes 10 seconds is already too slow.
Write the PreToolUse guard
The hook receives JSON on stdin with tool_name, tool_input and cwd, among other fields. For Edit and Write, the file path is in tool_input.file_path.
#!/usr/bin/env python3
"""PreToolUse: deny Edit/Write on a migration that is already in git."""
import json
import subprocess
import sys
from pathlib import Path
event = json.load(sys.stdin)
path = event.get("tool_input", {}).get("file_path", "")
if "/migrations/" not in path.replace("\\", "/"):
sys.exit(0) # not a migration: no decision, normal flow continues
# A new file in migrations/ is fine. Only block what git already tracks.
tracked = (
subprocess.run(
["git", "ls-files", "--error-unmatch", path],
check=False,
cwd=event.get("cwd") or Path(path).parent,
capture_output=True,
).returncode
== 0
)
if tracked:
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
f"{Path(path).name} is already versioned and may have run in production. "
"Do not edit existing migrations: create a new migration with the change."
),
}
}
)
)
sys.exit(0)The decision goes inside hookSpecificOutput, with permissionDecision set to allow, deny, ask or defer. Claude sees the permissionDecisionReason of a deny, so write it as an instruction: say what to do instead of what was blocked. A PreToolUse deny blocks the tool even in bypassPermissions mode.
Notice what the script does not do. It does not exit with code 1 assuming that blocks. It does not. According to the reference, only exit 2 blocks. Exit 0 is success (and stdout is read as JSON), any other code is a non-blocking error, and the tool runs anyway.
When not to use a hook
If the rule were "never edit anything in migrations/", without the git condition, the right place would be permissions, not a hook:
{
"permissions": {
"deny": ["Edit(migrations/**)"]
}
}The docs themselves say to use the permission system "rather than a hook to enforce a hard allow or deny". Watch out: path rules use Edit(...). A Write(migrations/**) rule is accepted but never consulted. Hooks are for conditional logic that depends on state (git, the time of day, the branch).
Write the PostToolUse sensor
#!/usr/bin/env python3
"""PostToolUse: format the edited .py and send Claude what the linter could not fix."""
import json
import subprocess
import sys
event = json.load(sys.stdin)
path = event.get("tool_input", {}).get("file_path", "")
if not path.endswith(".py"):
sys.exit(0)
subprocess.run(["ruff", "format", "--quiet", path], check=False)
lint = subprocess.run(
["ruff", "check", "--fix", "--quiet", "--output-format=concise", path],
check=False,
capture_output=True,
text=True,
)
if lint.returncode != 0:
# In PostToolUse, exit 2 does not undo the edit: it shows stderr to Claude.
print(
f"ruff found problems in {path}:\n{lint.stdout[-3000:]}",
file=sys.stderr,
)
sys.exit(2)
sys.exit(0)In PostToolUse the tool has already run, so exit 2 does not undo anything. It hands stderr to Claude as feedback, and Claude fixes it on its next action. This is the classic sensor. What ruff fixes on its own (formatting, imports) disappears without spending tokens. Only what needs judgment, like an undefined variable, goes back to the model. The cut at 3,000 characters exists because hook output has a 10,000 character cap. Above that, Claude Code writes the text to a file and sends only a preview.
Write the Stop gate
#!/usr/bin/env python3
"""Stop: do not let Claude end the turn with a failing test."""
import json
import subprocess
import sys
event = json.load(sys.stdin)
r = subprocess.run(
["python3", "-m", "pytest", "-q", "-x", "--no-header"],
check=False,
cwd=event.get("cwd"),
capture_output=True,
text=True,
)
if r.returncode in (0, 5): # 5 = no tests collected
sys.exit(0)
output = r.stdout[-2000:]
if event.get("stop_hook_active"):
# We already blocked once and Claude did not fix it: let it stop, but tell you.
response = {"systemMessage": "Claude stopped with failing tests:\n" + output}
else:
response = {
"decision": "block",
"reason": "The tests failed. Fix them before ending the turn:\n" + output,
}
print(json.dumps(response))decision: "block" with a reason makes Claude keep working, with the reason as its next instruction. The stop_hook_active field arrives as true when Claude is already continuing because of a Stop hook. The example in the docs exits when it is true. I chose not to exit silently. The script lets the turn end but sends a systemMessage, which you see and the model does not. That way a broken test never ends in silence.
Even if you forget that check, Claude Code has a safety stop. After 8 consecutive continuations caused by Stop hooks, it ignores the next block and ends the turn (CLAUDE_CODE_STOP_HOOK_BLOCK_CAP changes the limit). Do not rely on it. Eight runs of a test suite are expensive.
Test each hook before you open Claude Code
A hook is a program that reads stdin. Test it like a program:
echo '{"hook_event_name":"PreToolUse","tool_name":"Edit","cwd":"'"$PWD"'",
"tool_input":{"file_path":"'"$PWD"'/migrations/0001_initial.py"}}' \
| python3 .claude/hooks/immutable_migrations.py; echo "exit=$?"{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "0001_initial.py is already versioned and may have run in production. Do not edit existing migrations: create a new migration with the change."}}
exit=0In the example repository, I wrote a script that creates a temporary git repo and runs 8 cases: a tracked migration, a new migration, a regular file, a .py that is only badly formatted, a .py with an error ruff cannot fix, green tests, red tests and red tests with stop_hook_active. All 8 behaved as expected.
Then, inside Claude Code, /hooks shows what was loaded and from where (the menu is read-only; you edit the JSON). To see the exit code, stdout and stderr of each run, start with claude --debug-file /tmp/claude.log and follow it with tail -f.
How we use it at Capiva
capivaOS, the open source harness we use on projects, registers one PreToolUse guard on Edit|MultiEdit|Write|NotebookEdit and another on Bash|PowerShell, both with a 10 second timeout (hooks.json). Two decisions from it that I would recommend to any team:
- The rule left the prompt. ADR-0008 records that a "never do X" in the prompt is not a guardrail, and moved enforcement into a hook.
- Fail open, but loudly. If the guard itself breaks, it lets the action through and writes a loud warning to stderr. It also writes a heartbeat to a file so you can check that it is running. A guard that blocks all work when it has a bug tends to get switched off by the team, and then it protects nothing.
Troubleshooting checklist
- [ ] The hook does not fire. Check in
/hooksthat it was loaded and from which file. Check that the matcher is exact (Edit|Write) and that the tool name is right, with the capital letter. - [ ] The block does not block. Did you exit with 1? Only exit 2 blocks. Or use the JSON with
permissionDecision: "deny"and exit 0. - [ ] The JSON is ignored without an error. Something wrote to stdout before the JSON, usually an
echoin~/.bashrcloaded throughBASH_ENV. Look forHook output does not start with {in the debug log. - [ ]
permissionDecisionhas no effect. It is at the top level of the JSON instead of insidehookSpecificOutput. The log showsHook JSON output had unrecognized keys. - [ ] The script path does not resolve. Use
${CLAUDE_PROJECT_DIR}withargs, not a relative path. The hook can run with a different current directory. - [ ] Stop goes into a loop. Handle
stop_hook_active. - [ ] You need to turn everything off now. Set
"disableAllHooks": truein settings, or runclaude --settings '{"disableAllHooks": true}'for a single session. Hooks from managed settings keep running. - [ ] Python or ruff "not found". Exec form looks for the binary in the
PATHof the Claude Code process, not in the venv you activated in another terminal. On Windows, see the shell differences in How to install Claude Code on Windows.
Frequently asked questions
Does a hook replace CLAUDE.md?▼
No. CLAUDE.md guides and the hook enforces. A rule that must always hold goes into a hook. Context that helps the model decide stays in CLAUDE.md. A procedure you use now and then becomes a skill.
Can I write hooks in another language?▼
Yes. A command hook is any executable that reads JSON from stdin. There are also http, mcp_tool, prompt and agent hooks.
Can a PostToolUse hook undo the edit?▼
No. The tool has already run. It can send feedback (exit 2 or decision: "block") so Claude fixes it on its next action.
Does the hook run in bypassPermissions mode?▼
Yes. PreToolUse fires before any permission mode check, and a deny blocks even with --dangerously-skip-permissions.
Read next
- Claude Code is a harness: CLAUDE.md, hooks, MCP and subagents (the main guide)
- How to write a CLAUDE.md the agent follows
- How to create a Skill in Claude Code
- How to build an MCP server in Python and configure it in Claude Code
- How to install Claude Code on Windows
- Guardrails and evals: how to know your agent got it right
Want these guards ready and tested in your repository? See capivaOS.