Hooks — The Guard Rails
Hard walls that Claude cannot talk around
Everything in this course so far has been instructions: rules Claude should follow, recipes Claude can use, history Claude can read. All of it is interpreted — which means a clever or confused Claude can deviate. are the opposite. They're shell commands the Claude Code harness runs automatically at lifecycle moments — and the harness, not Claude, decides what happens next.
Specifically: a can refuse a tool call before it happens. Claude literally cannot argue its way around a refused call. This chapter is where you stop relying on good intentions and start putting walls around your filesystem.
You'll have a secret-scan hook wired up in your repo that physically prevents Claude from writing any file containing an AWS key, Anthropic key, GitHub token, or Slack token. Plus at least one PostToolUse reactor you pick.
Hooks are the only unarguable primitive
CLAUDE.md rules are contracts — Claude treats them as binding, but it still interprets them, so edge cases slip. Skills and commands are requests — Claude follows them because they describe what to do, but enough gaslighting can push it off-script. Hooks are different: they run outside Claude. They're shell commands the harness executes at specific moments, and their exit code decides whether the next action happens or gets blocked.
A hook that exits with code 2 is a hard stop. Not "Claude politely refuses." Not "Claude tries again." The harness drops the tool call entirely and hands the reason back as an error. A hook runs after the tool call and can react — auto-format, log, notify you on Slack.
Reserve hooks for rules you absolutely cannot allow to fail. Never for polite preferences. Hooks are the safety net, not the personality.
Four primitives, escalating in how seriously Claude takes them:
- Skills / commands are requests. Claude follows them because they describe what to do. A confused Claude can deviate from the script.
- CLAUDE.md rules are contracts. Stronger than skills — Claude treats them as binding — but still interpreted, so edge cases slip.
- Memory is context. Not enforced at all; just loaded when relevant. Great for information, bad for enforcement.
- Hooks are walls. Not even read by Claude. The harness runs them before/after each tool call. Exit 2 blocks the call as if Claude had no permission for it — end of story.
Use hooks when the consequence of failure is non-negotiable. Committing a secret. Editing a production migration. Breaking a lockfile. Everything else — style, preferences, shortcuts — belongs higher up the chain.
Each recipe below has a JSON entry that goes in .claude/settings.json, and sometimes a shell script that lives in scripts/. Shell scripts must be executable (chmod +x ./scripts/<name>.sh). Start with Recipe 1 — it's the one you should have running on day one of any serious project.
PreToolUse secret-scan (blocking)
Fires when: Fires before any Write or Edit. Blocks if the file content contains common secret patterns.
Why it earns its keep: Claude occasionally generates code with inline API keys as examples. This catches the obvious patterns (AWS, Anthropic, GitHub, Slack) before they hit disk. Zero false-negatives on the well-known formats.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "./scripts/secret-scan.sh"
}
]
}
]
}
}
#!/usr/bin/env bash
# scripts/secret-scan.sh
# Blocks Claude Code from writing files that contain obvious secrets.
set -e
CONTENT_JSON=$(cat)
# Extract the file_text from the tool input
CONTENT=$(echo "$CONTENT_JSON" | jq -r '.tool_input.new_string // .tool_input.content // ""')
# Block if we find common secret patterns
if echo "$CONTENT" | grep -E -q \
'AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9]{32,}|ghp_[a-zA-Z0-9]{36}|xoxb-[0-9a-zA-Z-]+'; then
echo "blocked: possible secret detected in file content" >&2
exit 2 # exit 2 = block the tool call
fi
# exit 0 = allow
exit 0
PostToolUse auto-format
Fires when: Fires after every successful Edit or Write. Runs the formatter on the file Claude just touched.
Why it earns its keep: You get prettier / ruff / gofmt runs for free, without remembering to re-save. The `|| true` means a missing formatter doesn't noisily break Claude's tool call.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "pnpm prettier --write \"$CLAUDE_FILE_PATH\" 2>/dev/null || true"
}
]
}
]
}
}
UserPromptSubmit context-inject
Fires when: Fires when you send a message. Whatever the hook prints to stdout gets prepended to your prompt.
Why it earns its keep: Useful for always-on context — e.g. the top of your memory index, current git branch, or "it’s Friday and deploys are frozen." Use sparingly — every word here burns context budget on every request.
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "cat memory/MEMORY.md 2>/dev/null | head -20"
}
]
}
]
}
}
SessionStart status print
Fires when: Fires once when a Claude Code session starts.
Why it earns its keep: Shows you (and Claude) what state the repo is in — uncommitted changes, recent commits. Five seconds to wire up, saves "what did I do last time I was here?" re-discovery every session.
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "git status --short && echo '---' && git log --oneline -5"
}
]
}
]
}
}
PreToolUse protected-paths (blocking)
Fires when: Blocks edits to paths you never want Claude touching: .env files, database migrations, CI workflows, lockfiles.
Why it earns its keep: These paths need manual review. One accidental Claude edit to a Supabase migration or a GitHub Actions workflow is an incident waiting to happen.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "./scripts/protect-paths.sh"
}
]
}
]
}
}
#!/usr/bin/env bash
# scripts/protect-paths.sh
# Refuses edits to paths Claude shouldn't touch without explicit consent.
PROTECTED=(
"^\\.env"
"^supabase/migrations/"
"^\\.github/workflows/"
"package-lock\\.json$"
"pnpm-lock\\.yaml$"
)
FILE=$(cat | jq -r '.tool_input.file_path // ""')
for pat in "${PROTECTED[@]}"; do
if [[ "$FILE" =~ $pat ]]; then
echo "blocked: $FILE is in a protected path. Edit manually or adjust scripts/protect-paths.sh." >&2
exit 2
fi
done
exit 0
Stop long-run notifier (macOS)
Fires when: Fires when Claude finishes a turn.
Why it earns its keep: For long-running tasks — runs, builds, diagnostics — you want to walk away. A desktop notification when Claude returns beats tab-switching. Replace osascript with a Slack webhook or `terminal-notifier` if you're on Linux.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code finished\" with title \"cc\"'"
}
]
}
]
}
}
Everything you know, on one real app
That's all five primitives: CLAUDE.md, skills, commands, memory, hooks. You now have the complete toolkit Claude Code power-users actually use day to day.
Chapter 8 is the capstone — you build an actual SaaS (a Notes app with AI summarisation) end to end using every primitive you've learned. By the end of it, you'll have a live URL, a database, authentication, an AI feature, and the deployment. Not a tutorial repo. A real thing on the internet.