Your Agent Has Skills — Now Give It Guardrails
Last Updated: March 2026

🪝
OpenClaw Hooks
Deterministic Control

Skills tell your agent what it can do. Hooks enforce what it must and must not do — regardless of what it decides. Protect .env from ever being overwritten. Get a notification the moment Claude needs your input. Require tests to pass before it can declare anything done. Set these up once and they run silently in every session.

⏱️ 45 min 🎯 Intermediate 💰 Free 🔧 Works on EC2 & Mini PC

From "I Hope It Does the Right Thing" to "It Always Does"

Skills teach your agent what to do. Hooks are different — they're guarantees about how it behaves, regardless of what it decides. A hook isn't a suggestion. It's a tripwire that fires every time a specific event happens in your session.

Write a hook that blocks edits to .env and your credentials can never be accidentally overwritten — not by Claude misreading a request, not by a skill with a bug, not by you being tired and giving a careless instruction. Write a hook that runs your test suite before Claude declares a task complete and it can never ship broken code. This is deterministic control.

The Four Handler Types

Hooks can do four different things when they fire. Choose the type based on what you need the hook to actually accomplish:

commandmost common
Runs a shell script or command. Reads event data from stdin as JSON. Communicates back via stdout (allow/deny decisions), stderr (feedback to Claude), and exit codes. The most flexible type — you can do anything a shell script can do.
promptLLM check
Sends a single-turn prompt to a small model (Haiku by default). Good for semantic checks that shell scripts can't do easily: "Does this commit message follow conventional commit format?" or "Is this response complete?" Returns a JSON decision.
agentfull subagent
Spawns a full subagent with tool access for multi-step verification. Use when a simple command or one-shot LLM check isn't enough — for example, actually running your test suite and verifying the results before allowing a Stop event.
httpremote endpoint
POSTs event data to an HTTP endpoint. Use for logging to external services, triggering webhooks, or integrating with monitoring systems. The endpoint can return a decision the same way a command hook does.

The Exit Code Protocol

For command type hooks, exit codes control what happens next. This is the mechanism that gives hooks their power:

📝 Exit code behavior
Exit 0   → Allow. Claude proceeds. Stdout is parsed for optional JSON decisions.
Exit 2   → Block. Claude does NOT proceed. Whatever you wrote to stderr is fed back
           to Claude as context — it will read your reason and respond to it.
Any other → Non-blocking error. Claude proceeds. Stderr is logged but Claude doesn't see it.

The exit 2 feedback loop is what makes hooks more than just blockers. When you exit 2 with a message like "Blocked: .env matches protected file pattern", Claude reads that reason and adjusts. It's not just stopped — it's informed.

PostToolUse hooks cannot block. The tool has already run by the time PostToolUse fires. Exit codes on PostToolUse hooks are ignored for blocking — they're feedback-only. Use PreToolUse if you need to stop something from happening.

Where Hook Configuration Lives

Hooks are configured in a hooks block inside Claude Code's settings files. Which file you use determines who the hooks apply to:

