Claude Code Learning Hub
中文 Mingyu's Library

Hub / Course / D8

D8 · Hooks

Turn "hopefully Claude remembers to do it" into "guaranteed to happen": a hook is your own code attached to fixed points in the agentic loop — this lesson covers the events, the configuration, the exit codes, and the three canonical uses.

For the first seven lessons, every way you influenced Claude was "saying it to the model": CLAUDE.md, plans, Skills. Hooks take a different road — no negotiating with the model; you insert deterministically-executed shell commands directly into its execution flow. Formatting, blocking edits to sensitive files, forcing tests before finishing — all of it lives in this lesson.

Why this lesson comes today

This lesson stands squarely on two existing foundations. D3 (permission modes and sandboxing) is static gatekeeping: a tool call is either "allowed or not allowed". Hooks give you a second kind of control on the same pathway: run your code before and after the call — it can block, rewrite, and feed back — and a PreToolUse hook returning deny stops even bypassPermissions mode, an angle permission rules can't cover. And the comparison thread left open by D7 (Skills) closes here: a Skill "teaches the model how to do it", but whether it complies is ultimately the model's call; a hook "happens no matter what the model thinks".

Looking ahead, hooks keep reappearing: the tools of D9 (MCP) can be matched by hooks too (mcp__server__tool); in D13 (Headless and CI) hooks are the only reliable gatekeeper when no one is watching; and D14 (Plugins) packages hooks for distribution to a whole team. Skip this lesson and all three of those are missing a piece.

Choosing tools"Should this need be a Skill, a hook, MCP, or a Subagent?" — if you still can't call it after this lesson, see the dedicated comparison page Tip E1: choosing among the five extension mechanisms.

Core concepts, explained

OfficialA hook is a user-defined shell command (it can also be an HTTP endpoint or an LLM prompt) that Claude Code runs automatically at specific points in its lifecycle, giving you deterministic control: certain actions are guaranteed to happen, rather than pinned on the model "remembering to do them on its own". The docs also give a selection principle: anything expressible as a deterministic rule should be a command hook; for decisions needing judgment, use type: "prompt" (a single-turn model evaluation) or the experimental type: "agent" (a tool-equipped subagent check).

One table to tell them apart: advice, gates, force

Our takeLine up the mechanisms from earlier lessons side by side and hooks' position becomes clear:

MechanismWhat it isWho executes itCan the model ignore it?
CLAUDE.md / Skills (D4, D7)Instructions written for the modelThe model reads them and followsYes — it's advice, not a constraint
Permission rules (D3)Static allow / ask / deny gatesClaude Code checks the table on tool callsNo, but they can only "pass or block" — no extra actions attached
Hooks (this lesson)Your code attached to lifecycle pointsClaude Code runs it unconditionallyNo — the model isn't even involved, and hooks can also block, rewrite, and feed back

Where hooks plug into the agentic loop

OfficialEvents fire on three rhythms: once per session (SessionStart / SessionEnd), once per turn (UserPromptSubmit, Stop), and PreToolUse / PostToolUse firing on every single tool call in the agentic loop. The diagram below shows the trunk path:

SessionStart UserPromptSubmit agentic loop (every tool call passes through) Model picks a tool call PreToolUse Tool executes PostToolUse exit 2 or deny: tool call blocked, stderr fed back to model No new tool calls; this turn's reply is done Stop Stop hook exit 2: sent back to keep working Session ends SessionEnd
Solid accent boxes are hook events (insertion points); plain boxes are the loop itself. PreToolUse can block (the tool hasn't run yet), PostToolUse can't (it already ran — feedback only), and Stop can send a Claude that "wants to clock out" back to work.

The events you'll actually use

OfficialThe reference lists 31 events in total; below are the most commonly used. Each event accepts a matcher filter (tool events match on tool name, e.g. Bash, Edit|Write, regex mcp__.*; leave it empty to fire every time; matching is case-sensitive):

EventWhen it firesTypical use
SessionStartWhen a session starts or resumes (matcher distinguishes startup/resume/compact, etc.)Inject context; restore key info after compaction
UserPromptSubmitAfter you submit a prompt, before Claude processes itValidate or block prompts, attach environment info
PreToolUseBefore every tool call executes, can blockProtect sensitive files, forbid dangerous commands
PermissionRequestWhen Claude Code is about to show you a permission promptAuto-approve specific requests on your behalf
PostToolUseAfter every successful tool call (already executed, irreversible)Auto-format, write audit logs
NotificationWhen Claude Code sends a notification (waiting for your input, waiting for permission)Desktop notifications so you don't have to watch the terminal
Stop / SubagentStopWhen Claude (or a subagent) finishes its turn, can send it backDone-gating: no stopping while tests fail
PreCompact / SessionEndBefore context compaction / at session terminationBack up transcripts, clean up temp files

Where the configuration lives

OfficialHooks are written under the hooks key of a settings file, nested three levels deep: event name → matcher group → handler array. Below is the official "run Prettier after edits" example; drop it into .claude/settings.json at the project root and it's live:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ]
  }
}

Which file it lives in decides its scope:

LocationScopeShareable
~/.claude/settings.jsonAll of your projectsNo, this machine only
.claude/settings.jsonOne projectYes, commit it to the repo
.claude/settings.local.jsonOne projectNo, gitignored by default
Enterprise managed policy settingsThe whole organizationYes, admin-controlled
Plugin hooks/hooks.jsonWhile the plugin is enabledYes, ships with the plugin (D14)
Skill / agent frontmatterWhile that component is activeYes, lives in the component file

