Skip to content

Claude Code Hooks: A Complete Guide to Event-Driven AI Control

Mar 27, 2026 1 min
TL;DR Hooks are Claude Code's event system. They trigger shell commands, HTTP requests, MCP tools, or LLM evaluations automatically before/after tool execution, when a prompt is submitted, or when a task ends. Use them to block dangerous operations, run automated reviews, inject context, or write audit logs.
Table of Contents
  1. Event Lifecycle
    1. Full Event Reference
  2. Configuration
    1. Config File Locations and Priority
    2. Matcher Syntax
  3. Handler Types
    1. 1. Command (Most Common)
    2. 2. HTTP
    3. 3. Prompt
    4. 4. Agent
  4. Real-World Use Cases
    1. Case 1: Run Lint + Typecheck Before Commit
    2. Case 2: Block Dangerous Commands
    3. Case 3: Auto-Approve Safe Commands
    4. Case 4: Stop Interception โ€” Don't Stop Until Tests Pass
    5. Case 5: Audit Log
    6. Case 6: Inject Environment Variables at Session Start
    7. Case 7: Send a Notification When Claude Finishes
  5. Hook Input and Output
    1. PreToolUse Input
    2. PreToolUse Output (Optional)
    3. UserPromptSubmit Output
  6. Hooks Inside Skills and Agents
  7. Division of Responsibility with Skills
  8. Design Principles
  9. What Hooks Look Like as of August 2026
    1. Five handler types now
    2. Full exit code semantics
    3. Async hooks: run in the background without blocking the loop
    4. HTTP hooks: how responses count
    5. Prompt hooks: let an LLM answer ok/false
    6. MCP tool hooks: outsource the check to a connected MCP server
  10. Summary
  11. References
  12. Update Log

๐ŸŒ ไธญๆ–‡็‰ˆ

Claude Code's Hook system is an event-driven architecture. At every critical point in the AI's operation lifecycle, events are emitted โ€” and you can attach automated actions to those points: block dangerous commands, inject additional context, log operations, or even auto-approve safe actions.

The concept is similar to git hooks or CI webhooks, but the target is an AI agent.

Event Lifecycle

Here's what the event flow looks like in a Claude Code session:

SessionStart
    โ†“
UserPromptSubmit (user sends a message)
    โ†“
โ”Œโ”€ Agentic Loop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  PreToolUse โ†’ execute tool โ†’ PostToolUse โ”‚
โ”‚  PreToolUse โ†’ execute tool โ†’ PostToolUse โ”‚
โ”‚  ... (repeats until task is complete)    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
    โ†“
Stop (Claude finishes responding)
    โ†“
SessionEnd

Any event can have a hook attached. The most commonly used are PreToolUse (before a tool runs) and Stop (when a task ends).

Full Event Reference

EventTriggerCan Block?
SessionStartSession starts or resumesNo
UserPromptSubmitUser submits a promptYes
PreToolUseBefore a tool executesYes
PostToolUseAfter a tool succeedsYes
PostToolUseFailureAfter a tool failsNo (already failed)
PermissionRequestPermission dialog about to appearJSON can auto-approve or deny (exit 2 has no effect)
StopClaude finishes respondingYes (force continuation)
StopFailureAPI error causes stopNo (observation only)
SubagentStart / SubagentStopSubagent starts/stopsYes
TaskCreated / TaskCompletedTask created/completedYes
NotificationNotification eventNo
FileChangedFile changedNo
CwdChangedWorking directory changedNo
ConfigChangeConfig file changedYes
PreCompact / PostCompactBefore/after context compactionNo
SessionEndSession endsNo (observation only)

The table above covers the core events at the time this post was written. As of August 2026, the official reference has grown to around thirty events: newer additions include Setup (one-time preparation for CI/scripts), UserPromptExpansion (when a slash command expands), PermissionDenied (after auto mode denies a tool call), PostToolBatch (after a batch of parallel tool calls), TeammateIdle, InstructionsLoaded, WorktreeCreate/WorktreeRemove, Elicitation/ElicitationResult (MCP elicitation), DirectoryAdded, and MessageDisplay. The official hooks reference is the authoritative list.