~/.claude/settings.json
Global — all your projects. Hooks here fire in every session, every project. Use for personal workflow hooks: notifications, bash command logging, anything that should always be true about how you work.
.claude/settings.json
Project — this repo only. Safe to commit. Use for project-specific hooks: file protection for your specific sensitive files, formatting rules, deployment guards. Your team inherits the hooks when they check out the project.
.claude/settings.local.json
Project — this repo only. Gitignored. For hooks that are project-specific but personal — your local credentials, paths that differ between team members, anything you don't want in the shared repo.
📝 Basic hook config structure
{
  "hooks": {
    "EventName": [
      {
        "matcher": "ToolNameOrPattern",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/script.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

The matcher field filters which tool calls trigger the hook. Use a tool name like "Bash", a pipe-separated list like "Edit|Write", or an empty string "" to match everything. Matchers are case-sensitivebash won't match anything. Tool names are Bash, Edit, Write, Read, Glob, Grep.

The Hook Events That Matter Most

There are 21 hook events total. Most are specialized. For day-to-day use, these six cover 90% of what people actually set up:

PreToolUsecan block
Fires before any tool call. Exit 2 cancels the call entirely and feeds your stderr back to Claude. The workhorse hook. Use for file protection, blocking destructive commands, enforcing naming conventions.
PostToolUsefeedback only
Fires after a tool call completes. Cannot block, but can feed information back. Use for auto-formatting files after edits, logging commands, triggering side effects.
Stopcan block
Fires when Claude thinks it's finished and is about to stop responding. Exit 2 forces it to keep working. Use for test enforcement: "don't stop until the test suite is green."
Notificationobserve only
Fires when Claude sends a notification — idle, needs permission, task complete. Cannot block. Use to trigger desktop alerts, Slack messages, sound effects.
SessionStartobserve only
Fires at session start. Supports a matcher for the trigger type: startup, resume, compact. The compact trigger is particularly useful — re-inject context after the history gets summarized.
PermissionRequestcan auto-approve
Fires when Claude needs permission to use a tool. Return a JSON decision to auto-approve specific tool types without the interactive prompt. Useful for removing friction from tools you trust unconditionally in a given project.

Know When Your Agent Needs You

The most universally recommended hook in the entire community — and the one you should set up first. When you're running an agent on a server and step away to do something else, you have no idea when it stops, gets stuck, or needs a decision from you. A notification hook fixes that. One config block, set once, forget forever.

Desktop Notification Hook

Open your global settings file and add the hooks block. This fires any time Claude sends a notification — idle state, waiting for permission, task complete:

☁️ AWS EC2 / 🖥️ Mini PC — open global settings
nano ~/.claude/settings.json
📝 ~/.claude/settings.json — macOS notification
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude needs your attention\" with title \"Claude Code\" sound name \"Ping\"'"
          }
        ]
      }
    ]
  }
}
📝 ~/.claude/settings.json — Linux notification
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "notify-send 'Claude Code' 'Needs your attention' --urgency=normal --icon=dialog-information"
          }
        ]
      }
    ]
  }
}
Running on a headless server (EC2)? Desktop notifications don't apply to a remote server — but you can route the notification to wherever you actually are. Replace the command with a curl call to your phone via Pushover or Ntfy, a Discord webhook, a Slack incoming webhook, or a text message via Twilio. The hook itself is the same — only the delivery method changes.

Notification via Discord Webhook

If you're already using OpenClaw through Discord, routing idle notifications back to a private Discord channel is a natural fit. You'll know Claude is waiting without having to check your terminal:

📝 ~/.claude/settings.json — Discord webhook notification
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "curl -s -X POST \"$DISCORD_WEBHOOK_URL\" -H 'Content-Type: application/json' -d '{\"content\": \"🤖 Claude Code needs your attention\"}'"
          }
        ]
      }
    ]
  }
}

Store your webhook URL as an environment variable rather than hardcoding it in the settings file. Add it to your shell profile:

☁️ AWS EC2 / 🖥️ Mini PC
echo 'export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/..."' >> ~/.bashrc
source ~/.bashrc
Never put webhook URLs directly in settings files you commit to git. The ~/.claude/settings.json global file isn't in a repo, so it's fine there. If you add notification hooks to a project's .claude/settings.json, use an environment variable the same way — no credentials in committed files.

Notification via Ntfy (Self-Hosted Push)

Ntfy is a lightweight open-source push notification service. Run it on your Mini PC alongside OpenClaw and you have private push notifications to your phone — no third-party service, no account required:

📝 ~/.claude/settings.json — Ntfy push notification
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "curl -s -d 'Claude Code needs your attention' ntfy.sh/your-topic-name"
          }
        ]
      }
    ]
  }
}

