Back to Blog

The Hook Cookbook

Every lifecycle event. Real code for each. Copy, paste, stop your agent from pushing to main.

Your agent follows instructions 95% of the time. The other 5%, it decides your CLAUDE.md is more of a suggestion. Like all of us with safety training, really.

For experiments — fine. For production — no. When agents commit code, deploy services, edit production files — probabilistic control doesn’t cut it. In any other field, 95% compliance on safety protocols gets you shut down. In AI tooling, it gets you a blog post about “impressive reliability.”

Hooks fix this. A hook is a shell script that runs deterministically at a specific point in the agent’s lifecycle. Not “ask the AI to maybe do this” — “enforce through code.”

Agent tries to write a file. Your script runs first. Checks for API keys. Found one. Blocked. No discussion.

This cookbook covers every lifecycle event in Claude Code’s hook system. Each entry: what fires, when, and a real hook you can copy.

How Hooks Work

Hooks live in .claude/settings.json (project) or ~/.claude/settings.json (user-level). JSON format:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "your-script-here"
          }
        ]
      }
    ]
  }
}

Matcher controls which tools trigger the hook. "Bash" matches Bash tool only. "Edit|Write" matches both. "" or "*" matches everything. Case-sensitive.

Input: Your script receives JSON via stdin — session ID, tool name, tool input, working directory.

Output: Exit code 0 = success. Exit code 2 = block the operation (stderr fed back to Claude). Any other code = non-blocking error.

For advanced control, return JSON on stdout:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "API key detected in file"
  }
}

Important nuance: JSON output is only processed on exit code 0. Exit code 2 ignores stdout entirely — only stderr matters. So there are two blocking strategies: exit code 2 (simple, stderr becomes the message) or exit code 0 with "permissionDecision": "deny" in JSON (structured, more control).

There’s also "type": "prompt" hooks — instead of running a shell command, they send a prompt to a fast LLM (Haiku) for evaluation. The LLM returns {"ok": true} or {"ok": false, "reason": "..."}. Useful for fuzzy checks that shell scripts can’t handle.

All matching hooks run in parallel. Default timeout: 60 seconds.

The Events

Claude Code has 12 lifecycle events. Here’s every one.

SessionStart

When: Session begins or resumes.

Use for: Loading context. Git status, recent commits, project state — injected before Claude starts thinking.

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"Branch: $(git branch --show-current). Last commit: $(git log -1 --oneline). Modified: $(git diff --name-only | head -5 | tr '\\n' ', ')\""
          }
        ]
      }
    ]
  }
}

stdout goes straight into Claude’s context. It starts every session knowing where you are.

Advanced — session continuity (Mother Claude): Load the last session’s handoff notes so Claude picks up where it left off. The handoff script reads the previous session’s transcript, summarizes it with Haiku, and saves a markdown file. SessionStart loads it.

Setup

When: Claude Code runs with --init, --init-only, or --maintenance.

Use for: One-time project init. Install dependencies, check environment, validate before first run.

Matcher options: "init" or "maintenance".

UserPromptSubmit

When: User submits a prompt, before Claude processes it.

Use for: Prompt validation, logging, injecting context. Exit code 2 blocks the prompt entirely.

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.prompt' >> ~/.claude/prompt-log.txt"
          }
        ]
      }
    ]
  }
}

No matcher needed — this event doesn’t involve tools.

PreToolUse

When: Before any tool executes. This is the gate.

Use for: Blocking dangerous operations, validating inputs, scanning for secrets. The most-used hook event.

Matchers: Bash, Edit, Write, Read, Glob, Grep, Task, WebFetch, WebSearch, or any MCP tool (mcp__server__tool).

Recipe 1 — Block dangerous commands:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.command' | grep -qE '(rm\\s+-rf|sudo|chmod\\s+777|git\\s+push\\s+--force)' && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Blocked: dangerous command. Use a safer alternative.\"}}' || true"
          }
        ]
      }
    ]
  }
}

