CLAUDE.md tells Claude how to behave.

Hooks make Claude actually obey.

That is the difference most people miss.

You can write “do not modify prod.env” in CLAUDE.md.

But whether Claude follows it depends entirely on Claude’s judgment in that moment.

Hooks bypass that judgment.

They are programmable checkpoints that run inside Claude Code’s execution flow — before the action happens, not after.

This is the complete guide.

The problem Hooks solve

Claude Code is powerful.

It reads files. Writes code. Executes commands. Calls APIs.

The more you give it, the more useful it becomes.

But also the more dangerous.

What stops it from modifying a production config at 2am?

What enforces your linting rules every single time?

What logs every sensitive file read without relying on Claude remembering to do it?

CLAUDE.md is guidance.

Hooks are guarantees.

The difference: Hooks run real code. Their logic does not depend on whether the model understands or remembers the rule. It depends on the code you wrote in advance.

What a Hook actually is

A Hook is not a prompt.

It is not another way to inject context.

It is a programmable control mechanism that runs inside Claude Code’s execution flow.

When Claude Code is about to call a tool, write a file, or execute a command — a Hook steps in before the action happens and decides:

→ Allow it → Block it → Ask a human to confirm it

The decision is made by code you wrote in advance.

Not by the model.

Here is the minimal Hook configuration in settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'about to write a file'"
          }
        ]
      }
    ]
  }
}

The word “hooks” appears three times. Each means something different.

Let me unpack the three layers once.

Layer 1 — The event registry.

The outer hooks object. Each key is an event name — PreToolUse, PostToolUse, Stop. Where do you want to intervene? Register that event here.

Layer 2 — The matching rule.

Under each event: an array of matcher objects. matcher: “Write” means this group only triggers when Claude tries to call the Write tool. No matcher = matches everything.

Layer 3 — The actual Hook.

Inside each matcher: the Hooks array. This is the real logic. type: command runs a shell script. type: http calls a URL. type: mcp_tool calls an MCP tool.

hooks ← Entry point for the whole system

└── PreToolUse ← Event: fires before any tool call

└── matcher ← Filter: only match “Write” tool

└── hook ← Action: run this shell command.

The Hook event system

Claude Code has 28 Hook events.

They cover every key point in Claude’s execution:

→ Sessions starting and stopping

→ Tool calls before and after

→ Permission requests

→ File changes

→ Subagent tasks

→ Notifications

One thing most people get wrong: events are siblings, not parents and children.

PreToolUse and PermissionRequest often fire back to back. This makes it look like one causes the other.

They don’t. They are completely independent intervention points. Each has its own matching rules and executes without interfering with the other.

The events split into two types:

Main-flow events — run through the core execution path. These can block Claude. PreToolUse, PermissionRequest, PostToolUse, Stop.

Side-path events — observation and notification channels. They fire at the right moment but don’t change the main flow. Notification, ConfigChange.

The critical difference between them:

Blocking: Hook result determines what Claude does next. Main flow pauses until the Hook returns.

Non-blocking: Hook runs, but Claude doesn’t wait for it. Useful for logging, pushing notifications, syncing state.

How to actually block Claude

This is the part the documentation buries.

Two ways to block a blocking event. They mean completely different things.

Exit 2 → System error

Claude thinks something broke. A tool is unavailable, resources are missing, the environment is broken. It may try to understand the error. It may try a workaround.

#!/bin/bash
# Block with system error signal
if [[ "$TOOL_INPUT" == *"prod.env"* ]]; then
    echo "Environment error: target file locked" >&2
    exit 2
fi
Exit 0 + JSON → Policy rejection

Claude understands this as an explicit business rule saying the operation is not allowed. It doesn’t try to bypass it. It accepts the decision and adjusts behavior.

#!/bin/bash
# Block with policy decision (much better for rules)
TOOL_INPUT=$(cat)
FILE=$(echo "$TOOL_INPUT" | jq -r '.path // ""')
if [[ "$FILE" == *"prod.env"* ]]; then
    echo '{
        "decision": "deny",
        "reason": "Writing to prod.env is not allowed. Use staging.env instead."
    }'
    exit 0
fi
# Allow everything else
echo '{"decision": "allow"}'
exit 0

The JSON approach does more too:

