DEV Community

Sungsoo Youn
Sungsoo Youn

Posted on

The Hook System — Blocking AI Mistakes with Structure

This is chapter 4 of my book **Building Autonomous AI Agents with Claude Code* — a field guide to turning Claude Code from a coding assistant into an agent that remembers, verifies its own work, and knows when to stop. Everything below is from a system I actually run every day on one Windows PC.*

1. A Hook Is a Safety Mechanism Outside the AI

A rules file is something the AI tries to follow; a hook is something the system uses to make it be followed.
This difference is bigger than it looks. Rules get buried as context grows longer, get skipped when things are urgent,
and "just this once" exceptions pile up. Hooks don't do that.

Point Timing Typical use
UserPromptSubmit Right after the user types input Automatic context injection (record summaries, related rules)
PreToolUse Right before a tool runs Blocking dangerous actions (gates)
PostToolUse Right after a tool runs After-the-fact checks (contamination detection, follow-up procedure reminders)
Stop When the response ends Quality gates (forbidden-word detection, verification requirements)

Registration happens in one place, the settings file.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [{ "type": "command",
                    "command": "python C:/hooks/record_gate.py" }]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Pattern A — The Blocking Hook (Gate)

This is a gate that blocks "attempts to modify a file without reading the records first." What follows is
a shortened version of one actually in use.

import json, sys, time
from pathlib import Path

STATE = Path(tempfile.gettempdir()) / "read_state.json"
REQUIRED = ["memory/diary.md", "memory/mistakes.md"]

payload = json.load(sys.stdin)            # hooks receive the tool call on stdin
tool = payload.get("tool_name", "")

if tool == "Read":
    state = json.loads(STATE.read_text()) if STATE.exists() else {}
    state[payload["tool_input"]["file_path"]] = time.time()
    STATE.write_text(json.dumps(state))
    sys.exit(0)

state = json.loads(STATE.read_text()) if STATE.exists() else {}
missing = [f for f in REQUIRED if not any(f in k for k in state)]
if missing:
    print(f"""🔒 Records not read yet. Read these before editing:
  {chr(10).join('  - ' + m for m in missing)}
Retry the same action after reading and it will pass.""", file=sys.stderr)
sys.exit(0)
Enter fullscreen mode Exit fullscreen mode

The key is the last sentence. If you also return "what to do to pass," the AI recovers on its own.
The rule gets enforced without a human having to step in.

3. Pattern B — The Injection Hook

On every input, automatically insert the most recent contents of the records.

import sys
from pathlib import Path

def tail(path, n=5):
    if not Path(path).exists():
    heads = [ln for ln in Path(path).read_text(encoding="utf-8").splitlines()
             if ln.startswith("## ")]
    return "\n".join(f"  {h}" for h in heads[-n:])

{tail('memory/diary.md')}
{tail('memory/mistakes.md')}
Enter fullscreen mode Exit fullscreen mode

That said, injection is strictly an aid. It breeds the habit of looking only at the summary and never reading
the original, so for important work we still use a gate to force a Read of the original.
(Our gate message actually contains this sentence — "seeing the injected summary does not count as checking.")

4. Pattern C — The After-the-Fact Reminder Hook

Some procedures are too minor to block on but too important to just let slide. For example, "if you committed, update the handoff note too."

if "git commit" in payload["tool_input"].get("command", "") \
        and payload.get("exit_code") == 0:
    handoff = PROJECT / "memory/HANDOFF.md"
    if not modified_today(handoff):
              file=sys.stderr)
Enter fullscreen mode Exit fullscreen mode

The once-per-session limit matters. If it pops up every time, it just gets ignored — and a hook that gets ignored is a hook that doesn't exist.

5. Three Operational Pitfalls (We Hit All of Them)

① Silent blocking. If a hook returns only an exit code with no reason, the AI concludes "the tool is broken"
and attempts some misguided workaround. A blocking message must always include the reason and the way to resolve it.

② False positives. Once a gate starts blocking normal work too, the human gets annoyed and turns the hook off,
and at that moment the entire safety mechanism disappears. One over-blocking gate really was removed in the end.
Put gates only on patterns that are clearly dangerous, and steer everything else gently with injection hooks.

③ Hooks die quietly. This is the scariest one. A script path changed, nobody noticed,
and months went by. We believed we were safe while the safety mechanism wasn't there at all.

for name, path in HOOKS.items():
    r = subprocess.run([sys.executable, path], input="{}",
                       capture_output=True, text=True, timeout=10)
Enter fullscreen mode Exit fullscreen mode

6. How Far Should Hooks Go?

Hooks are not a cure-all. There is exactly one criterion for the call.

Has the same mistake happened twice? Then make it a hook. If it happened once, record it.

If you turn every one-time incident into a hook, the system becomes a mass of rules and eventually heads
toward the false positives of ②. Twice is a signal that it's not chance but a structural problem — and structural problems get solved with structure.


Want the whole system? The book has 10 chapters plus 4 ready-to-use templates (CLAUDE.md starter, memory files, auditor checklist, measurement guide) and a hands-on section for every chapter. It's $19 as a PDF: https://dbsoul.gumroad.com/l/autonomous-ai-agents-claude-code

Not sure yet? The first three chapters are free, same PDF format: https://dbsoul.gumroad.com/l/autonomous-ai-agents-claude-code-free-sample

Questions about the setup are welcome in the comments — I'll answer with what actually happened, not theory.

Top comments (0)