Agent tries rm -rf. Hook blocks it. Claude gets the reason and adapts.

Recipe 2 — Secret scanner before file writes:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3 -c \"import json,sys,re; data=json.load(sys.stdin); content=json.dumps(data.get('tool_input',{})); patterns=['AKIA[0-9A-Z]{16}','sk-[a-zA-Z0-9]{32,}','ghp_[a-zA-Z0-9]{36}','-----BEGIN (RSA|EC|DSA) PRIVATE KEY-----']; sys.exit(2) if any(re.search(p,content) for p in patterns) else sys.exit(0)\""
          }
        ]
      }
    ]
  }
}

Catches AWS keys, OpenAI keys, GitHub tokens, private keys. Exit code 2 = blocked.

Recipe 3 — Modify tool input before execution:

Hooks can silently rewrite what the tool does. Add --dry-run to dangerous commands:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.command' | { read cmd; echo \"$cmd\" | grep -qE '^npm publish' && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{\"command\":\"npm publish --dry-run\"},\"additionalContext\":\"Running in dry-run mode per policy.\"}}' || true; }"
          }
        ]
      }
    ]
  }
}

The updatedInput field replaces the tool’s input before execution. The agent doesn’t know you changed anything.

Recipe 4 — Branch protection:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.command' | grep -qE 'git\\s+(push\\s+(origin\\s+)?main|commit.*-m)' && { echo 'Use a feature branch. Direct commits to main blocked.' >&2; exit 2; } || true"
          }
        ]
      }
    ]
  }
}

PermissionRequest

When: Claude shows a permission dialog.

Use for: Auto-approving safe operations or auto-denying dangerous ones.

{
  "hooks": {
    "PermissionRequest": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "python3 -c \"import json,sys,re; d=json.load(sys.stdin); t=d.get('tool_name',''); i=json.dumps(d.get('tool_input',{})); safe=['Read','Glob','Grep']; dangerous=[r'sudo',r'git push.*--force',r'rm\\\\s+-rf']; result={'hookSpecificOutput':{'hookEventName':'PermissionRequest','decision':{'behavior':'allow'}}} if t in safe else ({'hookSpecificOutput':{'hookEventName':'PermissionRequest','decision':{'behavior':'deny','message':'Blocked by safety hook'}}} if any(re.search(p,i) for p in dangerous) else {}); print(json.dumps(result)) if result else sys.exit(0)\""
          }
        ]
      }
    ]
  }
}

Read, Glob, Grep — always approved. sudo, git push --force, rm -rf — always denied. Everything else — normal permission flow.

PostToolUse

When: After a tool succeeds.

Use for: Auto-formatting, linting, running tests. The second most-used hook event.

Recipe 1 — Auto-format after every edit (most popular community hook):

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | { read f; echo \"$f\" | grep -qE '\\.(ts|tsx|js|jsx)$' && npx prettier --write \"$f\" 2>/dev/null; } || true"
          }
        ]
      }
    ]
  }
}

Recipe 2 — Run related tests:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | { read f; echo \"$f\" | grep -qE '\\.test\\.' && npm test -- --testPathPattern=\"$f\" 2>&1 | tail -5; } || true"
          }
        ]
      }
    ]
  }
}

Edits a test file. Tests run automatically. Output goes to Claude.

PostToolUseFailure

When: After a tool fails.

Use for: Logging failures, custom error recovery.

Notification

When: Claude sends a notification — permission prompts, idle waiting, auth events.

Matchers: permission_prompt, idle_prompt, auth_success, elicitation_dialog.

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude needs you\" with title \"Claude Code\" sound name \"Glass\"'"
          }
        ]
      }
    ]
  }
}

macOS only. First run requires a one-time permission grant in System Settings. Linux: use notify-send.

Stop

When: Claude finishes responding. Does NOT fire on user interrupt.