Replace your-topic-name with any unique string — that's your private channel. Subscribe to it in the Ntfy mobile app. Messages appear as push notifications in seconds. For a self-hosted instance, replace ntfy.sh with your server address.

Task Complete Notification

Want a different alert when the agent finishes a task versus when it's just idle? Use the Stop event for task completion:

📝 ~/.claude/settings.json — separate idle and done alerts
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "notify-send 'Claude Code' 'Waiting for input'"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "notify-send 'Claude Code' 'Task complete ✅' --urgency=low"
          }
        ]
      }
    ]
  }
}
Stop hooks can also block. If your Stop hook exits with code 2, Claude won't stop — it keeps working. We use this for test enforcement in the Workflow section. Here we're just observing (exit 0), so it fires the notification and lets Claude stop normally.

PreToolUse — The Guardian Hook

PreToolUse fires before any tool call and can exit 2 to cancel it entirely. Whatever you write to stderr becomes feedback Claude reads and responds to. This is the hook pattern that enforces your file protection rules with zero exceptions — a natural complement to the Securing Your Secrets tutorial, but now enforced at the agent level, not just the filesystem level.

Protect Sensitive Files

This script blocks any Edit or Write tool call that targets a file matching your protected patterns. The agent receives the block reason as feedback and will propose an alternative approach instead of silently failing.

First, create the hooks directory and the script:

☁️ AWS EC2 / 🖥️ Mini PC — project hooks directory
mkdir -p .claude/hooks
nano .claude/hooks/protect-files.sh
📝 .claude/hooks/protect-files.sh
#!/bin/bash
# Blocks Edit/Write tool calls to sensitive files.
# Exit 2 cancels the call; stderr is fed back to Claude as context.

INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

# Files and patterns to protect — customize this list
PROTECTED_PATTERNS=(
  ".env"
  ".env.local"
  ".env.production"
  "package-lock.json"
  ".git/"
  "openclaw.json"
  "id_rsa"
  "id_ed25519"
  ".pem"
  ".key"
)

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "BLOCKED: '$FILE_PATH' matches protected pattern '$pattern'." >&2
    echo "To modify this file, the user must edit it directly." >&2
    echo "If you need to update environment variables, ask the user to do it." >&2
    exit 2
  fi
done

exit 0
Make the script executable
chmod +x .claude/hooks/protect-files.sh

Wire it into the project settings:

Create or edit .claude/settings.json
nano .claude/settings.json
📝 .claude/settings.json — file protection hook
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}
$CLAUDE_PROJECT_DIR is an environment variable Claude Code sets to the root of your current project. Using it in the command path means the hook works correctly regardless of which subdirectory you're running from.

Block Destructive Shell Commands

A second protection hook for Bash calls — catches patterns that can cause irreversible damage before they run. The agent reads the block reason and will either ask you to confirm or find a safer approach:

Create the script
nano .claude/hooks/guard-bash.sh
📝 .claude/hooks/guard-bash.sh
#!/bin/bash
# Blocks dangerous shell command patterns before execution.

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

# Patterns to block — exit 2 cancels, stderr goes back to Claude
DANGEROUS_PATTERNS=(
  "rm -rf"
  "rm -r /"
  "DROP TABLE"
  "DROP DATABASE"
  "git push --force"
  "> /dev/sda"
  "mkfs"
  "dd if="
)

for pattern in "${DANGEROUS_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qiF "$pattern"; then
    echo "BLOCKED: Command contains destructive pattern: '$pattern'" >&2
    echo "This operation requires explicit user confirmation before proceeding." >&2
    echo "Please describe what you're trying to accomplish and ask the user to approve." >&2
    exit 2
  fi
done

exit 0
Make executable and add to settings
chmod +x .claude/hooks/guard-bash.sh
📝 .claude/settings.json — add the Bash guard
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh",
            "timeout": 10
          }
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-bash.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}
The feedback loop is the point. When exit 2 fires, Claude doesn't just stop — it reads your stderr message and responds to it. "This operation requires explicit user confirmation" means Claude will surface the question to you rather than retrying silently. Write your block messages like instructions to the agent, not just error codes.

