Claude Code Mastery — Direct Track
CC7 — Automation Module 8 of 16
50 minAdvanced
← CC6: Subagents 🏠 Home CC8: Tool Use Deep Dive →

CC7: Hooks

Programmatic control over Claude's tool calls. Auto-format on every write, block dangerous commands, route permissions to a central policy server, chain pipelines on subagent stop. 9 lifecycle events; 5 handler types; one settings.json block.

Learning Objectives

  • Recognize the 9 hook events across 6 categories and pick the right one for a goal.
  • Configure each of the 5 handler types: command / http / mcp_tool / prompt / agent.
  • Use exit codes (0 / 2 / other) and JSON output to allow / block / inject context.
  • Wire async + HTTP hooks for centralized policy enforcement.
  • Use permissionDecision: "defer" for slow human-in-the-loop approvals.
  • Avoid the four hook hygiene mistakes that quietly break workflows.

What Hooks Are For — Three Real Use Cases

Everyday Analogy

Every hotel room has door cards instead of locks-and-keys. Why? Because the front desk needs to revoke access without fishing for keys, log every entry/exit centrally, and update access rights when a guest extends their stay — all without changing the door itself. The card reader is the hook on the door's behavior: it intercepts the action, runs policy, then either allows or denies.

Hooks in Claude Code are exactly this: a thin layer that runs around Claude's tool calls. Same Claude, same tools, but with hooks you intercept every call — log it, validate it, inject context, modify the input, or block it outright. The "card reader" runs your code, not Claude's, and Claude doesn't know it's there.

Three concrete cases

  • Auto-format on write. Every PostToolUse(Edit|Write) runs prettier/eslint on the changed file. Claude writes ugly code; you only ever see formatted.
  • Block dangerous commands. PreToolUse(Bash) intercepts rm -rf, exits 2 with a stderr message. Claude sees the rejection and tries something else.
  • Route policy to HQ. PreToolUse(*) POSTs every tool call to https://policy.acme.internal. Central server logs, decides allow/deny/ask, and Claude obeys. One audit trail across every developer.

Your First Hook — Auto-Format on Write

Drop this into .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "FILE=$(jq -r '.tool_input.file_path' < /dev/stdin); npx prettier --write \"$FILE\" 2>/dev/null || true"
          }
        ]
      }
    ]
  }
}

That's it. After every Edit or Write, prettier formats the file. The hook receives JSON via stdin (containing tool_input.file_path), extracts the path with jq, and runs prettier. The || true stops a prettier failure from blocking Claude.

What just happened?

You added a guarantee to the codebase: every file Claude edits is auto-formatted. No more "format on save" reminders, no more diffs polluted by indentation. The same pattern works for: tests after every commit, type-checking after every edit, audit logs for every Bash call.

All 9 Hook Events — Grouped by Category

Most workflows use 3-4 events. Here's the full surface so you know what's available:

CategoryEventCan block? (exit 2)Use case
SessionSessionStartnoInject branch/CI/issue context (matchers: startup/resume/clear/compact)
SessionEndnoArchive transcript, emit metrics
PromptUserPromptSubmityesValidate / classify / inject context for typed prompts
ToolPreToolUseyesBlock / modify tool inputs (precedence: deny > ask > allow)
PostToolUsenoLint, format, validate output (feedback to Claude via exit 2)
StopStopyesBlock normal stop — force more iterations
SubagentStopyesBlock / continue when a subagent (Task tool) finishes
CompactPreCompactnoSnapshot critical state before summarization (matchers: manual / auto)
UserNotificationnoCustom TTS, Slack, push notifications

The can-block column matters: only some events let exit code 2 stop the action. Use PreToolUse to block a Bash call; PostToolUse's exit 2 surfaces feedback to Claude after the tool ran (it can't undo the call).

Handler Types — Pick by Style

The Claude Code hook spec uses type: "command" — an executable invoked with the hook JSON on stdin. Within that single primitive you can build five distinct enforcement styles, summarized below. (HTTP / MCP / prompt / agent variants are wrappers around a command-type hook that shells out to curl, an MCP client, the claude -p CLI, or a subagent runner.)

