Claude Code Hooks That Make Claude Fix Its Own Mistakes

Four Claude Code hooks that lint every edit, log failed tool calls, block a finish while tests fail and load lessons at startup

The most expensive sentence an agent can say is "Done." Half the time it means done, and the other half it means "I stopped." Claude Code hooks close that gap. They are small scripts that fire at fixed moments, check the work with real tools, and hand the result back to Claude so it fixes its own mistake before you ever see it.

What a hook actually is

A hook is a command registered in .claude/settings.json that Claude Code runs on its own at a named event. You do not ask for it and Claude cannot forget it. That is the whole point. Instructions in a prompt are suggestions. A hook is a rule the harness enforces every time.

Four events do the heavy lifting for self correction. Event names are case sensitive, so type them exactly:

  • PostToolUse runs after a tool call succeeds, for example after Claude edits a file
  • PostToolUseFailure runs after a tool call fails, such as a command that exits with an error
  • Stop runs when Claude finishes its reply and is about to hand control back to you
  • SessionStart runs when a session starts, resumes, clears or compacts

The trick is how a hook talks back. For PostToolUse, exiting with code 2 sends whatever you printed to stderr straight to Claude. For Stop, printing {"decision": "block", "reason": "..."} keeps Claude working and gives it the reason. For SessionStart, anything printed to stdout is added to Claude's context. Those three channels are the entire loop.

The loop in one picture

  1. Claude edits a file. A hook lints that one file and pushes any errors back immediately.
  2. A tool call fails. A hook writes one line to a failure log.
  3. Claude tries to finish. A hook runs the tests, and if they fail, Claude is sent back with the output.
  4. Once a week you turn the failure log into short lessons, and a hook loads those lessons at the start of every session.

Steps 1 to 3 are automatic. Step 4 keeps a human in charge of what becomes a rule, which is where I think it belongs. A model rewriting its own rule book with nobody reading it drifts fast.

Hook 1: check every edit

Save this as .claude/hooks/check_edit.py. It lints only the file Claude just touched, so it stays fast. Swap ESLint for whatever checker your project uses.

#!/usr/bin/env python3
# Lints the file Claude just edited and hands errors back to Claude.
import json, os, subprocess, sys

data = json.load(sys.stdin)
path = data.get("tool_input", {}).get("file_path", "")
if not path.endswith((".ts", ".tsx", ".js", ".jsx")):
    sys.exit(0)

os.chdir(os.environ.get("CLAUDE_PROJECT_DIR", "."))
result = subprocess.run(["npx", "eslint", path], capture_output=True, text=True)
if result.returncode != 0:
    print(f"Lint failed for {path}. Fix this before moving on:\n{result.stdout[-3000:]}", file=sys.stderr)
    sys.exit(2)

Exit code 2 matters. Exit 1 only shows a notice in the transcript. Exit 2 puts the message in front of Claude, and Claude reacts to it on the very next step.

Hook 2: log every failure

Save as .claude/hooks/log_failure.py. It keeps one JSON line per failed tool call, which is easy for you and for Claude to read later.

#!/usr/bin/env python3
# Appends one line per failed tool call to .claude/hooks/failures.jsonl
import datetime, json, os, pathlib, sys

data = json.load(sys.stdin)
log = pathlib.Path(os.environ.get("CLAUDE_PROJECT_DIR", ".")) / ".claude" / "hooks" / "failures.jsonl"
entry = {
    "time": datetime.datetime.now().isoformat(timespec="seconds"),
    "tool": data.get("tool_name"),
    "input": json.dumps(data.get("tool_input", {}), ensure_ascii=False)[:200],
    "error": (data.get("error") or "").split("\n")[0][:200],
}
with open(log, "a") as f:
    f.write(json.dumps(entry, ensure_ascii=False) + "\n")

Hook 3: no "done" while tests fail

This is the one that changes how Claude behaves. Save as .claude/hooks/finish_gate.py. If nothing changed, it lets Claude stop. If files changed and the tests fail, it blocks the stop once and sends the failure output back.

#!/usr/bin/env python3
# Blocks the finish while tests fail. Allows one retry, then hands it to you.
import json, os, subprocess, sys

data = json.load(sys.stdin)
os.chdir(os.environ.get("CLAUDE_PROJECT_DIR", "."))

changed = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True).stdout.strip()
if not changed:
    sys.exit(0)

tests = subprocess.run(["npm", "test", "--silent"], capture_output=True, text=True)
if tests.returncode == 0:
    sys.exit(0)

if data.get("stop_hook_active"):
    print(json.dumps({"systemMessage": "Tests still fail after a retry. Stopping so you can look."}))
    sys.exit(0)

print(json.dumps({
    "decision": "block",
    "reason": "Files changed and the test suite fails. Fix the cause, run npm test again, then finish.\n\n"
              + (tests.stdout + tests.stderr)[-3000:],
}))

The stop_hook_active check is your safety valve. It is true when Claude is already continuing because of a Stop hook, so this script gives one retry and then stops and tells you. Claude Code also ends the turn by itself after 8 blocks in a row, but I would not rely on that as the design.

Hook 4: load the lessons

Create .claude/lessons.md with a handful of one line rules. The SessionStart hook prints it, and printed output becomes context. Including compact in the matcher means the lessons come back after a long session gets compacted, which is exactly when they tend to get lost.

Wire it up

Merge this into .claude/settings.json at the project root:

{
  "hooks": {
    "PostToolUse": [
      { "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/check_edit.py\"" }] }
    ],
    "PostToolUseFailure": [
      { "hooks": [{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/log_failure.py\"" }] }
    ],
    "Stop": [
      { "hooks": [{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/finish_gate.py\"" }] }
    ],
    "SessionStart": [
      { "matcher": "startup|resume|clear|compact",
        "hooks": [{ "type": "command", "command": "cat \"$CLAUDE_PROJECT_DIR/.claude/lessons.md\" 2>/dev/null || true" }] }
    ]
  }
}

Then type /hooks inside Claude Code. It is a read only view, but it shows every event, the matcher and which settings file each hook came from. If a hook is missing there, it will not fire.

Test it on purpose

  1. Ask Claude to write a line that breaks one of your lint rules. Watch it get the error and fix the line without you saying anything.
  2. Ask it to cat a file that does not exist. A new line appears in failures.jsonl.
  3. Break a test, ask for an unrelated change, and let it finish. It should come back, read the failing output and fix it.
  4. Start a new session and ask which lessons are loaded. It should list yours.

The weekly review

Once a week, paste this into Claude Code:

Read .claude/hooks/failures.jsonl. Group the failures by cause, not by tool.
For any cause that shows up three or more times, propose one short rule for
.claude/lessons.md that would have prevented it. Keep the file under 20 lines.
Show me the proposed diff and wait for my approval before writing anything.

When a lesson proves itself for a few weeks, move it into CLAUDE.md and delete it from the lessons file. Keep lessons short. Hook output that becomes context is capped at 10,000 characters, and long rule books get ignored well before that.

What to watch

  • Hooks run as you, with your permissions. Read every script before you register it, including ones you find online.
  • Keep them fast. A Stop hook that runs a ten minute suite makes every reply feel broken. Point it at the fast tests.
  • Add .claude/hooks/failures.jsonl to .gitignore. It can contain file paths and command text.
  • If you need everything off for a moment, set "disableAllHooks": true in your settings.

Start with the finish gate alone. It is one script and it removes the most common failure I see in agent work, which is a confident summary on top of a red test suite. Add the other three once you trust it. The full list of events and output fields is in the hooks reference, and the hooks guide has more starter scripts.

More in Agents

← All guides