Global File Protection (All Projects)

The hooks above live in your project's .claude/ directory and only protect that project. For rules that should apply everywhere — never touch ~/.ssh/, never overwrite your global dotfiles — put a protection hook in your global settings:

Create a global hooks directory
mkdir -p ~/.claude/hooks
nano ~/.claude/hooks/protect-global.sh
📝 ~/.claude/hooks/protect-global.sh
#!/bin/bash
# Global file protection — applies to every project.

INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

GLOBAL_PROTECTED=(
  ".ssh/"
  ".gnupg/"
  ".aws/credentials"
  ".claude/settings.json"
)

for pattern in "${GLOBAL_PROTECTED[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "BLOCKED: '$FILE_PATH' is globally protected and cannot be modified by the agent." >&2
    exit 2
  fi
done

exit 0
Make executable
chmod +x ~/.claude/hooks/protect-global.sh
📝 ~/.claude/settings.json — add global protection
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/protect-global.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

Global hooks and project hooks stack — both run when you're inside a project. The global protection fires first, then the project-level protection. If either exits 2, the tool call is cancelled.

Hooks That Make Your Workflow Effortless

Protection hooks tell the agent what it can't do. Workflow hooks do the opposite — they make useful things happen automatically so you don't have to think about them. Auto-format every file the agent touches. Run your test suite before it can call a task done. Re-inject your project context after the history compacts. Set these up once and they run silently in the background forever.

Auto-Format After Every Edit

A PostToolUse hook on Edit|Write events. Every time Claude writes or edits a file, the formatter runs on it automatically. The result: consistently formatted code with zero effort, even across a multi-file refactor.

📝 .claude/settings.json — Prettier auto-format
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path // empty' | xargs -I{} sh -c 'test -f \"{}\" && npx prettier --write \"{}\" 2>/dev/null || true'"
          }
        ]
      }
    ]
  }
}

Using Python instead of Node? Swap Prettier for Black:

📝 PostToolUse — Python Black formatter
"command": "jq -r '.tool_input.file_path // empty' | xargs -I{} sh -c 'test -f \"{}\" && black \"{}\" --quiet 2>/dev/null || true'"

Or Go's formatter:

📝 PostToolUse — Go gofmt
"command": "jq -r '.tool_input.file_path // empty' | xargs -I{} sh -c 'test -f \"{}\" && gofmt -w \"{}\" 2>/dev/null || true'"
Requires jq. The hook reads the file path from the JSON event data that Claude Code pipes to stdin. Install jq if you don't have it: sudo apt install jq (Ubuntu) or brew install jq (macOS).

Log Every Bash Command

A lightweight audit log of every shell command the agent runs. Useful for understanding what a long-running task actually did, debugging unexpected changes, or reviewing the session history after the fact.

📝 ~/.claude/settings.json — bash command log
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '\"[\" + (now | todate) + \"] \" + (.tool_input.command // \"(no command)\")' >> ~/.claude/bash-log.txt"
          }
        ]
      }
    ]
  }
}

Each line in ~/.claude/bash-log.txt is a timestamped command. Check it with tail -f ~/.claude/bash-log.txt to watch in real time or cat ~/.claude/bash-log.txt | grep "rm" to audit deletions.

Tests Before Stop — The Stop Hook

The most impactful workflow hook for anyone writing code. The Stop event fires when Claude thinks it's done and is about to stop responding. Exit 2 forces it to keep working — with your stderr reason as its next instruction.

The result: Claude cannot declare a task complete until your test suite is green. It will fix whatever is broken, then try to stop again, and the hook will check again.

Create the script
nano .claude/hooks/require-tests.sh
📝 .claude/hooks/require-tests.sh
#!/bin/bash
# Force Claude to run tests before stopping.
# Exit 2 keeps Claude working; it reads our message and responds.

