GUIDES Apr 14, 2026 12 min read

Claude Code Hooks Tutorial: Automate Your AI Coding Workflow

Claude Code Hooks Tutorial: Automate Your AI Coding Workflow Overview Claude Code hooks are event-driven scripts that fire in response to lifecycle events — before a tool runs, after it…

by Bugi 12 min

Claude Code Hooks Tutorial: Automate Your AI Coding Workflow

Overview

Claude Code hooks are event-driven scripts that fire in response to lifecycle events — before a tool runs, after it completes, when a session starts, or when the agent is about to stop. They let you enforce guardrails, inject context, and automate repetitive checks without manual intervention.

This guide covers hook configuration in settings.json, all nine hook events, the matcher syntax, and practical examples you can copy into your project today. It targets developers already using Claude Code who want finer control over agent behavior.

Tool
Claude Code
Latest Version
1.x
Pricing
Free with Claude Pro ($20/mo), Team ($30/mo), Max ($100–200/mo)
Platforms
macOS, Linux, Windows
Last Checked
2026-04-10

Prerequisites

  • OS: macOS, Linux, or Windows
  • Claude Code 1.x installed and authenticated
  • A Claude subscription: Pro ($20/mo), Team ($30/mo), or Max ($100–200/mo)
  • Basic JSON knowledge — hooks are configured in settings.json
  • A shell (bash, zsh, or PowerShell) for command-based hooks

Confirm Claude Code is installed by running:

claude --version

If you don’t have a ~/.claude/settings.json file yet, create one with an empty JSON object {}. Claude Code reads this file on every session start.

Step 1: Understand the Hook Event Model

Hooks fire at specific points in the Claude Code lifecycle. Nine events are available:

Event When It Fires Typical Use
SessionStart Session begins Load project context, set env vars
UserPromptSubmit User sends a prompt Add context, validate input
PreToolUse Before a tool executes Block dangerous ops, modify input
PostToolUse After a tool completes Log results, run linters
Stop Agent considers stopping Verify tests passed, build succeeded
SubagentStop Subagent considers stopping Ensure subagent task completion
PreCompact Before context compaction Preserve critical info
Notification Notification is sent External integrations, logging
SessionEnd Session ends Cleanup, state preservation

PreToolUse and PostToolUse are the most commonly used. They accept a matcher that filters which tools trigger the hook. The others fire globally.

Tip

Start with a single PreToolUse hook on Write. You’ll learn the mechanics without risking anything — file writes are easy to reason about and test.

Step 2: Configure Your First Hook in settings.json

Open ~/.claude/settings.json and add a hook entry. The top-level keys are event names. Each event holds an array of matcher + hooks pairs.

Here’s a minimal PreToolUse hook that blocks writes to .env files:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r .tool_input.file_path); if echo \"$FILE\" | grep -qE \"\\.env$\"; then echo \"{\\\"decision\\\": \\\"block\\\", \\\"reason\\\": \\\".env writes are blocked by hook\\\"}\" >&2; exit 2; fi; echo \"{\\\"continue\\\": true}\"'"
          }
        ]
      }
    ]
  }
}

Key fields:

  • matcher — tool name filter. Supports exact names (Write), pipes for multiple (Write|Edit), wildcards (*), and regex (mcp__.*). Case-sensitive.
  • type — either "command" (runs a shell script) or "prompt" (uses LLM reasoning).
  • command — the shell command to execute. Receives hook context as JSON on stdin.

Step 3: Understand Hook Input and Output

Every command hook receives a JSON payload on stdin:

{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.txt",
  "cwd": "/your/project",
  "hook_event_name": "PreToolUse",
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/your/project/src/app.ts",
    "content": "..."
  }
}

PostToolUse hooks also include tool_result with the tool’s output.

Your hook must return JSON on stdout:

{
  "continue": true,
  "suppressOutput": false,
  "systemMessage": "Optional message sent to Claude"
}

Exit codes matter:

0
Success — stdout shown in transcript
2
Block — stderr fed back to Claude

Exit code 2 is special: it signals a blocking error. Claude reads the stderr content and adjusts its behavior. Any other non-zero exit code is a non-blocking error.

Step 4: Write a Bash Validation Hook

For a real-world example, create a script that validates bash commands before execution. Save this as ~/.claude/hooks/validate-bash.sh:

#!/bin/bash
set -euo pipefail

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

if [ -z "$COMMAND" ]; then
  echo '{"continue": true}'
  exit 0
fi

# Block destructive commands
if echo "$COMMAND" | grep -qE '(rm\s+-rf\s+/|sudo\s+rm|mkfs|dd\s+if=)'; then
  echo '{"decision": "block", "reason": "Destructive command blocked by hook"}' >&2
  exit 2
fi

