Prelude

Most developers spend their first months with Claude Code in a reactive mode. Approving tool calls manually, reviewing outputs after the fact, and occasionally catching something that should not have happened. It works. It is also entirely reactive.

The shift comes when you realise that Claude Code has a full lifecycle event system built into it. Every tool call, every session start, every file edit fires an event. And you can hook into any of them with a shell command, an HTTP endpoint, or an LLM prompt that runs automatically.

That changes everything about how you work with Claude Code. Instead of reviewing after the fact, you intercept before execution. Instead of manually running linters, you trigger them automatically when Claude edits a file. Instead of trusting that the right commands were used, you log every tool call to a central endpoint.

This is not a tutorial about one hook. This is a guide to thinking about Claude Code as an event-driven system and building workflows on top of it.

The Problem

Claude Code makes hundreds of decisions during a typical session. Which files to read, which commands to run, which edits to make, which tools to call. The standard interaction model gives you a permission prompt for some of these and automatic approval for others.

That permission model works for safety. It does not work for automation. You cannot automate linting by clicking "approve" on every edit. You cannot build an audit trail by manually copying tool outputs. You cannot enforce coding standards by hoping Claude follows the instructions in your CLAUDE.md.

What you need is a way to react to Claude Code's actions programmatically. Run a linter every time a file changes. Block certain patterns before they execute. Log every command to a database. Send a notification when a session exceeds a time threshold.

The hooks system is that programmatic layer.

The Journey

Understanding the Lifecycle

Every Claude Code session follows a lifecycle. The session starts, the user submits a prompt, Claude decides which tools to use, the tools execute, and eventually the session ends. Hooks fire at specific points in this lifecycle.

The hooks system defines multiple lifecycle events, each firing at a different point in the session. The most frequently used ones are these.

SessionStart fires when a session begins or resumes. Use it to initialise logging, check prerequisites, or set up the working environment.

UserPromptSubmit fires when you submit a prompt, before Claude processes it. Use it to validate, transform, or log user inputs.

PreToolUse fires before any tool call executes. This is the most powerful event because it can approve, deny, or modify the tool call. If your hook returns a permissionDecision of "deny", the tool call is blocked. If it returns "allow", the tool call proceeds without prompting the user.

PostToolUse fires after a tool call succeeds. Use it for linting, testing, logging, or any reaction to a completed action.

PostToolUseFailure fires after a tool call fails. Use it to log errors, trigger alerts, or run cleanup.

Stop fires when Claude finishes its response. Use it for session-level summaries, notifications, or final checks.

ConfigChange fires when a configuration file changes during a session. This is critical for security because it catches attempts to modify settings mid-session.

SessionEnd fires when a session terminates. Use it for cleanup, final logging, or resource deallocation.

Each event passes JSON context to your hook handler. For PreToolUse, that includes the tool name and the full tool input. For PostToolUse, it includes the tool output as well. The hooks reference documents the complete schema for each event.

Prerequisites

Before writing your first hook, make sure you have the following in place.

Claude Code 1.0.20 or later. Hooks were introduced in Claude Code 1.0.20. Run claude --version to check. If you are on an older version, update with npm update -g @anthropic-ai/claude-code or through your organisation's managed installation.

jq for JSON processing. Most hook scripts use jq to parse the JSON input that Claude Code passes to hook handlers. Install it with brew install jq on macOS or sudo apt install jq on Debian and Ubuntu. You can verify it is installed by running jq --version.

Executable permissions on hook scripts. Shell scripts must be marked executable or they will fail silently. After creating any hook script, run chmod +x on it.

chmod +x .claude/hooks/block-rm.sh
chmod +x .claude/hooks/auto-lint.sh

A .claude/hooks/ directory in your project. This is not strictly required, as you can place hook scripts anywhere, but keeping them in .claude/hooks/ is the convention. Create it with mkdir -p .claude/hooks.