TypeHandlerDefault timeoutWhen to pick it
commandShell script via stdin/stdout600 sDefault; supports shell: bash | powershell, async, asyncRewake
httpPOST to URL with JSON body30 sCentralized policy engine; needs allowedHttpHookUrls + allowedEnvVars
mcp_toolCalls a tool on a configured MCP serverReuse existing MCP capability for enforcement
promptLLM yes/no judgment30 sSoft semantic checks ("is this prompt off-topic?")
agentSubagent verification with tools60 sDeep policy checks needing file access (PII, license audit)

Example: prompt-based check

Not every check should be a regex. Use a prompt-type hook for semantic decisions:

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "User submitted: '$ARGUMENTS'. Is this prompt asking Claude to do something off-topic for a backend engineering project (e.g. write poetry, debate philosophy)? Answer YES or NO only.",
            "model": "haiku"
          }
        ]
      }
    ]
  }
}

If Haiku replies YES, the hook blocks the prompt with a polite redirect. Cheap (Haiku), fast (~30s timeout), and handles fuzziness regex can't.

HTTP Hooks & Defer Permission — The Modern Production Pattern

For organizations: stop scattering hook scripts across every developer's repo. Centralize policy in one HTTP endpoint. Add async + defer for slow human-in-the-loop approvals.

{
  "allowedHttpHookUrls": ["https://policy.acme.internal/*"],
  "httpHookAllowedEnvVars": ["POLICY_TOKEN"],
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Write|Edit",
        "hooks": [
          {
            "type": "http",
            "url": "https://policy.acme.internal/check",
            "headers": { "Authorization": "Bearer $POLICY_TOKEN" },
            "allowedEnvVars": ["POLICY_TOKEN"],
            "async": true,
            "asyncRewake": true,
            "timeout": 5,
            "statusMessage": "Checking corporate policy..."
          }
        ]
      }
    ]
  }
}

The endpoint receives the tool-use JSON, runs whatever policy logic, returns a JSON response with hookSpecificOutput.permissionDecision set to one of:

  • allow — tool call proceeds.
  • deny — blocked, message returned to Claude.
  • ask — user prompted (escapes auto-approval).
  • deferpark the tool call, returns stop_reason: "tool_deferred"; resume later with claude --resume <session-id> after policy decision lands.

Defer requires Claude Code v2.1.89+ in non-interactive (-p) mode. Pattern: pre-commit checks send Bash calls to a Slack approval bot; while the SRE thumbs-ups in Slack, the session sits in deferred state; resume re-runs with the decision.

Why HTTP hooks beat shell scripts at scale

Shell scripts: every dev clones the repo, hopes the script runs, has different shell versions. HTTP hook: one server, one truth, one place to update policy, one log destination, one set of metrics. When the security team needs to add "block any commit message containing customer names," they edit the server, not 47 dev machines.

Hook Output Anatomy — Exit Codes & JSON

Hooks communicate with Claude Code two ways: exit codes (simple) and JSON output (rich). Most events accept both.

ChannelMeaningExample
exit 0 + plain stdoutSuccess; stdout shown in transcript as system messageLogging, status messages
exit 0 + JSON stdoutSuccess; parse JSON for structured responseInject context, modify tool input, set permission
exit 2Block (only certain events); stderr fed back to ClaudeRefuse rm -rf
Other exit codeNon-blocking error; stderr shown in transcriptHook script crashed

Common JSON output fields

{
  "continue": true,
  "stopReason": "Optional message when continue=false",
  "suppressOutput": false,
  "systemMessage": "Warning shown to user",
  "decision": "block",
  "reason": "Explanation for decision",
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "permissionDecisionReason": "Pre-approved by policy",
    "updatedInput": { "command": "git diff --name-only" },
    "additionalContext": "Note: this run is part of release pipeline"
  }
}

Useful hook input fields (always present in stdin)

  • session_id — current session ID, for log correlation.
  • transcript_path — path to the JSON-lines conversation history.
  • cwd — current working directory when the hook fires.
  • hook_event_name — the event firing this hook (e.g. PreToolUse).
  • tool_name + tool_input — on PreToolUse.
  • tool_name + tool_input + tool_response — on PostToolUse.
  • prompt — on UserPromptSubmit.
  • stop_hook_active — on Stop / SubagentStop (true if Claude is already continuing because of a stop hook; check this to avoid infinite loops).

Hook Hygiene — Limits That Bite You

Four limits worth memorizing

1. Output cap: 10,000 chars. Excess saved to a file with a path stub. Don't cat huge-log from a hook.