Configuration

Hooks are defined in settings.json using a three-level nested structure: event โ†’ matcher โ†’ handler.

// ~/.claude/settings.json (global)
// or .claude/settings.json (project)
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "./.claude/hooks/check-command.sh"
          }
        ]
      }
    ]
  }
}

Config File Locations and Priority

LocationScopeShareable
~/.claude/settings.jsonGlobal (all projects)No
.claude/settings.jsonSingle projectYes (commit to repo)
.claude/settings.local.jsonSingle project (personal)No (gitignored)
Managed policy settingsOrganization levelYes (admin-controlled)
Plugin hooks/hooks.jsonWhen plugin is enabledYes
Skill/Agent frontmatterSkills: the rest of the session after invocation; Subagents: while runningYes

For where these files sit in the .claude directory hierarchy, see A Complete Tour of the .claude Directory.

Matcher Syntax

The matcher determines when a hook fires, evaluated against a field carried by the event (the tool name for tool events). Evaluation rules: if it contains only letters, digits, _, -, whitespace, ,, and |, it's matched as an exact string (or a list separated by | or ,; comma support arrived in v2.1.191); any other character switches it to an unanchored JavaScript regex.

"matcher": "Bash"            // Only triggers on the Bash tool (exact match)
"matcher": "Edit|Write"      // Triggers on Edit or Write
"matcher": "^Edit$"          // Regex form; unanchored "Edit.*" would also hit NotebookEdit
"matcher": "mcp__github__.*" // All tools from the GitHub MCP server; the .* is required,
                             // otherwise the whole string is compared exactly and matches nothing
"matcher": ""                // Triggers in all cases

Note: the matcher only matches tool names โ€” you cannot write "Bash(git commit*)" here. That's permission-rule syntax, which belongs in the handler-level if field (see "What hooks look like as of August 2026" below).

What the matcher targets differs by event:

EventMatches AgainstExamples
PreToolUse / PostToolUseTool nameBash, Edit, mcp__memory__.*
SessionStartStart sourcestartup, resume, clear, compact, fork
StopFailureError typerate_limit, server_error
FileChangedFile name.envrc, package.json
NotificationNotification typepermission_prompt, idle_prompt

Handler Types

There are now five handler types: command, http, prompt, agent, plus the newer mcp_tool (covered in "What hooks look like as of August 2026" below). This section covers the original four.

1. Command (Most Common)

Runs a shell command. Receives JSON input via stdin and outputs a JSON result via stdout.

{
  "type": "command",
  "command": "./.claude/hooks/lint-check.sh",
  "timeout": 600
}

Exit code determines behavior:

Exit CodeMeaningBehavior
0Success, no decisionStdout starting with { is parsed as JSON; anything else is treated as plain text. This does not approve the call โ€” the tool goes through the normal permission flow
2BlockBlocks the action; a JSON allow cannot override it. The message prefers the JSON reason, otherwise stderr is fed back to Claude (some events only show it to the user)
OtherNon-blocking errorDoesn't block. If stdout is schema-valid JSON, the exit code is ignored and the JSON decides; otherwise it's a non-blocking error with stderr visible only in debug/verbose mode

For per-event details, see the exit code section in "What hooks look like as of August 2026" below.

2. HTTP

Sends an HTTP POST to a specified endpoint. Good for integrating with external services.

{
  "type": "http",
  "url": "http://localhost:8080/hooks/validate",
  "timeout": 30,
  "headers": {
    "Authorization": "Bearer $MY_TOKEN"
  },
  "allowedEnvVars": ["MY_TOKEN"]
}

Environment variables must be explicitly listed in allowedEnvVars before they are substituted โ€” a security requirement.

3. Prompt

Uses an LLM for evaluation. Best for scenarios requiring semantic understanding (e.g., determining whether a command is safe).

{
  "type": "prompt",
  "prompt": "Is this operation safe? $ARGUMENTS",
  "model": "claude-haiku-4-5",
  "timeout": 30
}

4. Agent

Uses a full agent with more tools and context. The most expensive option; suitable for complex judgments.

{
  "type": "agent",
  "prompt": "Validate this condition: $ARGUMENTS",
  "timeout": 60
}

Real-World Use Cases

Case 1: Run Lint + Typecheck Before Commit

The most fundamental use case: automatically check code quality before Claude runs git commit.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash(git commit*)",
        "hooks": [{
          "type": "command",
          "command": "cd ${CLAUDE_PROJECT_DIR} && pnpm run lint && pnpm run typecheck"
        }]
      }
    ]
  }
}