A settings file. Hook registrations go in .claude/settings.json for project-level hooks or ~/.claude/settings.json for global hooks. If the file does not exist yet, create it with an empty JSON object {}.

With these in place, you are ready to write hooks.

Your First Hook. Block Destructive Commands

The simplest useful hook is a PreToolUse handler that blocks dangerous Bash commands. This hook checks every Bash command before it executes and denies anything containing rm -rf.

Create a file at .claude/hooks/block-rm.sh in your project.

#!/bin/bash
# .claude/hooks/block-rm.sh
COMMAND=$(jq -r '.tool_input.command')

if echo "$COMMAND" | grep -q 'rm -rf'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "Destructive rm -rf command blocked by hook"
    }
  }'
else
  exit 0
fi

Register it in your settings file. This can live in .claude/settings.json for the project or in ~/.claude/settings.json for all your projects.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/block-rm.sh"
          }
        ]
      }
    ]
  }
}

The matcher field is a regex that filters which tool triggers the hook. "Bash" matches Bash commands only. "Edit|Write" would match file modifications. An empty string or "*" matches everything. You can omit the matcher entirely to match all occurrences.

When Claude Code decides to run rm -rf /tmp/build, the PreToolUse event fires, the matcher checks for Bash, the hook script runs, finds rm -rf in the command, and returns a deny decision. Claude sees the denial reason and adjusts its approach. The command never executes.

Auto-Linting on Every File Change

The most commonly used hook is a PostToolUse handler that lints files whenever Claude edits them.

#!/bin/bash
# .claude/hooks/auto-lint.sh
TOOL_NAME=$(jq -r '.tool_name')
FILE_PATH=""

if [ "$TOOL_NAME" = "Edit" ] || [ "$TOOL_NAME" = "Write" ]; then
  FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.path // empty')
fi

if [ -z "$FILE_PATH" ]; then
  exit 0
fi

EXTENSION="${FILE_PATH##*.}"

case "$EXTENSION" in
  js|ts|jsx|tsx)
    npx eslint --fix "$FILE_PATH" 2>/dev/null
    ;;
  rs)
    cargo fmt -- "$FILE_PATH" 2>/dev/null
    ;;
  py)
    ruff format "$FILE_PATH" 2>/dev/null
    ;;
esac

exit 0

Register it with a matcher for Edit and Write operations.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/auto-lint.sh"
          }
        ]
      }
    ]
  }
}

Every time Claude edits or creates a file, the linter runs immediately. No manual step. No separate terminal. The file is formatted before Claude even moves on to its next action.

HTTP Hooks for Central Logging

Command hooks run locally. HTTP hooks send the event data to a remote endpoint. This is where hooks become an enterprise tool.

An HTTP hook sends the event's JSON input as a POST request to your URL. The endpoint can log the data, run analysis, or return a decision back to Claude Code using the same JSON format as command hooks.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "http",
            "url": "https://audit.yourcompany.com/claude-code/events",
            "timeout": 5000,
            "headers": {
              "Authorization": "Bearer $AUDIT_TOKEN",
              "X-Developer": "$USER"
            },
            "allowedEnvVars": ["AUDIT_TOKEN", "USER"]
          }
        ]
      }
    ]
  }
}

The headers field supports environment variable interpolation, but only for variables listed in allowedEnvVars. This is a deliberate security measure. Even if the hook config references $AWS_SECRET_KEY, it resolves to empty unless explicitly approved. This prevents accidental credential leakage through hook configurations.

The empty matcher means this hook fires on every PostToolUse event, regardless of which tool was called. Every file read, every Bash command, every edit gets logged to your central endpoint.

Your endpoint receives the full event payload. For a Bash command, that includes the command string, the output, the exit code, and the session context. For an Edit, it includes the file path, the old content, the new content, and the diff. You have everything you need to build a complete audit trail.

Prompt Hooks. Intercepting User Input