2. Deduplication. Identical command strings or HTTP URLs run only ONCE per event. Don't expect side effects from "two copies."

3. Stale context on resume. Mid-session additionalContext is saved in the transcript with timestamps and SHAs that go stale on resume. Use SessionStart with the resume matcher to refresh.

4. Browse before debug. Type /hooks to see every active hook with source labels (User / Project / Local / Plugin / Session / Built-in). Read-only verification before you start blaming Claude.

Async vs sync — when to use which

async: true — hook runs in background, agent doesn't wait. Useful for telemetry, slow logging. Cannot block.

asyncRewake: true — like async, but if the hook exits 2 the agent gets woken with a system reminder. Lets you do slow validation that can still inject context (just not synchronously).

Default (sync) — agent blocks until hook completes. Use for permission decisions and anything where the next tool call depends on the hook's verdict.

Hands-On Lab — Block Java Edits That Fail Spotless

Working in the PublicRecords API. You'll wire a PostToolUse hook that runs mvn -q spotless:check after every Edit/Write touching a .java file, and exit 2 if formatting drifts — injecting a system reminder that forces Claude to fix the code before the next step. About 12 minutes.

Step 1 — Add the Spotless plugin to pom.xml

How it works

Spotless is a Maven plugin that enforces code style. mvn spotless:apply reformats your Java sources to Google Java Format. mvn spotless:check exits non-zero if any file is out of compliance — that exit code is what we'll use to gate Claude's edits.

Add this plugin block inside <build><plugins> in your existing pom.xml:

XML Maven Plugin
pom.xml — inside <build><plugins>
<plugin>
  <groupId>com.diffplug.spotless</groupId>
  <artifactId>spotless-maven-plugin</artifactId>
  <version>2.43.0</version>
  <configuration>
    <java>
      <googleJavaFormat/>
      <trimTrailingWhitespace/>
      <endWithNewline/>
    </java>
  </configuration>
</plugin>

Baseline the existing source (formats everything to Google style), then verify clean:

mvn spotless:apply && mvn spotless:check

Step 2 — Write the hook

How it works

Hooks run on lifecycle events — PostToolUse fires after every tool call. The matcher filters which tool calls trigger the hook (here: only Edit and Write). The command receives the tool call's JSON on stdin. jq pulls out the file path; if it's a .java file we run Spotless. Exit code 2 is special — it tells Claude the action was blocked and surfaces a system reminder.

This file is the same .claude/settings.json you started in CC4 — merge the new hooks key in alongside permissions, don't replace the file:

JSON Hook Config (merge with CC4)
.claude/settings.json
{
  "permissions": { "...": "from CC4" },
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "FILE=$(jq -r '.tool_input.file_path'); case \"$FILE\" in *.java) mvn -q spotless:check 1>&2 || exit 2 ;; esac"
          }
        ]
      }
    ]
  }
}

Step 3 — Verify the hook is loaded

> /hooks

Your PostToolUse hook should appear with source "Project." If it's missing, the JSON didn't parse — most often a trailing comma where you merged with permissions, or unescaped quotes inside the command string.

Step 4 — Make a deliberately bad edit