INPUT=$(cat)

# Prevent infinite loops — if this hook itself is already running, bail out
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
  exit 0
fi

# Run the test suite
TEST_OUTPUT=$(npm test 2>&1)
TEST_EXIT=$?

if [ $TEST_EXIT -ne 0 ]; then
  echo "Tests are failing. Fix them before stopping." >&2
  echo "" >&2
  echo "Test output:" >&2
  echo "$TEST_OUTPUT" | tail -30 >&2
  exit 2
fi

# Tests passed — allow stop
exit 0
Make executable
chmod +x .claude/hooks/require-tests.sh
📝 .claude/settings.json — add the Stop hook
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/require-tests.sh",
            "timeout": 120
          }
        ]
      }
    ]
  }
}
The stop_hook_active check is not optional. Without it, if tests fail and the hook exits 2, Claude tries to fix them and stops again, triggering the hook again, which checks tests again, creating a loop. Always read stop_hook_active from the event JSON and exit 0 immediately if it's true.

Not using npm? Swap the test command for yours:

📝 Common test command variants
# Python pytest
TEST_OUTPUT=$(python -m pytest 2>&1)

# Go tests
TEST_OUTPUT=$(go test ./... 2>&1)

# Ruby RSpec
TEST_OUTPUT=$(bundle exec rspec 2>&1)

# Bun
TEST_OUTPUT=$(bun test 2>&1)

Re-Inject Context After Compaction

When your conversation history fills the context window, Claude Code automatically compacts it — summarizing old turns to free up space. Useful background rules you set in your memory files survive this, but ad-hoc session reminders ("use Bun not npm", "we're working on the auth refactor") get lost in the summary.

The SessionStart hook with matcher: "compact" fires specifically after a compaction event. Use it to re-inject whatever context matters most:

📝 .claude/settings.json — re-inject after compaction
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Context restored after compaction. Key reminders: use Bun (not npm), run bun test before committing, current branch is feature/auth-refactor. Continue from where we left off.'"
          }
        ]
      }
    ]
  }
}

For a more dynamic version — one that pulls live context rather than a static string — use shell commands in the output:

📝 Dynamic compact hook — live context injection
"command": "echo \"Context compacted. Branch: $(git branch --show-current). Last 5 commits: $(git log --oneline -5 | tr '\n' ' '). Continue from where we left off.\""
This pairs well with a project-level AGENTS.md rule. If your AGENTS.md instructs the agent to always check the current branch and recent commits at the start of a compacted session, you've created a self-healing context loop — the memory file tells it to look, the hook provides the data.

Auto-Approve Known Safe Permissions

The PermissionRequest hook fires before the interactive "allow this tool?" prompt. Return a JSON allow decision to skip the prompt entirely for tools you trust unconditionally in a given project:

📝 .claude/settings.json — auto-approve read-only tools
{
  "hooks": {
    "PermissionRequest": [
      {
        "matcher": "Read|Glob|Grep",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"permissionDecision\": \"allow\", \"permissionDecisionReason\": \"Read-only tools are always safe in this project\"}}'"
          }
        ]
      }
    ]
  }
}

Use this for tools you'd click "allow" on every single time anyway. Keep the interactive prompt for anything that modifies files or runs commands — the friction there is intentional.

When Hooks Don't Fire and How to Fix Them

Hooks are powerful but have a handful of sharp edges. Most problems come down to the same three things: wrong tool name casing, unexpected shell output contaminating your JSON, or an infinite loop in a Stop hook. This section covers all of them.

My Hook Isn't Firing