The UserPromptSubmit event fires every time you submit a prompt to Claude Code, before Claude begins processing it. This gives you a chance to validate, transform, or log every instruction that enters the system.

A practical use case is enforcing prompt conventions across a team. If your organisation requires that certain projects always include a ticket reference in prompts, a UserPromptSubmit hook can check for that.

#!/bin/bash
# .claude/hooks/require-ticket.sh
PROMPT=$(jq -r '.user_prompt // empty')

if [ -z "$PROMPT" ]; then
  exit 0
fi

# Check if the prompt contains a ticket reference like PROJ-123
if ! echo "$PROMPT" | grep -qE '[A-Z]+-[0-9]+'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "UserPromptSubmit",
      permissionDecision: "deny",
      permissionDecisionReason: "Please include a ticket reference (e.g. PROJ-123) in your prompt for audit tracking."
    }
  }'
fi

Register it for the UserPromptSubmit event.

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/require-ticket.sh"
          }
        ]
      }
    ]
  }
}

Another useful pattern is logging every prompt to a local file for later review. This is particularly helpful during pair programming sessions or when onboarding new team members, as it creates a record of how the team interacts with Claude Code.

#!/bin/bash
# .claude/hooks/log-prompts.sh
PROMPT=$(jq -r '.user_prompt // empty')
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')

if [ -n "$PROMPT" ]; then
  echo "[$TIMESTAMP] $PROMPT" >> .claude/prompt-log.txt
fi

exit 0

Prompt hooks are especially powerful in enterprise managed settings where administrators can enforce organisation-wide prompt policies without relying on individual developers to follow conventions manually.

Three Hook Recipes Worth Using Every Day

Recipe 1. Auto-run tests after Bash commands that modify source files.

This PostToolUse hook watches for Bash commands that contain git commit and triggers the test suite. If tests fail, the output is captured and fed back to Claude on the next interaction.

#!/bin/bash
# .claude/hooks/post-commit-test.sh
COMMAND=$(jq -r '.tool_input.command // empty')

if echo "$COMMAND" | grep -q 'git commit'; then
  npm run test --silent 2>&1 | tail -20
fi

exit 0

Recipe 2. Validate file paths before edits.

This PreToolUse hook prevents Claude from editing files outside the project's source directory. It checks the file path in Edit and Write tool calls and blocks anything outside src/, tests/, or docs/.

#!/bin/bash
# .claude/hooks/validate-paths.sh
FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.path // empty')

if [ -z "$FILE_PATH" ]; then
  exit 0
fi

ALLOWED_DIRS="src/ tests/ docs/ .claude/"

MATCHED=false
for DIR in $ALLOWED_DIRS; do
  if [[ "$FILE_PATH" == *"$DIR"* ]]; then
    MATCHED=true
    break
  fi
done

if [ "$MATCHED" = false ]; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "Edit restricted to src/, tests/, docs/, .claude/ directories"
    }
  }'
fi

Recipe 3. Session duration notifications.

This Stop hook checks how long the session has been running and sends a notification if it exceeds 30 minutes. Useful for keeping track of long-running agent sessions.

#!/bin/bash
# .claude/hooks/session-timer.sh
SESSION_START=$(jq -r '.session.start_time // empty')

if [ -z "$SESSION_START" ]; then
  exit 0
fi

NOW=$(date +%s)
DURATION=$(( NOW - SESSION_START ))

if [ "$DURATION" -gt 1800 ]; then
  MINUTES=$(( DURATION / 60 ))
  notify-send "Claude Code" "Session running for ${MINUTES} minutes" 2>/dev/null || true
fi

exit 0

Combining Hooks with the Permission System

Hooks and permissions are complementary. The permission system defines what Claude Code is allowed to do. Hooks define what happens when it does something.

A powerful pattern is using PreToolUse hooks to implement dynamic permissions. Instead of a static allowlist, your hook can make runtime decisions based on context. For example, a hook could allow git push to feature branches but deny it to main. Or allow file edits during business hours but block them overnight.