> In FilingController.java, add a new endpoint
   GET /filings/count that returns the total count.
   Use 2-space indentation throughout (don't worry about Google style).

Watch what happens: Claude makes the edit, the hook fires, mvn -q spotless:check reports formatting drift, the hook exits 2. Claude receives a system reminder like "PostToolUse hook exited with code 2: Spotless check failed" and either runs mvn spotless:apply or rewrites the method with 4-space indent. Either way, the edit doesn't proceed until the formatter is happy.

Step 5 — Confirm clean edits aren't blocked

> Now add a JavaDoc to the new endpoint method, properly formatted.

Hook fires, Spotless passes, exit 0, no system reminder. Claude continues to the next step (probably running tests). The hook is invisible when things are good — that's the goal.

Step 6 — Inspect the hook's tool ribbon

Look at the tool ribbon on a recent turn. After each Edit tool call, there's a hook entry showing the command that ran and its exit code. That's your audit trail — if a teammate ever asks "did Claude really run the formatter on that PR?", point them at the transcript.

Stretch — Make it async + non-blocking

Change the hook entry to add "async": true, "asyncRewake": true. Now Spotless runs in the background; Claude continues immediately. If the format check fails, Claude gets woken with the system reminder a few seconds later (instead of being blocked synchronously). Useful when mvn warm-up is slow and you don't want to pay the latency on every edit.

Stretch — Wire in the CC6 PII auditor

Add a second hook on PreToolUse matching Bash(git commit:*) that invokes the pii-auditor subagent. If it returns anything other than "PII audit clean", exit 2 to block the commit. You've now got formatter + PII gate, both automatic.

Windows note

Replace the bash-style command with PowerShell:

"command": "pwsh -NoProfile -Command \"$j = [Console]::In.ReadToEnd() | ConvertFrom-Json; if ($j.tool_input.file_path -like '*.java') { mvn -q spotless:check; if ($LASTEXITCODE -ne 0) { exit 2 } }\""
Lab complete — what you should have

A PostToolUse hook that runs Spotless on every Java edit, blocks Claude with exit 2 on drift, and lets clean edits through silently. Plus a working understanding of the three knobs that matter: matcher (which tools), command exit code (allow / block), and async/sync (latency tradeoff). CC9 connects an external Postgres MCP server so Claude can query the UCC schema in plain English.

Knowledge Check

1. You want to auto-format every file Claude edits. Which event?

A
PreToolUse with matcher: Edit|Write
B
PostToolUse with matcher: Edit|Write
C
SessionEnd
D
UserPromptSubmit
Correct. PostToolUse fires after the edit lands, perfect for formatting. Pre would fire before the file even exists in its new state.
Look again. Format after the edit completes → PostToolUse. Pre fires before the change happens.

2. You want to block any Bash call containing rm -rf. Which exit code?

A
exit 1 — standard error.
B
exit 2 — with stderr explaining why.
C
exit 0 with empty output.
D
exit 127 — command not found.
Correct. Exit 2 is the blocking signal. stderr message is fed back to Claude so it knows why and can try something else.
Look again. Exit 2 is the special "block" signal — on a blockable event, Claude Code respects it and surfaces stderr to the model.

3. Your team needs centralized policy: log every Bash call, allow/deny based on a server, audit trail in one place. Which handler type?

A
command — shell script per dev.
B
http — POST to a central policy endpoint.
C
prompt — ask Haiku each time.
D
agent — subagent for each call.
Correct. http hooks centralize policy: one server, one log, one place to update. Don't forget allowedHttpHookUrls + httpHookAllowedEnvVars in settings.
Look again. http is the right type for centralized policy. command means N copies on N machines — the opposite of what you want.

4. permissionDecision: "defer" — what does it do?

A
Postpones the decision until the next prompt.
B
Parks the tool call. Session pauses; resume with --resume <id> after policy decision lands.
C
Same as ask — just prompts the user.
D
Auto-allows after a delay.
Correct. Defer is for slow approvals (e.g., Slack approval). Returns stop_reason: "tool_deferred". Requires v2.1.89+ in non-interactive mode (-p flag).
Look again. Defer parks the call so a slow async decision (e.g., human in Slack) can land. Resume with claude --resume <id>.

5. You configured the same command: "./check.sh" hook on both PreToolUse and PostToolUse for the same matcher. What runs?

A
Both hooks run, twice per tool call.
B
Hook deduplication: identical command strings run only ONCE per event — won't double-fire.
C
Only Pre runs; Post is ignored.
D
Error — duplicate config rejected.
Correct. Hook deduplication: identical command strings (or HTTP URLs) run once per event. So in your case, Pre runs once, Post runs once — not double-fired within either event. Don't expect side-effects from duplicate definitions.
Look again. Within each event, identical handlers run only once (deduplication). Pre runs once, Post runs once. They're different events so both fire, but neither double-fires.

Module Summary

  • 9 events across 6 categories: Session (SessionStart, SessionEnd) / Prompt (UserPromptSubmit) / Tool (PreToolUse, PostToolUse) / Stop (Stop, SubagentStop) / Compact (PreCompact) / User (Notification).
  • 5 handler types: command (default, shell), http (centralized), mcp_tool, prompt (LLM), agent (subagent verification).
  • Communication: exit codes (0 success, 2 block, other = non-blocking error) + optional JSON for rich responses.
  • Permission decisions: allow / deny / ask / defer (latter requires v2.1.89+ in -p mode).
  • Async: async for fire-and-forget, asyncRewake for slow validation that can still inject context.
  • Limits: 10K char output cap, hook deduplication per event, stale-on-resume context.
  • Browse with /hooks; debug before blame.