If lint or typecheck fails โ†’ non-zero exit code โ†’ commit is blocked. Claude sees the error output, but a command hook cannot make Claude auto-fix the issues (that's a Skill's job).

Case 2: Block Dangerous Commands

Parse the command content with a script and intercept destructive operations like rm -rf or DROP TABLE.

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

if echo "$COMMAND" | grep -qE 'rm -rf|DROP TABLE|--force'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "Destructive command blocked by hook"
    }
  }'
else
  exit 0
fi
{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "./.claude/hooks/block-dangerous.sh"
      }]
    }]
  }
}

Case 3: Auto-Approve Safe Commands

Getting a confirmation prompt every time Claude runs npm test gets old fast. Use a hook to automatically allow known-safe commands.

#!/bin/bash
# .claude/hooks/auto-approve.sh
COMMAND=$(jq -r '.tool_input.command' < /dev/stdin)

if [[ "$COMMAND" =~ ^(npm test|pnpm run lint|git status|git log) ]]; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "allow",
      permissionDecisionReason: "Safe read-only command"
    }
  }'
else
  exit 0  # No opinion โ€” let the normal flow handle it
fi

Case 4: Stop Interception โ€” Don't Stop Until Tests Pass

Claude says "done" but the tests are still failing? Use a Stop hook to force it to keep going.

#!/bin/bash
# .claude/hooks/must-pass-tests.sh
INPUT=$(cat)
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active')

# Prevent infinite loop: if we've already intercepted once, let it through
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
  exit 0
fi

if ! npm test 2>&1; then
  jq -n '{
    decision: "block",
    reason: "Tests failed. Fix the failures before stopping."
  }'
else
  exit 0
fi

stop_hook_active is the key โ€” on the second trigger it will be true, preventing Claude from getting stuck in an infinite loop. Claude Code also has its own safety net: after 8 consecutive blocks it overrides the hook and ends the turn, so a hook can't lock up the session.

Case 5: Audit Log

Record all of Claude's operations for post-hoc auditing.

#!/bin/bash
INPUT=$(cat)
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name')
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')

echo "{\"ts\": \"$TIMESTAMP\", \"event\": \"$EVENT\", \"tool\": \"$TOOL\"}" \
  >> ~/.claude/audit.log
exit 0

Pair with "async": true to run in the background without slowing down Claude's responses.

Case 6: Inject Environment Variables at Session Start

#!/bin/bash
# SessionStart hook: load .envrc
if [ -n "$CLAUDE_ENV_FILE" ] && [ -f .envrc ]; then
  eval "$(direnv export bash)"
  direnv export bash >> "$CLAUDE_ENV_FILE"
fi
exit 0

CLAUDE_ENV_FILE is a special variable provided by Claude Code. Environment variables written to this file remain active throughout the entire session.

Case 7: Send a Notification When Claude Finishes

{
  "hooks": {
    "Stop": [{
      "matcher": "",
      "hooks": [{
        "type": "command",
        "command": "osascript -e 'display notification \"Done\" with title \"Claude Code\"'"
      }]
    }]
  }
}

Hook Input and Output

Every hook receives JSON via stdin (for command type) or as the POST body (for HTTP type). The format varies by event.

PreToolUse Input

{
  "session_id": "abc123",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test",
    "description": "Run tests"
  }
}