Tool namecase matters
Matchers are case-sensitive. The correct tool names are Bash, Edit, Write, Read, Glob, Grep — all capitalized. bash and edit will never match anything. This is the most common hook setup mistake.
Script notexecutable
Shell scripts must be executable before Claude Code will run them. chmod +x .claude/hooks/your-script.sh — easy to forget when creating a new script. If the file isn't executable, the hook silently fails.
Wrongsettings file
Make sure the hook is in the right settings file for your intent. Global hooks go in ~/.claude/settings.json. Project hooks go in .claude/settings.json inside the project root. A hook in a project's settings file won't fire in a different project.
Headlessmode exception
PermissionRequest hooks do not fire in headless mode (claude -p "..." non-interactive flag). If you need to intercept or auto-approve tool calls in headless mode, use PreToolUse instead.
💬 View all currently configured hooks
/hooks

The /hooks command opens a read-only browser of every hook configured in your current session — global and project. If a hook isn't listed here, it's not loaded. Check the file path and JSON syntax.

Stop Hook Infinite Loop

The classic Stop hook mistake: your hook exits 2, Claude keeps working, finishes, tries to stop again, your hook exits 2 again, and you're stuck.

The fix: always check stop_hook_active in the event JSON at the top of every Stop hook script. If it's true, a Stop hook is already running in this cycle — exit 0 immediately and let Claude stop.

📝 Required pattern for every Stop hook
#!/bin/bash
INPUT=$(cat)

# REQUIRED: prevent infinite loop
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
  exit 0
fi

# Your actual hook logic below this line...

If you're already in an infinite loop and Claude is stuck, press Ctrl+C to interrupt the session, then fix the script before reconnecting.

Shell Output Contaminating JSON

Hook scripts run in non-interactive shells, but your ~/.zshrc or ~/.bashrc may still produce output — welcome messages, colorized prompts, nvm load messages, conda initialization text. This output gets mixed with your hook's intended stdout, breaking the JSON parsing Claude Code does.

Wrapinteractive output
In your shell profile, wrap any output that should only appear in interactive terminals:

if [[ $- == *i* ]]; then
    echo "Welcome message"
fi
Redirectstderr
In your hook scripts, redirect tool stderr to /dev/null for commands that produce verbose output you don't want Claude to see: npm test 2>/dev/null. Be intentional about what goes to stderr — Claude reads it.
Teststandalone
Before wiring a hook in, run the script standalone in a fresh shell: bash -l .claude/hooks/your-script.sh < /dev/null. The -l flag loads your shell profile. If it produces unexpected output here, it will in the hook too.

Debugging with Verbose Mode

When a hook is configured but behaving unexpectedly, enable verbose mode to see exactly what's firing, what data it received, and what it returned:

💬 Toggle verbose output in-session
Ctrl+O

Or start a session with debug logging enabled:

☁️ AWS EC2 / 🖥️ Mini PC
claude --debug

Debug output shows the full lifecycle of each hook: which event fired, which hooks matched, the stdin JSON payload, stdout/stderr, and the exit code. If something is wrong, it's almost always visible here.

Putting It All Together

A complete .claude/settings.json for a typical project — combining all the hooks from this tutorial into one file:

📝 .claude/settings.json — full project hooks setup
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh",
            "timeout": 10
          }
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-bash.sh",
            "timeout": 10
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path // empty' | xargs -I{} sh -c 'test -f \"{}\" && npx prettier --write \"{}\" 2>/dev/null || true'"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/require-tests.sh",
            "timeout": 120
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"Context compacted. Branch: $(git branch --show-current). Continue from where we left off.\""
          }
        ]
      }
    ]
  }
}

And the global ~/.claude/settings.json for personal hooks that apply everywhere:

📝 ~/.claude/settings.json — global hooks
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/protect-global.sh",
            "timeout": 10
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '\"[\" + (now | todate) + \"] \" + (.tool_input.command // \"(no command)\")' >> ~/.claude/bash-log.txt"
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "notify-send 'Claude Code' 'Needs your attention'"
          }
        ]
      }
    ]
  }
}
Hooks are set-and-forget infrastructure. Spend an hour setting them up correctly and they silently improve every session that follows. The notification hook alone is worth the time — it changes how you work with a remote agent once you stop having to actively watch it.