Set Up Your First Claude Code Hook in Three Steps

To configure a Claude Code hook — the event-driven automation layer that runs on every tool call, edit, or session event — you need three things: a shell script, a registration in .claude/settings.json, and executable permissions. Walk through it once and every subsequent hook is a copy-paste variant.

Step 1: Write the hook script. This example is a PreToolUse hook that blocks rm -rf before it executes. Save it as .claude/hooks/block-rm.sh.

#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
  jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"rm -rf blocked"}}'
fi

Step 2: Register the hook in .claude/settings.json. Hook registration is declarative — the matcher regex filters which tools the hook fires on, and the command field points at the script.

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

Step 3: Make the script executable. This is the step most first-time setups miss — a missing execute bit makes the hook fail silently with no error in the Claude Code output.

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

Run Claude Code and attempt a destructive command. The hook intercepts and denies. That is the full cycle: event fires, script runs, decision returned. Every other hook in this guide — auto-lint, audit trail, path restriction, HTTP webhook — is a variation on the same three steps.

Jump to Troubleshooting hooks that fail silently if your first hook does not fire.

Troubleshooting Hooks That Fail Silently

Four failure modes cover almost every first-hook problem.

No output from the hook at all. Check execute permissions with ls -la .claude/hooks/. If the file is not marked executable, chmod +x it and restart the Claude Code session.

Hook runs but the decision is ignored. Claude Code parses stdout as JSON. Any stray echo or printf that is not valid JSON breaks the parser. Send diagnostic output to stderr with >&2 and reserve stdout for the JSON response.

jq: command not found. Most hook scripts depend on jq. Install it with brew install jq (macOS) or sudo apt install jq (Debian/Ubuntu). Confirm with jq --version.

Matcher regex does not fire. The matcher field uses regex, not globs. Use Bash or Edit|Write, not Bash*. Test the regex manually with echo "Bash" | grep -E "Bash" to confirm it matches what you expect.

Why Claude Code Hooks Matter

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 level of extensibility is one of the key differentiators when evaluating Claude Code vs Cursor for workflow automation.

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.

Here is a reference summary of all eight events and their key properties. "Blocking" means the hook can return a permissionDecision of deny to halt execution.

Event Fires when Blocking? Common use
SessionStart Session begins or resumes No Initialise logging, check prerequisites
UserPromptSubmit User submits a prompt Yes Validate input, enforce prompt conventions
PreToolUse Before a tool call executes Yes Block dangerous commands, enforce path restrictions
PostToolUse After a tool call succeeds No Auto-lint, auto-test, log to audit endpoint
PostToolUseFailure After a tool call fails No Error logging, alerts, cleanup
Stop Claude finishes its response No Session summaries, notifications
ConfigChange A config file changes mid-session No Alert on unexpected settings modification
SessionEnd Session terminates No Final logging, resource deallocation

Data source: Claude Code hooks reference, as of 2026-04.

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 (the official package on npm) or through your organisation's managed installation. Release history and known issues live in the anthropics/claude-code GitHub repository.

jq for JSON processing. Most hook scripts use jq to parse the JSON input that Claude Code passes to hook handlers. The jq source and releases are on GitHub. 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.

The table below catalogues the tool names Claude Code emits at the tool_name field, the matcher regex you would use, and the hook patterns that fire most often against each.

Tool name Typical matcher Fires on Common hook pattern
Bash "Bash" Any shell command execution Destructive-command guardrail, branch-guard, secret scanner on command string
Edit "Edit" or "Edit|Write" In-place edits to an existing file Auto-format, path allowlist, diff logging
Write "Write" or "Edit|Write" Creating a new file Path allowlist, template check, license header injection
Read "Read" Reading a file into context Access logging, secret redaction on Read of env/config files
Glob / Grep "Glob|Grep" Filesystem search Audit trail; rarely blocking
(any) "" or omitted All tool calls for the event Central HTTP audit logger, telemetry fan-out

Data source: Claude Code hooks reference tool-name conventions and matcher semantics, as of 2026-04.

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.

Before writing one, know which hook type fits which job. The three supported handler types (command, http, and prompt-style handlers registered via the same configuration) each have different latency, privilege, and durability profiles.

Type Transport Latency Reads payload Can return decision Best for
command Local shell script (stdin JSON, stdout JSON) <100ms typical Yes Yes Guardrails, local linting, path validation, debug logging
http POST to an HTTPS endpoint Network-bound; governed by timeout Yes Yes (if endpoint responds in JSON within timeout) Central audit, team-wide policy, SIEM ingest
Prompt handler Command hook wired to UserPromptSubmit <100ms typical Yes (user_prompt field) Yes Prompt conventions, ticket references, PII redaction