Use for: Post-completion actions — auto-commit, notifications, session logging.

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Done.\" with title \"Claude Code\" sound name \"Glass\"'"
          }
        ]
      }
    ]
  }
}

Infinite loop warning: The Stop hook input includes "stop_hook_active": true when Claude is already continuing because a previous Stop hook blocked stoppage. Check this. If you blindly block every Stop event, Claude never stops. Ever.

Advanced — auto-commit per session (GitButler): GitButler built shadow git indexes that track each agent session’s changes separately. When the agent stops, the hook commits everything to a session-specific branch. No conflicts between parallel sessions.

SubagentStart

When: A subagent is spawned via the Task tool.

Use for: Logging which subagents launch, injecting context per agent type. Input includes agent_type"Bash", "Explore", "Plan", or custom names.

SubagentStop

When: A subagent task finishes.

Use for: Tracking multi-agent workflows. Same as Stop, but for Task-spawned subagents. Input includes agent_transcript_path — the subagent’s own transcript, separate from the main session.

PreCompact

When: Context is about to be compacted (auto or manual).

Matchers: "manual" (from /compact) or "auto" (context window full).

Use for: Saving state before context loss. Mother Claude uses this to generate a session handoff summary before compression wipes the details.

Gotcha: PreCompact runs side effects but can’t prevent compaction. And if both PreCompact and SessionEnd generate handoffs — the thin SessionEnd handoff might overwrite the rich PreCompact one. Deduplicate.

SessionEnd

When: Session terminates.

Use for: Cleanup. Final logging. Kill what needs killing.

Gotchas

Exit code 2, not 1. Exit 1 = non-blocking (Claude sees it, continues). Exit 2 = blocks the operation. Everyone gets this wrong at first. And remember: exit 2 only reads stderr. For structured blocking with a custom reason, use exit 0 + JSON (covered in “How Hooks Work” above).

No template variables. There’s no {{tool.name}} or {{tool.input.file_path}}. Those appear literally in the command. Parse the JSON from stdin yourself with jq or Python.

Hooks run in parallel. All matching hooks for an event fire simultaneously. No sequential execution, no chaining output from one hook to another. A feature request for sequential hooks was closed as “not planned.” If you need a pipeline, put it in one script.

Hooks snapshot at startup. Edit .claude/settings.json mid-session — nothing happens. Claude loads hooks when it starts and keeps that snapshot. To apply changes, restart or use /hooks to review.

Timeout is 60 seconds. Hooks that run longer get killed. Configurable per hook with "timeout": 120.

Test commands manually first. Before putting a command in a hook, run it yourself. Twice. Hook debugging is painful — claude --debug shows execution details, but you’ll wish you tested earlier.

macOS notification permissions. The first osascript notification needs a one-time manual permission grant in System Preferences. It fails silently without it.

The Bigger Picture

Git hooks. CI/CD pipeline hooks. IDE plugins. Kubernetes admission webhooks. Every tool that becomes infrastructure grows extension points.

Steve Yegge wrote in January 2026: agents need to stop being “beloved pets” and become “cattle” — standardized, automatable. He runs 20-30 agents in parallel and hits exactly this wall.

Google’s Gemini CLI shipped hooks on January 28, 2026 — 7 lifecycle events with different names (BeforeTool instead of PreToolUse) but the same pattern. The initial implementation even included Claude Code compatibility aliasespermissionDecision, permissionDecisionReason. They removed them later. To avoid carrying someone else’s technical debt, they said.

Cursor, Copilot CLI, AWS Kiro — all converging. The pattern is the same everywhere because the problem is the same everywhere: probabilistic systems need deterministic guardrails.

Start with three hooks. Secret scanner on PreToolUse. Prettier on PostToolUse. Branch protection on PreToolUse. Copy the JSON from this page. That’s 15 minutes. After that, your agent still follows instructions 95% of the time — but the other 5% hits a wall instead of your production database.

Related: Programming in Human