The Course
chapter 07core course

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.

hard walls~12 mincreates settings.json + 2 shell scripts
before you startset your expectations
by the end of this chapter

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.

lesson

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.

part 1hooks vs skills vs commands vs CLAUDE.md

Four primitives, escalating in how seriously Claude takes them:

  1. Skills / commands are requests. Claude follows them because they describe what to do. A confused Claude can deviate from the script.
  2. CLAUDE.md rules are contracts. Stronger than skills — Claude treats them as binding — but still interpreted, so edge cases slip.
  3. Memory is context. Not enforced at all; just loaded when relevant. Great for information, bad for enforcement.
  4. 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.

part 26 hook recipes

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.

1

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.

CREATE / EDIT THIS FILE·.claude/settings.json
Merge this `hooks` entry into your existing settings.json (it doesn't overwrite other keys).
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/secret-scan.sh"
          }
        ]
      }
    ]
  }
}
json
CREATE / EDIT THIS FILE·scripts/secret-scan.sh
Save the script, then run `chmod +x` on it so the hook can execute it.
#!/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
bash
2

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.

CREATE / EDIT THIS FILE·.claude/settings.json
Merge this `hooks` entry into your existing settings.json (it doesn't overwrite other keys).
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "pnpm prettier --write \"$CLAUDE_FILE_PATH\" 2>/dev/null || true"
          }
        ]
      }
    ]
  }
}
json
3

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.

CREATE / EDIT THIS FILE·.claude/settings.json
Merge this `hooks` entry into your existing settings.json (it doesn't overwrite other keys).
{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "cat memory/MEMORY.md 2>/dev/null | head -20"
          }
        ]
      }
    ]
  }
}
json
4

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.

CREATE / EDIT THIS FILE·.claude/settings.json
Merge this `hooks` entry into your existing settings.json (it doesn't overwrite other keys).
{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "git status --short && echo '---' && git log --oneline -5"
          }
        ]
      }
    ]
  }
}
json
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.

CREATE / EDIT THIS FILE·.claude/settings.json
Merge this `hooks` entry into your existing settings.json (it doesn't overwrite other keys).
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/protect-paths.sh"
          }
        ]
      }
    ]
  }
}
json
CREATE / EDIT THIS FILE·scripts/protect-paths.sh
Save the script, then run `chmod +x` on it so the hook can execute it.
#!/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
bash
6

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.

CREATE / EDIT THIS FILE·.claude/settings.json
Merge this `hooks` entry into your existing settings.json (it doesn't overwrite other keys).
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code finished\" with title \"cc\"'"
          }
        ]
      }
    ]
  }
}
json
After saving any shell script, make it executable with chmod +x ./scripts/<name>.sh. If a hook silently doesn't fire, that's usually the problem.
next up

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.

exercisestry this in your own repo0/5