Data source: Claude Code hooks reference and Claude Code hooks guide, as of 2026-04.

{
  "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.

Here is a complete reference for the hook configuration fields, covering both command and http types.

Field Applies to Required Description
type Both Yes "command" for shell scripts, "http" for remote endpoints
command command Yes Path to the executable script (absolute or relative to project root)
url http Yes Full HTTPS URL receiving the POST request
timeout http No Response timeout in milliseconds. Default varies; recommended 3000-5000 for logging, under 2000 for PreToolUse decisions
headers http No HTTP headers as key/value pairs. Supports $VAR interpolation for variables listed in allowedEnvVars
allowedEnvVars http No Allowlist of environment variable names that may be interpolated into header values. Variables not listed resolve to empty
matcher Both No Regex matched against the tool name. "Bash" matches Bash only. "Edit|Write" matches file edits. Empty string or omitted matches all tools

Data source: Claude Code hooks reference, as of 2026-04.

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.

Hook Pattern Catalogue

The hooks already covered slot into a small number of reusable patterns. Use this table as a shortcut when you are scoping a new hook: pick the pattern, pick the event, copy the matcher, adapt the logic.

Pattern Event Matcher Action Decision emitted
Destructive-command guardrail PreToolUse "Bash" Grep command string for rm -rf, mkfs, dd of=, force-push flags deny on match
Path allowlist PreToolUse "Edit|Write" Check tool_input.file_path against allowed directories deny on miss
Auto-formatter PostToolUse "Edit|Write" Run eslint --fix, cargo fmt, ruff format by extension none (non-blocking)
Secret scanner PostToolUse "Edit|Write" Grep new file content for keys/tokens; log and alert none; fail loud in stderr
Telemetry / audit fan-out PostToolUse "" (all) POST full payload to SIEM or audit endpoint none
Prompt convention UserPromptSubmit n/a Require ticket ID, forbid PII, log prompts deny if missing
Session timer Stop n/a Compute duration, notify on overrun none

Data source: pattern taxonomy derived from the Claude Code hooks reference event and matcher schema, and the Claude Code hooks guide example inventory, as of 2026-04.

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

Multi-Hook Orchestration Patterns

Real projects rarely need just one hook per event. Claude Code supports multiple hooks on the same event, and they execute in the order they are listed in the configuration. This opens up orchestration patterns that are more powerful than any single hook.

Chain of responsibility. Register multiple PreToolUse hooks on the same matcher, each checking a different condition. The first hook checks file path restrictions. The second checks time-of-day policies. The third checks branch protection rules. If any hook returns a deny decision, the tool call is blocked. If all pass, the tool call proceeds.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/validate-paths.sh" },
          { "type": "command", "command": ".claude/hooks/check-business-hours.sh" },
          { "type": "command", "command": ".claude/hooks/branch-guard.sh" }
        ]
      }
    ]
  }
}

Each hook is a self-contained script that does one thing well. Adding or removing a check is a single line change in the configuration. No monolithic script to maintain.

Parallel logging and linting. PostToolUse hooks can combine a local linting step with a remote logging step. The linter runs as a command hook and fixes the file locally. The logger runs as an HTTP hook and sends the event to your audit endpoint. Both fire on the same event, both complete independently, and neither blocks the other.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/auto-lint.sh" },
          {
            "type": "http",
            "url": "https://audit.yourcompany.com/claude-code/edits",
            "timeout": 3000,
            "headers": { "Authorization": "Bearer $AUDIT_TOKEN" },
            "allowedEnvVars": ["AUDIT_TOKEN"]
          }
        ]
      }
    ]
  }
}

Event fan-out. Use multiple matchers on the same event to apply different hooks to different tools. Bash commands get security checks. File edits get linting. Everything gets logged. This keeps each hook focused and avoids complex conditional logic inside scripts.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": ".claude/hooks/block-rm.sh" }]
      },
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": ".claude/hooks/validate-paths.sh" }]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "",
        "hooks": [{
          "type": "http",
          "url": "https://audit.yourcompany.com/claude-code/events",
          "timeout": 3000
        }]
      }
    ]
  }
}

The key principle: each hook should do one thing. Orchestration happens through composition in the configuration, not through complexity inside scripts.