#!/bin/bash
# .claude/hooks/branch-guard.sh
COMMAND=$(jq -r '.tool_input.command // empty')

if echo "$COMMAND" | grep -q 'git push'; then
  BRANCH=$(git branch --show-current 2>/dev/null)
  if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
    jq -n '{
      hookSpecificOutput: {
        "hookEventName": "PreToolUse",
        "permissionDecision": "deny",
        "permissionDecisionReason": "Direct push to main/master blocked. Use a feature branch."
      }
    }'
    exit 0
  fi
fi

exit 0

This kind of context-aware policy is impossible with static permission rules alone. The hooks system gives you the programmability to implement whatever logic your team needs.

Hook Security Considerations

Hooks execute with the same privileges as the user running Claude Code. A malicious hook in a project's .claude/settings.json could exfiltrate data or modify files silently. There are several safeguards against this.

First, hooks snapshot at session start. Changes to hook configurations during a session do not take effect until the next session. Claude Code warns the developer if configuration files change and requires review. This prevents malicious pull requests from injecting hooks that take effect immediately.

Second, the ConfigChange event fires when any configuration file changes during a session. You can hook into this event to alert on unexpected configuration modifications.

Third, enterprise administrators can set allowManagedHooksOnly in managed settings to block all user, project, and plugin hooks. Only centrally managed hooks run. See our enterprise managed settings guide for the full lockdown configuration.

Fourth, HTTP hooks have allowedEnvVars and allowedHttpHookUrls restrictions that prevent hooks from accessing credentials or reaching endpoints that have not been explicitly approved.

Debugging Hook Failures

Hooks fail silently more often than you would expect. When a hook does not behave as intended, here is a systematic approach to finding the problem.

The hook script is not executable. This is the most common issue by far. If your hook script lacks execute permissions, Claude Code cannot run it. The fix is straightforward.

chmod +x .claude/hooks/your-hook.sh

You can verify permissions with ls -la .claude/hooks/ and look for the x flag in the output.

jq is not installed or not on PATH. If your hook uses jq and it is not available, the script fails at the first jq call. The rest of the script never runs, and no JSON output is produced. Claude Code treats this as a hook that returned nothing, so it proceeds as if the hook did not exist. Test that jq is available by running which jq in your terminal.

The hook returns invalid JSON. When a hook outputs malformed JSON, Claude Code cannot parse the response. This typically happens when your script mixes echo statements with the jq -n output, or when a command upstream in the script writes unexpected text to stdout. Keep your hooks clean: write diagnostic output to stderr with >&2, and reserve stdout exclusively for the JSON response.

#!/bin/bash
# Good practice: debug output goes to stderr
echo "Hook triggered for tool: $(jq -r '.tool_name')" >&2

# Only the JSON decision goes to stdout
jq -n '{
  hookSpecificOutput: {
    hookEventName: "PreToolUse",
    permissionDecision: "allow"
  }
}'

The hook times out. Command hooks have a default timeout. If your hook calls an external service, runs a slow test suite, or blocks on user input, it may exceed this limit. Claude Code terminates the hook process and proceeds without its output. For HTTP hooks, you can set an explicit timeout value in milliseconds in the configuration. For command hooks, keep execution fast and offload slow work to background processes.

The hook runs but the matcher does not match. The matcher field is a regular expression, not a glob. If you write "matcher": "*.sh", it will not match anything useful. For matching Bash commands, use "Bash". For matching file edits, use "Edit|Write". Test your regex separately before adding it to the hook config.

Debugging with stderr logging. The quickest way to debug any hook is to write diagnostic information to stderr. Claude Code does not consume stderr output from hooks, so it appears in the terminal where Claude Code is running.

#!/bin/bash
# .claude/hooks/debug-example.sh
echo "DEBUG: Hook fired at $(date)" >&2
echo "DEBUG: Input JSON:" >&2
cat | tee /tmp/hook-input.json | jq -r '.tool_name' >&2

