Core Features

Hooks: Automated Guardrails

Mixed20 min

CLAUDE.md tells Claude what to do. Hooks make sure it actually happens - automatically, every time, without relying on the model to remember.

A hook is a script that runs in response to a Claude Code event. Edit a file? Auto-format it. About to run a dangerous command? Block it. Session ending? Run your test suite.

Hook Events

There are 7 events you can hook into:

EventWhen it firesCommon use
PreToolUseBefore a tool executesBlock dangerous commands
PostToolUseAfter a tool completesAuto-format edited files
UserPromptSubmitWhen user sends a messageValidate input, add context
StopWhen Claude finishes respondingRun tests, deploy
NotificationWhen Claude sends a notificationExternal alerts
SubagentStopWhen a subagent completesAggregate results
InstructionsLoadedAfter CLAUDE.md files are loadedInject dynamic context

Hook Types

Each hook has a type that determines how it runs:

command - Runs a shell command. The workhorse. Gets JSON on stdin, returns output on stdout.

http - Sends a POST request to a URL. Useful for webhooks and external integrations.

prompt - Asks the AI to make a decision based on the hook context. The model evaluates and decides whether to allow, deny, or modify.

Where Hooks Live

Hooks are configured in your settings files:

  • .claude/settings.json - shared with team (committed)
  • .claude/settings.local.json - personal (gitignored)

Exit Codes

For command hooks, the exit code determines what happens next:

Exit CodeMeaningUse when
0Allow - proceed normallyEverything is fine, or auto-fix succeeded
1Deny - block the actionThe action is dangerous or violates a rule
2Prompt - ask the user to decideThe action might be OK but needs human review

Matchers

Hooks use glob patterns to match tool names. This is how you target specific tools:

"Edit"                  # Matches the Edit tool
"Edit|Write"            # Matches Edit OR Write
"Bash(npm *)"           # Matches Bash calls starting with "npm "
"Bash(rm *)"            # Matches Bash calls starting with "rm "
"*"                     # Matches everything (use sparingly)

The Data Flow

When a hook fires, it receives JSON on stdin with context about the event:

For PreToolUse and PostToolUse, the JSON includes tool_name, tool_input, and (for PostToolUse) tool_output:

{
  "tool_name": "Edit",
  "tool_input": {
    "file_path": "/app/src/utils.ts",
    "old_string": "...",
    "new_string": "..."
  }
}

Your hook script reads this JSON, does its work, and exits with the appropriate code.

Practical Examples

Auto-format on save

The most common hook. Every time Claude edits a file, run Prettier on it:

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

This reads the edited file path from stdin JSON, pipes it to Prettier. Exit code 0 means "all good, carry on."

Block dangerous commands

Prevent Claude from running destructive commands:

.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash(rm -rf *)",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'BLOCKED: rm -rf is not allowed' && exit 1"
          }
        ]
      }
    ]
  }
}

Exit code 1 means deny - the command never executes.

Run linter after file edits

.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx eslint --fix"
          }
        ]
      }
    ]
  }
}

Ask for confirmation on force push

.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash(git push --force*)",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'This will force push. Are you sure?' && exit 2"
          }
        ]
      }
    ]
  }
}

Exit code 2 prompts the user to decide. The message is shown and they can approve or reject.

Hooks are your safety net

The model is probabilistic - it will occasionally forget a rule from CLAUDE.md. Hooks are deterministic. If you have a rule that absolutely must be followed every time, make it a hook, not just a CLAUDE.md instruction.

Hook security

Hooks run shell commands with your user permissions. A malicious CLAUDE.md in a cloned repo could define hooks that execute arbitrary code. Always review .claude/settings.json when cloning unfamiliar repositories, just like you would review a Makefile or postinstall script.

Combining Multiple Hooks

You can chain multiple hooks on the same event. They run in order - if any hook returns exit code 1, the chain stops:

.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          },
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx eslint --fix"
          }
        ]
      }
    ]
  }
}

Exercises

Ex

Create an auto-format hook

Set up a PostToolUse hook in .claude/settings.json that runs Prettier on any file Claude edits or writes.

Requirements:

  • Should trigger on both Edit and Write tools
  • Should extract the file path from the hook's stdin JSON
  • Should not block Claude if formatting fails
Use the Edit|Write matcher to catch both tools. The file path is at .tool_input.file_path in the JSON.
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null; exit 0"
          }
        ]
      }
    ]
  }
}

The 2>/dev/null; exit 0 ensures the hook always exits cleanly even if Prettier fails (e.g., on a non-supported file type). This prevents a formatting error from blocking Claude's work.

Ex

Create a safety hook that blocks rm -rf

Create a PreToolUse hook that prevents Claude from running any rm -rf command. The hook should output a clear message explaining why it was blocked.

Use a PreToolUse hook with a matcher like Bash(rm -rf*). Exit code 1 denies the action.
The message you echo to stdout will be shown to Claude, so make it informative - tell it what to do instead.
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash(rm -rf *)",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'BLOCKED: rm -rf is prohibited. Remove files individually or use git clean for tracked files.' && exit 1"
          }
        ]
      }
    ]
  }
}

When Claude tries to run rm -rf anything, the hook fires first, prints the message, and exits with code 1. Claude sees the denial message and will find an alternative approach.

Show a live demo of the PostToolUse Prettier hook. Edit a file with intentionally bad formatting, then show how Prettier auto-fixes it immediately after Claude's edit. The instant feedback loop is compelling.