Ordering matters for PreToolUse chains. When multiple PreToolUse hooks are registered on the same matcher, Claude Code evaluates them in array order and stops at the first deny. Put your fastest and most commonly triggered checks first. A file path validation that runs in 5ms should come before a remote policy check that takes 200ms. If the path check denies the operation, the slower check never runs, keeping the session responsive. For PostToolUse chains the order is less critical because every hook runs regardless, but placing the local linter before the remote logger keeps the formatting correction visible to Claude before it plans its next action.

Hook Performance and Timeout Tuning

Hooks run synchronously in the Claude Code lifecycle. A slow hook blocks the entire session until it completes or times out. Understanding the performance characteristics of your hooks is essential for keeping Claude Code responsive.

Command hook timeouts. Command hooks have a default timeout. If your script takes longer than this, Claude Code kills the process and proceeds without the hook's output. For PreToolUse hooks, this means the tool call proceeds as if the hook did not exist. For PostToolUse hooks, it means the logging or linting step is silently skipped.

Keep command hooks fast. A good target is under 500 milliseconds. If your hook needs to call an external service, consider whether it belongs as an HTTP hook instead, since HTTP hooks have configurable timeouts and are designed for network operations. Slow hooks also add to your effective token cost by extending sessions; for strategies on keeping overall Claude Code spend predictable, see our guide on cost optimisation.

HTTP hook timeouts. Set the timeout field explicitly in every HTTP hook configuration. The value is in milliseconds. For audit logging where you do not need the response, 3000-5000ms is reasonable. For PreToolUse hooks that make remote policy decisions, keep it under 2000ms or developers will notice the delay on every tool call.

{
  "type": "http",
  "url": "https://audit.yourcompany.com/events",
  "timeout": 3000
}

Offload slow work. If a hook needs to trigger a slow operation (running a full test suite, generating a report, sending a Slack notification), launch it as a background process. The hook script returns immediately, and the background process completes on its own.

#!/bin/bash
# .claude/hooks/async-notify.sh
# Fire and forget: send notification in background
(curl -s -X POST "https://slack.webhook.url" \
  -d "{\"text\": \"Claude Code session active\"}" &) 2>/dev/null

exit 0

The parentheses and & launch the curl command in a background subshell. The hook script exits immediately with code 0, so Claude Code continues without waiting.

Measure your hooks. If a session feels sluggish after adding hooks, measure each one individually. Wrap the hook invocation in a timing script:

#!/bin/bash
# .claude/hooks/timed-wrapper.sh
START=$(date +%s%N)
.claude/hooks/actual-hook.sh
EXIT_CODE=$?
END=$(date +%s%N)
DURATION_MS=$(( (END - START) / 1000000 ))
echo "Hook took ${DURATION_MS}ms" >&2
exit $EXIT_CODE

If a hook consistently exceeds 200ms, either optimise it or move the work to a background process. Interactive development depends on sub-second feedback loops, and hooks that break that contract degrade the experience for everyone.

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. For a broader treatment of trust-boundary issues for AI agents, Anthropic's documentation on Claude Code security describes the session-boundary, permission, and settings model that hooks extend.

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.

Validate your shell scripts with ShellCheck. Before deploying a hook, run it through ShellCheck to catch quoting errors, undefined variables, and POSIX compatibility issues. Most issues that cause hooks to fail silently show up immediately in ShellCheck output. Install it with brew install shellcheck or sudo apt install shellcheck.

Testing hooks in isolation before registering them. The fastest way to iterate on a hook script is to test it outside Claude Code entirely. Create a sample JSON file that mirrors the payload for the event you are targeting, then pipe it into your script.

echo '{"tool_name":"Edit","tool_input":{"file_path":"src/main.rs"}}' | bash .claude/hooks/validate-paths.sh

Inspect both stdout (which Claude Code parses as the hook response) and the exit code (echo $?). If stdout contains anything other than valid JSON or is empty, Claude Code will ignore the hook silently. Run this check every time you modify a hook before testing it in a live session. It catches the majority of issues, including jq syntax errors, unexpected echo statements leaking into stdout, and incorrect field names in the JSON response, without requiring a full Claude Code restart for each iteration.

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. Anthropic's Claude Cookbook repository collects worked examples across Claude tooling, and community-reported issues and patterns surface in the anthropics/claude-code issue tracker. If you need central management of hooks across an organisation, read our enterprise managed settings guide for the allowManagedHooksOnly configuration.