→ Attach a readable rejection reason

→ Modify tool input parameters before Claude uses them via updatedInput

→ Ask for human confirmation instead of auto-denying via “decision”: “ask”

The mistake everyone makes:

exit 1 does nothing. In Unix conventions it means failure. In Claude Code’s Hook system, exit 1 is non-blocking. Claude ignores it and continues.

Only exit 2 or exit 0 + JSON actually affects the flow.

Where Hooks live

Hooks don’t only live in your personal settings.json.

They can be registered in 4 different places. Each has a different scope and lifecycle.

1. Settings-level Hooks — the resident Hooks.

Written in settings.json at user, project, or local level. Active from the start of Claude Code to the end. Never cleaned up between tasks.

~/.claude/settings.json ← user-level, your machine 

.claude/settings.json ← project-level, shared with team 

.claude/settings.local.json ← local overrides, not committed

2. Plugin Hooks — loaded with the plugin.

A Plugin bundles its own CLAUDE.md, Skills, and Hooks. When Claude Code loads the Plugin, its Hooks merge with the main Hooks and run equally. No priority difference — they participate together.

One hard limit: Plugin Subagents cannot define Hooks. This is intentional. A Subagent is a restricted execution unit. Letting it register Hooks would give a lower-privileged role the ability to modify execution-flow control. That breaks the security model.

3. Skill Hooks — scoped to the Skill.

Registered when the Skill is invoked. Automatically cleaned up when the Skill finishes. They don’t pollute the global environment.

---
name: planning-with-files
hooks:
  PreToolUse:
    - matcher: "Write|Edit|Bash|Read"
      hooks:
        - type: command
          command: "cat task_plan.md 2>/dev/null | head -30 || true"
  PostToolUse:
    - matcher: "Write|Edit"
      hooks:
        - type: command
          command: "echo 'File updated. Update task_plan.md status.'"
  Stop:
    - hooks:
        - type: command
          command: "./scripts/final-check.sh"
---

Every time this Skill calls Write, Edit, or Bash, it first prints the first 30 lines of the task plan. After every file write, it reminds Claude to update plan status. When the Skill ends, it runs a final check.

4. Subagent Hooks — scoped to the Subagent.

Same as Skill Hooks. Temporary, auto-cleaned-up. One extra behavior: if you register a Stop Hook in a Subagent’s frontmatter, it automatically converts to SubagentStop at runtime. Because what ends is the Subagent, not the whole session.

How multiple Hooks merge

At any moment, Hooks from multiple layers may be active simultaneously.

When a Write operation fires PreToolUse, it might match Hooks from your user settings, your project config, and the currently active Skill — all at once.

Three rules govern what happens.

Rule 1: Parallel execution.

All matched Hooks run simultaneously. Not serially. Not by priority order.

Your logging Hook and your security check Hook start at the same time and complete independently. Claude waits for all of them before deciding.

Rule 2: Automatic deduplication.

If two layers register the exact same Hook — same event, same matcher, same command string — Claude Code keeps only one copy and runs it once.

This matters in practice: you don’t need to worry about the same script running twice if it appears in multiple config files. But the reverse: if you want the same script to run separately under two different conditions, make sure their command strings are different.

Rule 3: Strictest result wins.

When multiple Hooks return different decisions, Claude picks the strictest one.

deny > ask > allow

One deny is enough. It doesn’t matter which layer it came from.

User-level Hook: allow (ordinary writes are fine) 
Project-level Hook: ask (.env files need confirmation) 
Plugin Hook: deny (prod.env is forbidden) ───────────────────────────── 
Final decision: deny (one veto is enough)

Why this design makes sense: in a security system, allowing requires everyone to agree. Rejecting only needs one veto. This is how every serious security model works.

Two real Hooks worth studying

Theory is easy. Let’s look at two production Hooks and understand why they were designed the way they were.

Case 1: Superpowers plugin — inject context at session start

The superpowers plugin provides a complete Skill system for engineering discipline: requirement clarification, planning, test-driven development, code review.

But it only registers one Hook.

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|clear|compact",
        "hooks": [
          {
            "type": "command",
            "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start"
          }
        ]
      }
    ]
  }
}

One event. One Hook. What does it do?

