Hooks: Automating Behavior the Model Can't Reliably Remember
CLAUDE.md instructions work well for most things. But instructions are text — and as a session fills with context, behavioral instructions from earlier in the conversation or even from the CLAUDE.md file can fade. An agent running a complex 90-minute task has accumulated a lot of context since it read the startup files. Subtle behavioral nudges from line 400 of a CLAUDE.md get outweighed by the task at hand.
Hooks are the solution. They are small scripts that Claude Code runs automatically at session lifecycle events, regardless of what the model is thinking about. A hook doesn’t compete with context — it runs in the background and fires by construction.
Hook types
Section titled “Hook types”Claude Code supports two hook event types at this writing:
PreToolUse — runs before a tool call executes. You can inspect what the agent is about to do and block it if it violates a rule.
A PreToolUse hook that blocks dangerous commands might look like:
#!/usr/bin/env python3import sys, json
input_data = json.load(sys.stdin)tool_name = input_data.get("tool_name", "")tool_input = input_data.get("tool_input", {})
# Block force-push to mainif tool_name == "Bash": cmd = tool_input.get("command", "") if "git push --force" in cmd and "main" in cmd: print(json.dumps({"action": "block", "reason": "Force push to main is not allowed."})) sys.exit(0)
# Allow everything elseprint(json.dumps({"action": "allow"}))Stop — runs after the model finishes its turn. This is where you do post-processing: notify the user, sync state, fire a follow-up action.
The voice auto-fire example
Section titled “The voice auto-fire example”The clearest real example of why hooks matter more than instructions is the voice reply system.
The setup: the agent operates via Telegram. When it gives a long summary reply, it should also send an audio version via Kokoro TTS so the user can listen on their phone. This was documented in CLAUDE.md. It was reminded in the system prompt. The agent followed the rule reliably — for a few turns. Then the session got long, and it started forgetting.
The correct fix wasn’t to write better instructions. It was to move the behavior into a Stop hook.
The hook at ~/agent-system/scripts/telegram-stop-hook.py runs after every turn. It:
- Reads the session transcript.
- Checks whether the latest turn contained a Telegram reply.
- Checks whether that reply is “summary-class” (≥300 chars, or contains a markdown header, or has 2+ list items).
- If so, and if voice mode is on, it calls
send_voice()to synthesize and post the audio — without the model needing to remember to do it.
The hook is wired in settings.json:
{ "hooks": { "Stop": [ { "matcher": "", "hooks": [ { "type": "command", "command": "/opt/homebrew/bin/python3.12 /Users/jd/agent-system/scripts/telegram-stop-hook.py" } ] } ] }}The result: voice replies became 100% reliable the day the hook shipped, and they’ve stayed that way. The behavioral instruction in CLAUDE.md still exists (it explains the intent), but the infrastructure does the work.
Infrastructure beats instructions
Section titled “Infrastructure beats instructions”This is the lesson the voice system taught, and it generalizes widely:
- Instructions rely on the model reading them, weighting them appropriately, and remembering them across thousands of tokens of context. That’s unreliable for high-stakes behaviors.
- Hooks run by construction. They don’t need the model to remember anything. They intercept at a lifecycle event and execute a script you control.
For anything where the failure mode is “this sometimes gets skipped,” ask: should this be in a hook?
Good candidates for hooks:
- Logging or auditing every tool call.
- Blocking specific dangerous commands (
rm -rf,git push --force,DROP TABLE). - Syncing state to a file after every turn.
- Notifying a monitoring system that the session is still alive.
- Auto-firing a follow-up action (send notification, update a dashboard) after the model finishes its work.
Where hooks are configured
Section titled “Where hooks are configured”Hooks live in settings.json — the harness configuration file covered in the next article. The Stop hook shown above is the typical pattern: a matcher (empty string matches all sessions), a list of hook commands to run, and the type "command" pointing to a script.
The hook receives context as JSON on stdin: the current tool call (for PreToolUse), or the full session transcript path (for Stop). Your script reads that, makes a decision, and exits. Simple, composable, reliable.
Next: settings.json & Permissions Config — the harness configuration file: env vars, tool allowlists, hooks wiring, and the settings hierarchy.