Type /hooks in a session to browse every hook currently in effect, by event — note this menu is read-only; to add, remove or change hooks, edit the settings JSON directly (or have Claude do it for you). To switch everything off at once, set "disableAllHooks": true in settings (hooks from managed settings excepted).

How exit codes and output steer the flow

OfficialWhen an event fires, Claude Code pipes the event data as JSON into your script's stdin (including session_id, cwd, hook_event_name, plus tool_name and tool_input for tool events); your script talks back with its exit code:

Exit codeMeaningEffect
0No objectionThe action proceeds normally. For PreToolUse this is not approval — the normal permission flow still runs; stdout from UserPromptSubmit and SessionStart is injected into Claude's context
2BlockThe action is stopped, with stderr fed back to Claude as the reason so it can adjust. The exact effect varies by event: PreToolUse blocks the tool call, Stop prevents finishing, and PostToolUse — the tool already ran — can only feed back, not undo
OtherNon-blocking errorThe action executes anyway; a hook error notice appears in the transcript

When you need finer control than "block / don't block", exit 0 and print a JSON object to stdout: PreToolUse can return permissionDecision as "allow" (skip the permission prompt), "deny" (block and tell Claude the permissionDecisionReason), or "ask" (hand it to the user to confirm); Stop / PostToolUse use a top-level {"decision": "block", "reason": "..."}. Pick one mechanism or the other: with exit 2, any JSON on stdout is ignored.

Three high-frequency misconceptions

  • Official"exit 1 counts as failure too, so surely it blocks" — it doesn't. For most events only exit 2 blocks; exit 1 is a non-blocking error and the action executes anyway. To enforce policy, you must write exit 2.
  • Official"the hook allowed it, so we have permission" — backwards. A hook's deny is harder than any permission mode (it blocks even under bypassPermissions), but a hook's allow can't beat a deny rule in settings: hooks can only tighten, never loosen — D3's gates always stand.
  • Our take"could a Stop hook lock Claude into a work loop forever?" — the docs left a safety valve: the input includes a stop_hook_active field for your script to self-check, and after 8 consecutive blocks with no progress Claude Code force-ends the turn. Checking that field should be the first line of any Stop hook you write.

Hands-on: doable today

Goal: 30 minutes to configure two hooks — an audit log (PostToolUse) and a sensitive-file blocker (PreToolUse) — and watch a block happen with your own eyes. The examples parse JSON with jq; first confirm jq --version prints something (macOS: brew install jq).

  1. In a test project, create .claude/settings.json with the two hooks:
    {
      "hooks": {
        "PostToolUse": [
          {
            "matcher": "Bash",
            "hooks": [
              {
                "type": "command",
                "command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt"
              }
            ]
          }
        ],
        "PreToolUse": [
          {
            "matcher": "Edit|Write",
            "hooks": [
              {
                "type": "command",
                "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
              }
            ]
          }
        ]
      }
    }
  2. Create the blocker script .claude/hooks/protect-files.sh (copied verbatim from the official example), and run chmod +x .claude/hooks/protect-files.sh:
    #!/bin/bash
    INPUT=$(cat)
    FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
    
    PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
    
    for pattern in "${PROTECTED_PATTERNS[@]}"; do
      if [[ "$FILE_PATH" == *"$pattern"* ]]; then
        echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
        exit 2
      fi
    done
    
    exit 0
  3. Start Claude Code and type /hooks. Expected: one hook each under PostToolUse and PreToolUse, showing the event, matcher, source file, and command.
  4. Trigger the log hook: have Claude run any command (say, "list the files in this directory"), then cat ~/.claude/command-log.txt. Expected: the file has gained the command that just ran — a successful hook shows nothing in the UI; checking the effect is how you verify.
  5. Trigger the blocker hook: touch .env first, then ask Claude to "add a comment line to .env". Expected: the edit is stopped before executing, and Claude receives the Blocked reason from the script's stderr and explains to you that it changed approach or gave up.
  6. The lazy route: the docs explicitly say you can have Claude write hooks for you. Paste the block below straight into Claude Code and let it configure and demo everything itself:
Set up two hooks for the current project, written into .claude/settings.json:
1. PostToolUse with matcher "Bash": use jq to extract .tool_input.command and append it to ~/.claude/command-log.txt;
2. PreToolUse with matcher "Edit|Write": call .claude/hooks/protect-files.sh, a script that checks whether the file path matches .env, package-lock.json, or .git/ — on a match, print the reason to stderr and exit 2 to block; otherwise exit 0.
Make the script chmod +x. Once configured: first explain the three-level structure of this JSON (event → matcher → handlers), then run a test command to verify the log hook, and finally try editing .env to demonstrate a block.

How to know you've learned it

  • Without notes, you can explain the essential difference between hooks and Skills (deterministic execution vs. model discretion), and how hooks relate to D3's permission rules (deny is harder; allow can't overreach).
  • You can match needs to events: which event for auto-formatting? For protecting sensitive files? For "no clocking out while tests fail"? (PostToolUse / PreToolUse / Stop)
  • Both hands-on hooks verified: new lines in the command log; the .env edit blocked with Claude receiving the reason; both findable in /hooks.
  • Self-check: a PreToolUse hook script exits with exit 1 — is the tool call blocked? What about exit 0?
Self-check answerNeither blocks. Only exit 2 (or exit 0 plus a JSON permissionDecision: "deny") blocks; exit 1 is a non-blocking error and the action executes anyway; exit 0 means "no objection" and the call continues through the normal permission flow — which is not the same as approval. If you couldn't answer, re-read "How exit codes and output steer the flow".