# Block privilege escalation
if echo "$COMMAND" | grep -qE '^(sudo|su\s)'; then
  echo '{"decision": "block", "reason": "Privilege escalation blocked"}' >&2
  exit 2
fi

echo '{"continue": true}'

Make it executable and register it:

chmod +x ~/.claude/hooks/validate-bash.sh
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/hooks/validate-bash.sh"
          }
        ]
      }
    ]
  }
}
Warning

Grep-based command validation is not foolproof. Obfuscated commands can bypass simple pattern matching. Treat hooks as a safety net, not a security boundary.

Step 5: Use Prompt-Based Hooks for Complex Logic

Command hooks are fast and deterministic. But some validation requires reasoning — that’s where prompt-based hooks come in. Instead of a shell script, you give Claude a prompt to evaluate.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "prompt",
            "prompt": "File path: $TOOL_INPUT.file_path\nContent preview: $TOOL_INPUT.content\n\nCheck:\n1. Is this a sensitive file? (.env, credentials, secrets, private keys)\n2. Does the path contain directory traversal (..)?\n3. Is this writing to a system directory (/etc, /sys, /usr)?\n\nIf any check fails, set decision to 'block' with reason. Otherwise approve."
          }
        ]
      }
    ]
  }
}

Prompt hooks support variable substitution: $TOOL_INPUT, $TOOL_RESULT, $USER_PROMPT. Claude evaluates the prompt and returns a structured decision.

Prompt hooks are slower than command hooks — they require an LLM inference call. Use them when regex isn’t enough.

Step 6: Add a Stop Hook for Test Enforcement

Stop hooks fire when Claude is about to declare its work done. Use them to enforce that tests actually ran before the agent stops.

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Review the transcript. The task involved code changes. Verify:\n1. Were relevant tests executed?\n2. Did all tests pass?\n3. Was the build verified?\n\nIf any verification failed, set decision to 'block' and explain what's missing. Otherwise approve."
          }
        ]
      }
    ]
  }
}

Stop hook output uses a different schema:

{
  "decision": "approve",
  "reason": "All tests passed"
}

Set "decision": "block" to force Claude to continue working. It will read your reason and attempt to fix whatever’s missing.

Step 7: Load Context at Session Start

SessionStart hooks run once when Claude Code launches. They’re ideal for detecting project type and loading relevant context.

#!/bin/bash
# ~/.claude/hooks/load-context.sh
set -euo pipefail

CWD=$(cat | jq -r '.cwd')
MESSAGE=""

if [ -f "$CWD/package.json" ]; then
  MESSAGE="Node.js project detected."
  if [ -f "$CWD/tsconfig.json" ]; then
    MESSAGE="$MESSAGE TypeScript enabled."
  fi
elif [ -f "$CWD/pyproject.toml" ] || [ -f "$CWD/setup.py" ]; then
  MESSAGE="Python project detected."
elif [ -f "$CWD/go.mod" ]; then
  MESSAGE="Go project detected."
fi

if [ -n "$MESSAGE" ]; then
  echo "{\"systemMessage\": \"$MESSAGE\"}"
else
  echo '{"continue": true}'
fi

Register it:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/hooks/load-context.sh"
          }
        ]
      }
    ]
  }
}

The systemMessage gets injected into Claude’s context, so it’s aware of the project type from the first interaction.

Step 8: Combine Multiple Hooks

You can stack multiple hooks on the same event. They execute in order. If any hook returns a block decision, processing stops.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/hooks/validate-bash.sh"
          }
        ]
      },
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/hooks/validate-write.sh"
          },
          {
            "type": "prompt",
            "prompt": "Verify $TOOL_INPUT.file_path is not a lock file (package-lock.json, yarn.lock, Cargo.lock, go.sum). Block if it is."
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r .tool_input.file_path); npx prettier --write \"$FILE\" 2>/dev/null; echo \"{\\\"continue\\\": true}\"'"
          }
        ]
      }
    ]
  }
}

This configuration validates bash commands, checks file writes for safety and lock files, then auto-formats written files with Prettier.

Tips and Best Practices

  • Debug with --debug: Launch Claude Code with claude --debug to see hook execution logs, including stdin/stdout for each hook.
  • Keep command hooks fast. They run synchronously — a slow hook delays every tool call. Aim for under 100ms per hook.
  • Use jq for JSON parsing in bash hooks. Avoid string manipulation for JSON — it breaks on edge cases.
  • Prefer prompt hooks for nuanced checks (security review, code quality) and command hooks for deterministic validation (path checking, pattern matching).
  • Use $CLAUDE_PLUGIN_ROOT in plugin hooks for portable paths instead of hardcoding absolute directories.
  • Test hooks in isolation before adding them to settings.json. Pipe sample JSON into your script: echo '{"tool_input":{"file_path":"/tmp/test.txt"}}' | bash validate-write.sh
Tip