It injects the Superpowers skill instructions into every session as additional context. Every time a session starts, Claude gets the right initial context automatically.

The insight: Hooks don’t always have to block or approve. Sometimes bringing the right information in at the right moment is the entire job.

Case 2: claude-code-warp plugin — bridge Claude’s lifecycle to a terminal

Warp is a terminal. When you use Claude Code inside Warp, Warp had no idea what Claude was doing — working, waiting, requesting permission, done.

The claude-code-warp plugin fixes this with 6 Hooks:

SessionStart → sends initialization info to Warp 
UserPromptSubmit → tells Warp: Claude started working 
PostToolUse → tells Warp: blocking state cleared 
Notification → triggers Warp notification when Claude is idle PermissionRequest → sends tool name + input preview to Warp 
Stop → reads session, extracts summary, sends completion notification

The Stop Hook is the most interesting one.

It doesn’t just say “done.” It reads the current session content, extracts the last user prompt and Claude’s response, truncates to notification-friendly length, and sends a structured summary to Warp’s notification center.

One Hook turns Claude Code’s internal session record into a completion notification the user can actually read.

The insight: Hooks can act as event bridges — synchronizing Claude Code’s execution state to external systems that have no native awareness of Claude.

A practical Hook you can use today

Protect your production environment files. One script. Copy-paste ready.

Create .claude/hooks/protect-prod.sh:

#!/bin/bash
# Read tool input from stdin
TOOL_INPUT=$(cat)
# Extract file path from the input JSON
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.path // .file_path // ""')
# Check if target is a production config file
PROTECTED_PATTERNS=("prod.env" ".env.production" "prod-secrets" "production.yaml")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
    if [[ "$FILE_PATH" == *"$pattern"* ]]; then
        echo '{
            "decision": "deny",
            "reason": "'"$FILE_PATH"' is a protected production file. Use staging environment instead. If you genuinely need to edit production config, do it manually."
        }'
        exit 0
    fi
done
# Not a protected file - allow
echo '{"decision": "allow"}'
exit 0

Register it in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/protect-prod.sh"
          }
        ]
      }
    ]
  }
}

Make it executable:

chmod +x .claude/hooks/protect-prod.sh

Now every Write and Edit that targets a production config is blocked — with a clear reason — before Claude touches the file. Not because Claude remembers the rule. Because the code enforces it.

The mental model that makes Hooks click

Think of Claude Code’s execution flow as a pipeline.

Without Hooks: Claude runs the whole pipeline. CLAUDE.md guides it. Skills organize it. But the execution itself is uninterrupted.

With Hooks: you insert checkpoints at any point in the pipeline. Each checkpoint runs real code. It can inspect what Claude is about to do, decide whether to allow it, and optionally modify the input before Claude uses it.

The system has three layers working together:

CLAUDE.md — tells Claude how to understand the project 
Skills — organizes Claude into reliable workflows 
Hooks — guards the boundary at key execution points

None of these replaces the others.

CLAUDE.md without Hooks: good guidance, inconsistent enforcement. Hooks without CLAUDE.md: strict enforcement of rules the model doesn’t understand. Both together: reliable behavior at every level.

One warning before you go build Hooks.

Hooks run real code. Effects are immediate and sometimes irreversible.

A script with the wrong exit code can unexpectedly interrupt the flow.

A Stop Hook with poorly handled exit logic can trap the session in a loop.

Test every Hook the way you would test production code.

Handle edge cases. Handle error paths. Log failures explicitly.

The power of Hooks is that they can’t be ignored.

That same property makes bugs in Hooks expensive.

5 things to remember

→ 1. Hooks are not prompts. They are code. They run regardless of model state.

→ 2. exit 1 does nothing. Use exit 2 for system errors. Use exit 0 + JSON for policy decisions.

→ 3. Events are siblings. PreToolUse and PermissionRequest fire independently. One does not cause the other.

→ 4. Strictest result wins. One deny from any layer blocks the operation. Allow requires everyone to agree.

→ 5. Skill and Subagent Hooks are temporary. They register on invocation and clean up on completion. They don’t pollute global state.

If this was useful:

→ Repost to share it with every developer building with Claude Code 
→ Follow @sairahul1 for more deep dives like this 
→ Bookmark this

I write about AI, building products, and systems that work without you.

Keep reading