# Your actual hook logic here
TOOL_NAME=$(jq -r '.tool_name' < /tmp/hook-input.json)
echo "DEBUG: Tool name is $TOOL_NAME" >&2

This writes a copy of the input JSON to /tmp/hook-input.json so you can inspect it after the fact, and prints the tool name to stderr so you can watch it in real time.

Common error messages and what they mean. If you see "hook returned non-zero exit code", your script encountered an error. Check the script manually by piping sample JSON into it: echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | .claude/hooks/your-hook.sh. If you see "permission denied", the script is not executable. If you see "command not found", the shebang line is wrong or the script path in settings.json is incorrect.

Integrating Hooks with CI/CD Pipelines

Hooks are not limited to interactive development sessions. When Claude Code runs in a CI/CD pipeline, hooks provide the same lifecycle events, which means you can enforce policies and collect audit data in automated workflows.

In a GitHub Actions context, you can include hook configurations in your repository's .claude/settings.json and they will apply whenever Claude Code runs as part of a workflow. This is particularly useful for enforcing coding standards, blocking prohibited patterns, and logging all actions taken during automated code generation or review.

A typical CI/CD hook setup includes a PreToolUse guard that prevents Claude Code from modifying files outside the expected scope, and a PostToolUse logger that records every action for compliance purposes.

#!/bin/bash
# .claude/hooks/ci-guard.sh
# Restrict file modifications to the PR's changed files only
TOOL_NAME=$(jq -r '.tool_name // empty')
FILE_PATH=$(jq -r '.tool_input.file_path // .tool_input.path // empty')

if [ -z "$FILE_PATH" ]; then
  exit 0
fi

if [ "$TOOL_NAME" = "Edit" ] || [ "$TOOL_NAME" = "Write" ]; then
  # Check if the file is in the list of changed files for this PR
  CHANGED_FILES=$(git diff --name-only origin/main...HEAD 2>/dev/null)
  if ! echo "$CHANGED_FILES" | grep -qF "$FILE_PATH"; then
    jq -n '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: "CI mode: only files changed in this PR may be modified"
      }
    }'
  fi
fi

For HTTP hooks in CI/CD, you can send event data to your logging infrastructure to build an audit trail of every action Claude Code takes during automated runs. This is particularly valuable for regulated industries where you need to demonstrate what an AI agent did and why.

For a complete walkthrough of running Claude Code in GitHub Actions, including hook configuration, see our Claude Code GitHub Actions guide.

The Lesson

Claude Code is not just a coding assistant. It is an event-driven system with a full lifecycle API. Every action it takes fires an event. Every event can trigger your code. That changes the relationship from reactive supervision to proactive automation.

The most valuable hooks are not complicated. Auto-lint on file change. Block destructive commands. Log everything to a central endpoint. Each one is a short script that takes minutes to write and saves hours of manual oversight.

The pattern that matters is thinking about Claude Code sessions as pipelines. Input goes in, events fire, hooks react, output comes out. Once you see it that way, the question stops being "what should I approve?" and starts being "what should I automate?"

Conclusion

This guide started with reactive supervision. Approving tool calls manually, reviewing outputs after the fact, catching problems when they already happened.

The hooks system inverts that entirely. PreToolUse hooks catch problems before they happen. PostToolUse hooks automate the response. HTTP hooks give you visibility across your entire team.

Start with one hook. The auto-lint PostToolUse handler is the easiest win. Once that is working, add a PreToolUse guard for your most common mistake. Then add the HTTP hook for logging. Each one takes ten minutes and compounds over every session. To extend this automation into your CI/CD pipeline, see our Claude Code GitHub Actions Recipes for workflow definitions that run Claude on every pull request.

The hooks reference documents every available event, the full JSON schemas, and the configuration options. The hooks guide has more examples. And if you need central management of hooks across an organisation, read our enterprise managed settings guide for the allowManagedHooksOnly configuration.