PostToolUse hooks on Write|Edit are a natural place for auto-formatting. Wire up Prettier, Black, gofmt, or rustfmt as a PostToolUse command hook to keep files formatted without Claude needing to think about it.

FAQ

What is the difference between command hooks and prompt hooks in Claude Code?
Command hooks execute a shell command and return structured JSON. They’re fast and deterministic — good for pattern matching and file checks. Prompt hooks send a text prompt to the LLM for evaluation. They’re slower (require an inference call) but handle nuanced, context-dependent decisions that regex can’t cover. Prompt hooks support variable substitution like $TOOL_INPUT and $TOOL_RESULT.
Where do I configure Claude Code hooks?
In ~/.claude/settings.json for user-level hooks that apply to all projects. For project-specific hooks, use .claude/settings.json in your project root. The format is the same — event names as top-level keys under a "hooks" object, each containing an array of matcher + hooks pairs.
Can a hook modify tool input before execution?
Yes. PreToolUse hooks can return an updatedInput field inside hookSpecificOutput. For example: {"hookSpecificOutput": {"updatedInput": {"file_path": "/new/path.ts"}}}. This modifies the tool’s input before it runs. PostToolUse hooks cannot modify input since the tool already executed.
What happens if a hook script fails or times out?
Exit code 2 is a blocking error — stderr is sent back to Claude as feedback. Any other non-zero exit code is treated as a non-blocking error and logged. If a hook throws an unhandled exception or times out, Claude Code logs the failure and continues execution. Hooks should fail safely and not halt the entire session on unexpected errors.
How do I test hooks without running a full Claude Code session?
Pipe sample JSON into your hook script directly: echo '{"tool_input":{"command":"rm -rf /"}}' | bash validate-bash.sh. Check the exit code with echo $? and inspect stdout/stderr. For prompt hooks, there’s no offline test — you need a live session. Use claude --debug to trace prompt hook evaluation.
Do hooks work with MCP tools?
Yes. MCP tool names follow the pattern mcp__server__tool. Use regex matchers to target them: "matcher": "mcp__.*" for all MCP tools, or "matcher": "mcp__myserver__.*" for a specific server. This works for both PreToolUse and PostToolUse events.
Can I use hooks on Windows?
Yes. Claude Code runs on Windows and hooks work there. For command hooks, use PowerShell or batch scripts instead of bash. Alternatively, write hooks in Python or Node.js for cross-platform compatibility. Reference your scripts with the appropriate interpreter: "command": "python ~/.claude/hooks/validate.py".
What is the difference between command hooks and prompt hooks in Claude Code?
Command hooks execute a shell command and return structured JSON. They’re fast and deterministic — good for pattern matching and file checks. Prompt hooks send a text prompt to the LLM for evaluation. They’re slower (require an inference call) but handle nuanced, context-dependent decisions that regex can’t cover. Prompt hooks support variable substitution like $TOOL_INPUT and $TOOL_RESULT.
Where do I configure Claude Code hooks?
In ~/.claude/settings.json for user-level hooks that apply to all projects. For project-specific hooks, use .claude/settings.json in your project root. The format is the same — event names as top-level keys under a “hooks” object, each containing an array of matcher + hooks pairs.
Can a hook modify tool input before execution?
Yes. PreToolUse hooks can return an updatedInput field inside hookSpecificOutput. For example: {“hookSpecificOutput”: {“updatedInput”: {“file_path”: “/new/path.ts”}}}. This modifies the tool’s input before it runs. PostToolUse hooks cannot modify input since the tool already executed.
What happens if a hook script fails or times out?
Exit code 2 is a blocking error — stderr is sent back to Claude as feedback. Any other non-zero exit code is treated as a non-blocking error and logged. If a hook throws an unhandled exception or times out, Claude Code logs the failure and continues execution. Hooks should fail safely and not halt the entire session on unexpected errors.
How do I test hooks without running a full Claude Code session?
Pipe sample JSON into your hook script directly: echo ‘{“tool_input”:{“command”:”rm -rf /”}}’ | bash validate-bash.sh. Check the exit code with echo $? and inspect stdout/stderr. For prompt hooks, there’s no offline test — you need a live session. Use claude –debug to trace prompt hook evaluation.
Do hooks work with MCP tools?
Yes. MCP tool names follow the pattern mcp__server__tool. Use regex matchers to target them: “matcher”: “mcp__.*” for all MCP tools, or “matcher”: “mcp__myserver__.*” for a specific server. This works for both PreToolUse and PostToolUse events.
Can I use hooks on Windows?
Yes. Claude Code runs on Windows and hooks work there. For command hooks, use PowerShell or batch scripts instead of bash. Alternatively, write hooks in Python or Node.js for cross-platform compatibility. Reference your scripts with the appropriate interpreter: “command”: “python ~/.claude/hooks/validate.py”.