PreToolUse Output (Optional)

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "permissionDecisionReason": "Safe command",
    "updatedInput": {
      "command": "npm test -- --verbose"
    },
    "additionalContext": "This project uses Jest"
  }
}

Note updatedInput โ€” you can modify the tool input that Claude is about to execute. For example, automatically appending a --verbose flag, or converting relative paths to absolute ones.

UserPromptSubmit Output

{
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "Current branch: main, last commit: abc1234"
  }
}

additionalContext gets injected into Claude's context. This lets you automatically supply information (like git status or the current branch) every time a user sends a prompt.

Hooks Inside Skills and Agents

Hooks aren't limited to settings.json. They can also be defined in a Skill or Agent's frontmatter, and their scope is limited to that component's lifecycle.

---
name: secure-operations
description: Perform operations that require security checks
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "./scripts/security-check.sh"
---

This hook is registered when the secure-operations skill is invoked and stays active for the rest of the session (not just the skill's own turn). To run it only once, set once: true on the handler. Hooks in a subagent's frontmatter are only active while that subagent runs.

Division of Responsibility with Skills

This topic is covered in depth in The Three-Piece Toolkit: Hooks, Skills, and Instruction Files, and skill design itself is covered in the Skills deep dive. Here's a quick summary:

PropertyHookSkill
How it's triggeredAutomatic (event-driven)Manual (/name) or from an instruction file
CapabilitiesShell command / HTTP / LLMClaude's full capabilities
Can fix code?NoYes
Can interact?No (except prompt type)Yes
Best forBlocking, logging, injectingFixing, generating, interacting

Hooks are passive safety nets; Skills are active workflows. Hooks are responsible for "preventing bad things from happening," while Skills are responsible for "getting things done right."

Design Principles

Keep hooks fast. SessionStart and PreToolUse hooks run on every operation. If a hook is slow, Claude's responses will be too. Use "async": true for heavy operations so they run in the background.

Don't expose sensitive information in stderr. Exit code 2 sends stderr back to Claude as feedback. If your check script involves API keys or internal paths, make sure those don't end up in stderr.

Use stop_hook_active to prevent infinite loops. When a Stop hook blocks Claude, Claude continues working and triggers Stop again. Without checking stop_hook_active, this becomes an infinite loop.

Command hook stdout must be pure JSON. Welcome messages from shell profiles, echo debug output โ€” all of it will break JSON parsing. Make sure stdout contains only your JSON output.

Managed policy hooks cannot be overridden. Hooks set by organization administrators via policy settings cannot be disabled at the user or project level. This is the guarantee for enterprise security.

What Hooks Look Like as of August 2026

Hooks have grown another layer since this post was published. Below is the increment, filled in from the official hooks reference; anything the docs don't cover is not extrapolated here.

Five handler types now

type now has five values: command, http, mcp_tool, prompt, and agent. All handlers share these fields:

FieldDescription
ifA permission-rule filter (e.g. "Bash(git *)" or "Edit(*.ts)"), only evaluated on tool events. Accepts exactly one rule โ€” no &&, `
timeoutSeconds. Defaults: 600 for command/http/mcp_tool, 30 for prompt, 60 for agent
statusMessageCustom spinner message while the hook runs
onceIf true, runs once per session (only honored in skill frontmatter)

Command hooks additionally support args (when set, the command spawns directly as an executable with no shell involved โ€” exec form) and shell ("bash" or "powershell"). All matching hooks for an event run in parallel; when several return decisions, the most restrictive wins (deny > defer > ask > allow), and additionalContext from every hook is kept.

Full exit code semantics

Exit CodeBehavior
0Success. Stdout not starting with { is treated as plain text โ€” for UserPromptSubmit, UserPromptExpansion, and SessionStart that text goes into Claude's context; for every other event it goes only to the debug log
2Blocking error. The only exit code that blocks on its own, and no JSON output can override it. What "block" means varies by event: PreToolUse blocks the tool call, Stop forces continuation, ConfigChange stops the config change from taking effect โ€” but PermissionRequest ignores exit 2 entirely (use the JSON decision.behavior), PostToolUse only shows stderr to Claude (the tool already ran), and observation-only events (SessionEnd, Notification, etc.) ignore it completely
OtherNon-blocking error. Exception: if stdout is schema-valid JSON, the exit code is ignored and the JSON decides. So if a hook needs to block something, exit 2 is the only option โ€” the conventional exit 1 is just a non-blocking error on most events, and the action proceeds

One more trap worth knowing: a hook that hits its timeout is canceled and renders no decision โ€” on PreToolUse, a stalled hook does not act as a gate, and the tool call proceeds through the normal permission flow.

Async hooks: run in the background without blocking the loop

Add "async": true to a command hook and Claude Code starts the process, then immediately continues working:

  • When the background process exits, its additionalContext and systemMessage are delivered to Claude on the next conversation turn; if the session is idle, delivery waits until the next interaction
  • Since the action has already happened, async hooks cannot block anything: decision, permissionDecision, and continue all have no effect
  • To run in the background but still wake Claude on failure, use "asyncRewake": true: an exit code 2 is delivered immediately as a system reminder, even while the session is idle
  • Each firing spawns a separate background process โ€” repeated firings of the same async hook are not deduplicated

HTTP hooks: how responses count

An HTTP hook sends the event's JSON as a POST body. The response is scored differently from a command hook's exit codes:

  • 2xx + JSON object body: parsed with the same JSON output schema as command hooks โ€” to block, return a 2xx with the appropriate decision fields
  • 2xx + empty body: equivalent to exit 0 with no output
  • Non-2xx status or connection failure: non-blocking error; execution continues
  • Timeout: canceled, no decision

The key point: an HTTP hook cannot signal a block through status codes alone, and plain-text response bodies never reach Claude's context.

Prompt hooks: let an LLM answer ok/false

A prompt hook is single-turn LLM evaluation (a fast model by default). The model must respond with this JSON:

{
  "ok": true,
  "reason": "explanation",
  "impossible": false
}

What ok: false does depends on the event: on Stop/SubagentStop the reason is fed back to Claude as its next instruction (unless the model also sets impossible: true, in which case the stop is allowed); on PreToolUse the turn ends by default, and continueOnBlock: true instead returns the reason as a tool error so Claude can adjust and continue. For finer control (allow/deny/ask, rewriting input), use a command hook's JSON output.

MCP tool hooks: outsource the check to a connected MCP server

{
  "type": "mcp_tool",
  "server": "my_server",
  "tool": "security_scan",
  "input": { "file_path": "${tool_input.file_path}" }
}

Calls a tool on an already-connected MCP server; the tool's text output is parsed under the same rules as command-hook stdout. The input strings support ${path} substitution from the event's JSON. The server must already be connected โ€” the hook never triggers an OAuth or connection flow; a missing connection or an isError: true tool result becomes a non-blocking error. SessionStart and Setup typically fire before MCP servers finish connecting, so hooks on those events should expect a "not connected" error on first run.

Also note that not every event accepts all five handler types: the thirteen tool and turn events (PreToolUse, Stop, UserPromptSubmit, etc.) accept all five; observation-style events (Notification, FileChanged, etc.) don't support prompt/agent; and SessionStart and Setup accept only command and mcp_tool.

Summary

Hooks are Claude Code's lowest-level control mechanism. They're not smart (they don't understand your code), but they're reliable (the mechanism guarantees execution) โ€” at their core they attach your actions at specific points of the agentic loop, which the series entry post breaks down in full.

Most people only need two or three hooks: run checks before commits, block dangerous commands, and send a notification when done. Start there, and add more as specific needs arise. Don't over-engineer โ€” if a simple exit code solves the problem, there's no need to reach for a prompt or agent type.

The most powerful pattern is combining Hooks with Skills. Hooks catch problems, Skills fix problems, and instruction files wire the workflow together. With each layer doing its job, the AI gains a complete quality assurance system.


References

Update Log

  • 2026-08-26: Added schema / exit code / async / HTTP / prompt / MCP tool hook coverage from the hooks reference.