There's a quiet disappointment in opening a PR and seeing nothing but "LGTM" from a reviewer. In a busy development team, giving every commit the attention it deserves isn't always realistic. To close that gap, I built a setup using Claude Code's hook system that automatically reviews code as I work — no manual triggering required.
What surprised me most was that hooks aren't just a "run a command" feature. By combining PreToolUse, PostToolUse, and Stop events thoughtfully, you create an interactive quality layer that runs alongside Claude Code itself — distinct from CI/CD, and far more immediate.
Understanding When Hooks Fire
Hooks are defined in .claude/settings.json. There are four trigger points:
PreToolUse: Before a tool runs (returningexit 2blocks the tool)PostToolUse: After a tool completes (use this for analysis and reporting)Notification: When Claude sends a notificationStop: When the session ends
For automatic code review, PostToolUse on the Bash tool is the right choice. Every time Claude Code runs a shell command, the hook fires and checks whether any tracked files changed.
One thing to get right early: use matcher to target specific tools. Attaching heavy processes to every tool invocation makes Claude Code noticeably sluggish.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/auto-review.py"
}
]
}
]
}
}The Review Script
Claude Code passes context to hooks via stdin as JSON. Here's the script that reads that context, detects changed files, and asks Claude to review them:
#!/usr/bin/env python3
import json
import sys
import subprocess
def get_changed_files():
result = subprocess.run(
["git", "diff", "--name-only", "HEAD"],
capture_output=True, text=True
)
files = result.stdout.strip().split("\n")
return [f for f in files if f.endswith((".ts", ".tsx", ".py", ".go"))]
def review_with_claude(code: str, file_path: str):
prompt = f"""Review the following code briefly.
If there are issues, describe them. If everything looks fine, just respond with "OK".
File: {file_path}{code[:2000]}
result = subprocess.run(
["claude", "-p", prompt, "--output-format", "text"],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0:
print(result.stdout)
def main():
try:
hook_input = json.loads(sys.stdin.read())
except json.JSONDecodeError:
return
if hook_input.get("tool_name") != "Bash":
return
changed_files = get_changed_files()
if not changed_files:
return
for file_path in changed_files[:3]:
try:
with open(file_path) as f:
code = f.read()
except (FileNotFoundError, PermissionError):
continue
print(f"\n📋 Auto-review: {file_path}")
review_with_claude(code, file_path)
if __name__ == "__main__":
main()In practice, the review comments appear inline in the terminal as Claude Code works. It felt noisy at first, but after a few sessions it became useful — catching loose variable names and missing error handling that I would have deferred to "later."
Generating Session Reports with Stop Hooks
The Stop hook runs when a Claude Code session ends, which makes it ideal for summarizing what happened.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/session-report.py"
}
]
}
]
}
}#!/usr/bin/env python3
import subprocess
from datetime import datetime
from pathlib import Path
def get_recent_commits():
result = subprocess.run(
["git", "log", "--since=6 hours ago", "--oneline", "--no-merges"],
capture_output=True, text=True
)
return result.stdout.strip()
def generate_summary(commits: str) -> str:
if not commits:
return "No commits in this session."
prompt = f"""Summarize the following git commits in 3 bullet points.
Use plain language that's easy to share in a standup.
Commits:
{commits}"""
result = subprocess.run(
["claude", "-p", prompt, "--output-format", "text"],
capture_output=True, text=True, timeout=30
)
return result.stdout if result.returncode == 0 else "Summary generation failed."
def main():
commits = get_recent_commits()
summary = generate_summary(commits)
log_dir = Path.home() / "work-logs"
log_dir.mkdir(exist_ok=True)
today = datetime.now().strftime("%Y-%m-%d")
log_file = log_dir / f"{today}.md"
with open(log_file, "a") as f:
timestamp = datetime.now().strftime("%H:%M")
f.write(f"\n## {timestamp} Session ended\n\n{summary}\n")
print(f"📝 Work log saved: {log_file}")
if __name__ == "__main__":
main()These logs become genuinely useful when writing weekly summaries or project status updates — things that normally require reconstructing history from memory.
The Gotcha with PreToolUse Blockers
PreToolUse hooks that return exit 2 can block tool execution entirely. This sounds powerful, but there's a failure mode worth knowing: if your blocker is too broad, Claude Code enters a loop where it cannot recover.
I learned this when I built a hook to block Bash execution whenever TypeScript had compilation errors. The problem: Claude needs to run Bash commands to fix those errors. Block the tool, block the fix, block the tool again.
The design principle that works: never block the tools Claude needs to self-correct. Use precise matcher patterns and limit blocking to genuinely dangerous commands:
#!/usr/bin/env python3
import json
import sys
import re
DANGEROUS_PATTERNS = [
r"rm\s+-rf\s+/",
r"DROP\s+TABLE",
]
def main():
try:
hook_input = json.loads(sys.stdin.read())
except json.JSONDecodeError:
sys.exit(0)
command = hook_input.get("tool_input", {}).get("command", "")
for pattern in DANGEROUS_PATTERNS:
if re.search(pattern, command, re.IGNORECASE):
print(f"Blocked dangerous command: {command[:100]}")
sys.exit(2)
if __name__ == "__main__":
main()When exit 2 fires, Claude Code receives your stdout message and uses it to decide what to do next. A descriptive message helps Claude take a sensible alternative action rather than retrying the same blocked command.
Scope: User vs. Project
Hooks can live in two places:
~/.claude/settings.json— applies to all projects.claude/settings.json— applies to the current project only
The review and reporting hooks covered here are generic enough for the user scope. Project-specific blockers (say, one that enforces your team's linting rules) belong in the project scope so they don't interfere with other work.
Setup is minimal:
mkdir -p ~/.claude/hooks
chmod +x ~/.claude/hooks/auto-review.py
chmod +x ~/.claude/hooks/session-report.pyStart with the Stop hook and the session report. It has no risk — it only runs after Claude Code finishes — and you'll immediately have a habit of ending sessions with a written summary rather than a blank memory.