Telling Claude “don’t use find” or “always check X before doing Y” in CLAUDE.md works — until the context window fills up and those instructions get compressed away. The more rules you add, the less reliably any single rule fires. And front-loading every possible instruction means Claude reads 200 lines of rules before touching your actual task.
Hooks solve this by moving behavioral control out of the prompt and into code that runs at deterministic moments: before a tool fires, after a tool fires, on session start, on session stop. Your rules become shell scripts and Python programs that execute every time, regardless of how full the context window gets.
Here are four patterns I’ve been using that compose well together. All code examples are available in the companion reference repo.
Pattern 1: PreToolUse Interception
When: You want to block or warn before a command runs.
Register a hook on PreToolUse with a tool matcher (e.g., Bash). The hook reads the command from stdin, decides whether to allow or block, and exits with code 0 (allow) or 2 (block with message on stderr).
The hook script receives JSON on stdin with tool_input.command. Parse it, match patterns, and exit 0 (allow) or 2 (block):
#!/bin/bash
HOOK_DATA=$(cat)
COMMAND=$(echo "$HOOK_DATA" | jq -r '.tool_input.command // ""')
if echo "$COMMAND" | grep -qE 'find\s+(/|~)'; then
echo "BLOCKED: find on broad path. Use Glob or Grep instead." >&2
exit 2 # Block — stderr message is shown to Claude
fi
exit 0 # Allow
The stderr message becomes part of Claude’s context, so it knows why the command was blocked and can adjust. See the full examples in the companion repo.
Use cases: Blocking rm -rf, preventing force-push, catching find ~/, enforcing specific workflows over raw CLI commands.
Pattern 2: The Orchestrator
When: You have multiple independent checks that should all run on the same hook event.
The naive approach — registering 8 separate hooks in settings.json — gets unwieldy fast. Each hook independently parses stdin, ordering is implicit, and your settings.json balloons with repeated boilerplate.
Instead, register one orchestrator script that delegates to check modules in a directory:
#!/bin/bash
HOOK_DATA=$(cat)
COMMAND=$(echo "$HOOK_DATA" | jq -r '.tool_input.command // ""')
[[ -z "$COMMAND" ]] && exit 0
CHECKS_DIR="$HOME/.claude/hooks/checks"
for check in "$CHECKS_DIR"/prevent-force-push.sh \
"$CHECKS_DIR"/rm-safety-check.sh \
"$CHECKS_DIR"/block-broad-find.sh; do
[[ -x "$check" ]] && "$check" "$COMMAND"
[[ $? -eq 2 ]] && exit 2
done
exit 0
Each check is a standalone script: takes the command as $1, exits 0 to pass or 2 to block. Adding a new check means dropping a file into checks/ and adding one line to the loop. See the full orchestrator in the repo.
Benefits
- One entry in settings.json instead of eight
- Checks run in explicit order (put hard blocks first)
- Each check is independently testable
- Dead-simple to add or remove checks
Pattern 3: Session State Files
When: You want a hook to fire once per session, not on every invocation.
A “warn on first rm, allow subsequent” pattern needs state. The trick: use the Claude Code process PID as a session identifier, and touch a file in a state directory.
CLAUDE_PID=$(find_claude_pid) # walk up process tree to find "claude"
STATE_FILE="$HOME/.claude/hooks/state/my-check/$CLAUDE_PID"
mkdir -p "$(dirname "$STATE_FILE")"
[ -f "$STATE_FILE" ] && exit 0 # already warned this session
touch "$STATE_FILE" # mark warned
echo "First-time warning for this session..." >&2
exit 2
The find_claude_pid function walks up the process tree via ps -o ppid= until it finds a process named claude, giving you a stable session identifier. State files accumulate as sessions end; a lazy cleanup (5% probability per invocation) checks if each PID is still alive and deletes stale files. See the full implementation in the repo.
Use cases: Warn once about rm then allow, remind about safety checks on first push, show validation reminders once per session.
Pattern 4: PostToolUse Context Injection
When: You want to push the right reference doc at the right moment, based on what the agent just did.
This is the opposite of Pattern 1 — instead of blocking before, you inject context after. The hook watches what tool was just used and pushes a relevant document into Claude’s context via hookSpecificOutput.additionalContext.
The core output is simple — when a trigger matches, emit the resource as JSON on stdout:
# When a trigger matches, inject the resource into Claude's context
content = open("resources/deployment-checklist.md").read()
json.dump({
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": content,
}
}, sys.stdout)
Build a trigger system on top: a JSON manifest that maps tool patterns to resource files:
{
"push_triggers": [
{ "trigger": { "tool": "Bash", "command_pattern": "kubectl apply" },
"resource": "deployment-checklist" },
{ "trigger": { "tool": "Write", "file_path_pattern": ".*\\.css$" },
"resource": "style-guide" }
],
"resources": [
{ "name": "deployment-checklist", "file": "resources/deployment-checklist.md" },
{ "name": "style-guide", "file": "resources/style-guide.md" }
]
}
The full implementation in the repo includes trigger matching, session-scoped deduplication, and micro-reminders.
Why this beats CLAUDE.md
Your deployment checklist might be 50 lines. Your style guide might have examples and anti-patterns. Putting all of that in CLAUDE.md bloats the prompt with content that’s only relevant at specific moments. With push triggers, the agent gets the deployment checklist exactly when it runs kubectl — not 200 turns earlier when it was reading the task description.
You can also track which resources have been injected per session (using Pattern 3’s session state) so each resource fires exactly once, avoiding repeated context bloat.
How They Compose
These four patterns layer naturally:
| Pattern | Hook Event | Purpose | Fires |
|---|---|---|---|
| PreToolUse interception | PreToolUse |
Block dangerous commands | Before tool runs |
| Orchestrator | PreToolUse |
Single entry point for many checks | Before tool runs |
| Session state files | Any | Warn once, allow after | Once per session |
| Context injection | PostToolUse |
Push docs at the right moment | After tool runs |
Getting Started
- Start with Pattern 1 — pick one command you want to intercept (e.g.,
find ~/) and write a simple PreToolUse hook - When you have 3+ checks, wrap them in an orchestrator (Pattern 2)
- Add session state (Pattern 3) when “warn once” beats “block every time”
- Graduate to Pattern 4 when you have reference docs that are only relevant at specific moments
All four patterns use standard Claude Code hook APIs — no special tooling required. The official hooks reference and hooks guide cover the fundamentals; the patterns here are built on top.
Full working code for every pattern is in the companion repository.