A hook is a tiny script that fires at a set moment: when Claude boots, after every tool call, when the reply finishes. You register it once in .claude/settings.json, and from then on it fires itself, every single time. No reminders, no relying on Claude to remember.
This loop uses four of those moments (the names are case-sensitive, spell them exactly like this):
SessionStart fires when Claude bootsPostToolUse fires after every tool call that workedPostToolUseFailure fires after every tool call that failedStop fires when Claude finishes its replyWatch โ log โ review โ improve:
.claude/hooks/logs/, completely hands off.claude/hooks/lessons.md as plain one-line rulesHere is everything you are about to create:
your-project/
โโโ .claude/
โโโ settings.json <- the hooks block goes here
โโโ hooks/
โโโ log_tool.py <- file 1, the tool watcher
โโโ log_run.py <- file 2, the run watcher
โโโ review_trigger.py <- file 3, the reviewer
โโโ load_lessons.sh <- file 4, the reloader
โโโ lessons.md <- the rule book (grows on its own)
โโโ logs/ <- one log file per day
Fires after every tool call and appends one line: time, ok or FAIL, tool name, and a peek at the input. One file listens to both events, because Anthropic splits success and failure into PostToolUse and PostToolUseFailure.
#!/usr/bin/env python3
# File 1, the tool watcher: writes one line per tool call, which tool ran and did it work
import datetime, json, os, pathlib, sys
data = json.load(sys.stdin) # Claude hands every hook a JSON report on stdin
logs = pathlib.Path(os.environ.get("CLAUDE_PROJECT_DIR", ".")) / ".claude" / "hooks" / "logs"
logs.mkdir(parents=True, exist_ok=True)
status = "ok" if data.get("hook_event_name") == "PostToolUse" else "FAIL" # same script listens to both events
detail = json.dumps(data.get("tool_input", {}), ensure_ascii=False)[:160] # first 160 chars of what the tool was doing
with open(logs / f"{datetime.date.today()}.log", "a") as f:
f.write(f"{datetime.datetime.now():%H:%M:%S} {status} {data.get('tool_name', '?')} {detail}\n")
Fires when the run ends and saves Claude's closing words, so each day's log reads like a story: what ran, what failed, how it ended.
#!/usr/bin/env python3
# File 2, the run watcher: when a run ends, saves how it wrapped up to the same log
import datetime, json, os, pathlib, sys
data = json.load(sys.stdin) # the Stop report includes Claude's last message
logs = pathlib.Path(os.environ.get("CLAUDE_PROJECT_DIR", ".")) / ".claude" / "hooks" / "logs"
logs.mkdir(parents=True, exist_ok=True)
ending = " ".join((data.get("last_assistant_message") or "").split())[:400] # Claude's closing words, trimmed
with open(logs / f"{datetime.date.today()}.log", "a") as f:
f.write(f"--- run ended {datetime.datetime.now():%H:%M:%S} --- {ending}\n\n")
Counts finished runs. Runs 1 to 4 pass straight through. On run 5 it answers with {"decision": "block"}, which is the documented way a Stop hook says "not yet": Claude stays up, reads the reason, does the review, then stops. The counter ticks to 6 on the way out, so it can never loop forever.
#!/usr/bin/env python3
# File 3, the reviewer: every 5th run, holds the door and sends Claude back to turn logs into rules
import json, os, pathlib, sys
base = pathlib.Path(os.environ.get("CLAUDE_PROJECT_DIR", ".")) / ".claude" / "hooks"
base.mkdir(parents=True, exist_ok=True)
counter = base / "run_count.txt"
count = int(counter.read_text().strip() or 0) + 1 if counter.exists() else 1 # one tick per finished run
counter.write_text(str(count))
if count % 5: # runs 1 to 4 pass straight through
sys.exit(0)
print(json.dumps({"decision": "block", "reason":
"Review pass. Read today's file in .claude/hooks/logs/ and look for four things: "
"repeated failures, slow spots, bad outputs, and quality problems. Rewrite .claude/hooks/lessons.md "
"as plain one-line rules, most useful first, 20 rules max, keeping old rules that still apply. Then stop."}))
Fires at boot. Whatever a SessionStart hook prints to stdout gets added to Claude's context (that is documented behavior, not a trick), so the rule book loads itself.
#!/bin/bash
# File 4, the reloader: at boot, print the rule book so it loads straight into Claude's context
LESSONS="${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/lessons.md"
[ -s "$LESSONS" ] && echo "Lessons learned from past runs in this project, follow these:" && cat "$LESSONS"
exit 0
This goes in .claude/settings.json in your project root. Create the file if it doesn't exist; if you already have one, merge the "hooks" key into it:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear",
"hooks": [
{ "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/load_lessons.sh\"" }
]
}
],
"PostToolUse": [
{
"hooks": [
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/log_tool.py\"" }
]
}
],
"PostToolUseFailure": [
{
"hooks": [
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/log_tool.py\"" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/log_run.py\"" },
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/review_trigger.py\"" }
]
}
]
}
}
Reading it back: SessionStart runs the reloader on boot, resume, and /clear (that pipe list is the matcher). Both tool events feed the same watcher. Stop takes no matcher (it fires on every finished run) and runs the run watcher and the reviewer side by side. Keeping this at project level means each project grows its own rule book.
mkdir -p .claude/hooks/logs.claude/hooks/ with those exact names.claude/settings.json/hooks and check the four events are listed (edits to settings files are picked up automatically, the /hooks menu is just your receipt)Paste this into Claude Code inside your project and it builds the whole loop for you:
Build me a self-correcting loop with Claude Code hooks, following the official hooks reference at code.claude.com/docs/en/hooks. Create .claude/hooks/ with four files: (1) log_tool.py, registered on PostToolUse and PostToolUseFailure, appends one line per tool call (time, ok or FAIL, tool name, first 160 chars of tool_input) to .claude/hooks/logs/YYYY-MM-DD.log. (2) log_run.py, registered on Stop, appends the run's closing message to the same log. (3) review_trigger.py, registered on Stop, counts finished runs in .claude/hooks/run_count.txt and on every 5th run prints {"decision": "block", "reason": "..."} telling you to read the logs for repeated failures, slow spots, bad outputs and quality problems, then rewrite .claude/hooks/lessons.md as plain one-line rules, 20 max. (4) load_lessons.sh, registered on SessionStart, prints lessons.md to stdout so it loads into context. Register everything in .claude/settings.json with $CLAUDE_PROJECT_DIR paths, show me the final hooks block, then prove logging works with one test tool call.
.claude/hooks/logs/ and check today's file has ok lines in itcat a_file_that_does_not_exist.txt. A FAIL line appears in the log, that is PostToolUseFailure catching itecho 4 > .claude/hooks/run_count.txt, finish one more request, and watch Claude hold the door, read its own logs, and write lessons.md before it stops.claude/hooks/logs/ and run_count.txt to .gitignore, and keep lessons.md committed only if you want the team sharing one rule booklessons.md the first few times and delete anything wrong, it is just a text fileCLAUDE.md (the file Claude reads every session) and it sticks for good/hooks