Claude Code Hooks: Turn Prompts Into Workflows
Learn how Claude Code hooks enforce real engineering workflows with PreToolUse, PostToolUse, and SessionStart.
Claude Code Hooks: Turning Prompts Into Real Engineering Workflows
Most developers use Claude Code as a smart assistant. You type a prompt, it writes some code, you review it, repeat. But Claude Code hooks change the game entirely. They turn Claude from a reactive assistant into an enforceable engineering workflow where every tool call, every file write, and every bash command passes through your rules before it executes.
Hooks are user-defined shell commands, HTTP endpoints, or LLM prompts that fire automatically at specific points in Claude Code's lifecycle. They can block dangerous operations, auto-approve safe ones, inject context, run linters, log everything, and enforce team-wide standards without relying on anyone to remember the rules.
This guide covers everything you need to know about Claude Code hooks. We will start with the fundamentals, then build up to real workflow patterns that go far beyond basic setup. By the end, you will have a working mental model for designing hook-driven workflows that make your engineering process repeatable, auditable, and enforceable.
Table of Contents
- What Are Claude Code Hooks?
- Why Hooks Matter: From Assistant to Workflow Engine
- The Hook Lifecycle: Every Event Explained
- Where Hooks Live: Configuration Scopes
- Hook Configuration Anatomy
- SessionStart Hook: Setting Up the Environment
- PreToolUse Hook: The Gatekeeper
- PostToolUse Hook: Enforcing Standards After Execution
- Stop and SubagentStop: Controlling How Work Ends
- Matcher Patterns: Targeting the Right Events
- Hook Handler Types: Commands, HTTP, Prompts, and Agents
- Exit Codes and JSON Output: Communicating Decisions
- Environment Variables Available to Hooks
- Real Workflow Patterns: Beyond Basic Setup
- Hooks in Team Settings: Sharing Across Engineers
- Debugging and Troubleshooting Hooks
- Frequently Asked Questions
- Key Takeaways
What Are Claude Code Hooks?
Claude Code hooks are automation triggers that execute your code at specific points in Claude's lifecycle. Every time Claude starts a session, calls a tool, writes a file, or finishes responding, hooks give you an interception point where your own logic runs.
Think of hooks like Git hooks, but for an AI coding agent. A pre-commit hook runs your linter before every commit. A PreToolUse hook runs your validation script before every tool call Claude makes. The mental model is the same: intercept, validate, decide.
Hooks are not prompt instructions. They are not suggestions that Claude might follow or might ignore. They are hard enforcement points that execute deterministically outside of Claude's reasoning. If your PreToolUse hook returns exit code 2, the tool call is blocked. Period. Claude does not get to argue. It does not get to "try again with a slightly different approach." The operation is stopped.
This distinction is critical. CLAUDE.md instructions are soft guidance. Hooks are hard rules.
Why Hooks Matter: From Assistant to Workflow Engine
Hooks matter because they close the gap between what you tell Claude to do and what Claude actually does. Without hooks, your only enforcement mechanism is the prompt itself. And prompts are unreliable as policy.
The Prompt Problem
Consider this scenario. You tell Claude: "Never use rm -rf on any directory outside the project root." Claude will follow that instruction most of the time. But on a complex task involving cleanup scripts, it might forget. Or a subagent might not have the same context. Or the instruction might get lost during context compaction.
With a PreToolUse hook, you write a five-line bash script that checks every Bash command for dangerous patterns. It does not matter what Claude's context window looks like. It does not matter how many subagents are spawned. Every single command passes through your gate.
From Reactive to Proactive
Without hooks, your workflow is reactive. Claude does something, you review it, you fix it. With hooks, your workflow is proactive:
- Before Claude writes a file, your hook runs the linter on the proposed content.
- Before Claude runs a command, your hook validates it against a security allowlist.
- After Claude edits code, your hook runs the test suite and feeds failures back as context.
- When a session starts, your hook injects environment variables and project state.
This is the difference between "AI assistant" and "AI-powered engineering workflow." The assistant asks permission. The workflow enforces rules.
Team-Wide Standards
The real power shows up at team scale. Check your hooks into .claude/settings.json, and every engineer on the team gets the same enforcement. No one forgets to run the linter. No one accidentally runs destructive commands. No one bypasses the security policy.
Claude automation through hooks means your standards are not documentation that people read and forget. They are executable code that runs every time.
The Hook Lifecycle: Every Event Explained
Claude Code supports a comprehensive set of hook events that cover the entire session lifecycle. Here are the ones that matter most for building workflows.
| Event | When It Fires | Can Block? |
|---|---|---|
SessionStart |
Session begins or resumes | No |
UserPromptSubmit |
User submits a prompt, before processing | Yes |
PreToolUse |
Before a tool call executes | Yes |
PostToolUse |
After a tool call succeeds | Yes (feedback) |
PostToolUseFailure |
After a tool call fails | No |
PermissionRequest |
When a permission dialog appears | Yes |
PermissionDenied |
When auto mode denies a tool call | No |
Notification |
When Claude sends a notification | No |
SubagentStart |
When a subagent is spawned | No |
SubagentStop |
When a subagent finishes | Yes |
Stop |
When Claude finishes responding | Yes |
StopFailure |
When turn ends due to API error | No |
InstructionsLoaded |
When CLAUDE.md files are loaded | No |
ConfigChange |
When a configuration file changes | Yes |
CwdChanged |
When working directory changes | No |
FileChanged |
When a watched file changes on disk | No |
SessionEnd |
When a session terminates | No |
The blocking events are the most powerful ones for building workflows. PreToolUse and UserPromptSubmit can prevent actions before they happen. Stop and SubagentStop can prevent Claude from finishing until conditions are met.
The non-blocking events are valuable for observability, logging, context injection, and side effects like sending notifications or updating dashboards.
Where Hooks Live: Configuration Scopes
Hooks are configured in JSON settings files. Claude Code uses a scope hierarchy that determines where configurations apply and who they affect.
| Scope | Location | Shared With Team? |
|---|---|---|
| User | ~/.claude/settings.json |
No |
| Project | .claude/settings.json |
Yes (committed to git) |
| Local | .claude/settings.local.json |
No (gitignored) |
| Managed | Organization-level policy | Yes (deployed by IT) |
Choosing the Right Scope
Use project scope for hooks that enforce team standards. Linter checks, security validation, test running. These go in .claude/settings.json and get committed to the repo. Every engineer on the team gets the same enforcement automatically.
Use user scope for personal workflow hooks. Notification integrations, personal logging, custom environment setup. These live in ~/.claude/settings.json and only affect you.
Use local scope for project-specific personal overrides. Maybe you need a hook that references local paths or credentials. These go in .claude/settings.local.json and are gitignored by default.
Managed scope is for organization-wide security policies. If your company needs to enforce that certain commands are always blocked or certain endpoints are always called, managed settings handle that.
Hook Configuration Anatomy
Every hook configuration follows a three-level nesting structure: event, matcher group, and handler array. Understanding this structure is essential before building any hook.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/path/to/validate-bash.sh",
"timeout": 30
}
]
},
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "/path/to/validate-writes.sh"
}
]
}
]
}
}Level 1: The Event
The top-level key is the hook event name: PreToolUse, PostToolUse, SessionStart, Stop, etc. This determines when your hooks fire.
Level 2: The Matcher Group
Each event contains an array of matcher groups. Each group has a matcher field (a regex pattern) and a hooks array. The matcher filters which specific tool calls or event types trigger the group.
Omitting the matcher or setting it to "*" means the group fires for every occurrence of that event.
Level 3: The Handler Array
Each matcher group contains an array of hook handlers. These are the actual commands, HTTP requests, or prompts that execute. Multiple handlers in the same group run sequentially. If any handler blocks the operation, later handlers in the group do not run.
This three-level structure gives you precise control. You can have different validation logic for Bash commands versus file writes versus MCP tool calls, all within the same PreToolUse event.
SessionStart Hook: Setting Up the Environment
The Claude SessionStart hook fires when a session begins, resumes, is cleared, or is compacted. It is the right place for environment setup, context injection, and initialization tasks.
Why SessionStart Matters
Every Claude Code session starts in a relatively blank state. The model has its system prompt and your CLAUDE.md instructions, but it does not have runtime context like which services are running, what the current Git state is, or what environment variables are set.
SessionStart hooks fix this. They run before Claude processes any user input, giving you a chance to set up the environment and inject context.
Basic SessionStart Configuration
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-init.sh"
}
]
}
]
}
}The matcher for SessionStart events can be startup, resume, clear, or compact, letting you run different logic depending on how the session started.
Injecting Environment Variables
One of the most powerful SessionStart patterns is persisting environment variables using the CLAUDE_ENV_FILE. This special file path lets your hook set environment variables that persist for the entire session.
#!/bin/bash
# .claude/hooks/session-init.sh
# Persist environment variables for the session
if [ -n "$CLAUDE_ENV_FILE" ]; then
# Detect Node version
echo "export NODE_VERSION=$(node --version 2>/dev/null || echo 'not installed')" >> "$CLAUDE_ENV_FILE"
# Set environment based on branch
BRANCH=$(git branch --show-current 2>/dev/null)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "production" ]; then
echo 'export DEPLOY_ENV=production' >> "$CLAUDE_ENV_FILE"
else
echo 'export DEPLOY_ENV=development' >> "$CLAUDE_ENV_FILE"
fi
# Load .env file if it exists
if [ -f "$CLAUDE_PROJECT_DIR/.env" ]; then
while IFS='=' read -r key value; do
[[ "$key" =~ ^#.*$ ]] && continue
[[ -z "$key" ]] && continue
echo "export $key=$value" >> "$CLAUDE_ENV_FILE"
done < "$CLAUDE_PROJECT_DIR/.env"
fi
fi
exit 0Injecting Context About Project State
SessionStart can also return context that gets added to Claude's conversation through additionalContext in the JSON output.
#!/bin/bash
# .claude/hooks/inject-project-state.sh
# Gather project state
GIT_STATUS=$(git status --porcelain 2>/dev/null | head -20)
RECENT_COMMITS=$(git log --oneline -5 2>/dev/null)
RUNNING_SERVICES=$(docker ps --format "{{.Names}}: {{.Status}}" 2>/dev/null | head -10)
# Build context string
CONTEXT="## Current Project State
### Uncommitted Changes
$GIT_STATUS
### Recent Commits
$RECENT_COMMITS
### Running Services
$RUNNING_SERVICES"
# Return as JSON with additional context
jq -n --arg ctx "$CONTEXT" '{
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: $ctx
}
}'This means every time you start a Claude Code session, Claude already knows what is running, what has changed, and what was committed recently. No need to ask "what is the current state of things" every time.
Performance Considerations
SessionStart hooks should be fast. They run before Claude can process any input, so a slow hook means a slow startup. Keep initialization under a second or two. If you need to do expensive setup, consider using the async: true flag to run the hook in the background.
{
"type": "command",
"command": "/path/to/heavy-setup.sh",
"async": true
}Async hooks will not block session startup, but their results will not be available immediately either. Use async for side effects like logging or cache warming, and synchronous hooks for context injection.
PreToolUse Hook: The Gatekeeper
The PreToolUse hook is the most important hook for building enforceable workflows. It fires before every tool call Claude makes, and it can allow, deny, ask the user, or modify the operation.
How PreToolUse Works
When Claude decides to call a tool (Bash, Write, Edit, Read, Glob, Grep, WebFetch, or any MCP tool), the following happens:
- Claude generates the tool call with its parameters.
- The PreToolUse hook receives the full tool call as JSON on stdin.
- Your hook script examines the parameters and decides what to do.
- Based on the exit code and JSON output, the tool call proceeds, is blocked, or is modified.
The JSON input your hook receives includes the tool name and the complete tool input:
{
"session_id": "abc123",
"cwd": "/home/user/my-project",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test",
"description": "Run test suite",
"timeout": 120000
},
"tool_use_id": "toolu_01ABC..."
}Blocking Dangerous Commands
The most common PreToolUse pattern is a command validator that blocks dangerous operations.
#!/bin/bash
# .claude/hooks/validate-bash.sh
# Block dangerous bash commands
INPUT=$(cat /dev/stdin)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
# Block destructive patterns
if echo "$COMMAND" | grep -qE 'rm\s+-rf\s+/|rm\s+-rf\s+~|mkfs\.|dd\s+if=|chmod\s+-R\s+777\s+/|:(){ :|:& };:'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Blocked: destructive system command detected"
}
}'
exit 0
fi
# Block commands that modify git history
if echo "$COMMAND" | grep -qE 'git\s+push\s+.*--force|git\s+reset\s+--hard|git\s+clean\s+-fd'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Blocked: command modifies git history. Use non-destructive alternatives."
}
}'
exit 0
fi
# All other commands proceed normally
exit 0Auto-Approving Safe Commands
The opposite pattern is equally useful. Instead of blocking bad commands, auto-approve known safe ones to reduce permission prompts and speed up workflows.
#!/bin/bash
# .claude/hooks/auto-approve-safe.sh
# Auto-approve safe commands to reduce friction
INPUT=$(cat /dev/stdin)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Auto-approve read-only and common dev commands for Bash
if [ "$TOOL_NAME" = "Bash" ]; then
if echo "$COMMAND" | grep -qE '^(npm test|npm run lint|npm run build|npm run typecheck|npx tsc --noEmit|cargo test|cargo clippy|go test|pytest|make test|make lint)'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: "Auto-approved: known safe development command"
}
}'
exit 0
fi
fi
# Defer to normal permission handling for everything else
exit 0Restricting File Write Locations
PreToolUse hooks can also validate Write and Edit operations to enforce that Claude only modifies files in expected locations.
#!/bin/bash
# .claude/hooks/validate-writes.sh
# Ensure writes only happen in allowed directories
INPUT=$(cat /dev/stdin)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Only validate Write and Edit tools
if [ "$TOOL_NAME" != "Write" ] && [ "$TOOL_NAME" != "Edit" ]; then
exit 0
fi
# Block writes outside project directory
if [[ "$FILE_PATH" != "$CLAUDE_PROJECT_DIR"* ]]; then
jq -n --arg path "$FILE_PATH" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("Blocked: cannot write to " + $path + " which is outside the project directory")
}
}'
exit 0
fi
# Block writes to critical config files
BASENAME=$(basename "$FILE_PATH")
if echo "$BASENAME" | grep -qE '^\.(env|env\.local|env\.production)|package-lock\.json|yarn\.lock|pnpm-lock\.yaml$'; then
jq -n --arg file "$BASENAME" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: ("Caution: modifying " + $file + " requires manual confirmation")
}
}'
exit 0
fi
exit 0Notice the use of "ask" for the permission decision. This does not block the operation outright. Instead, it forces a user confirmation prompt even in auto mode. This is useful for operations that are not necessarily dangerous but should be intentional.
Modifying Tool Input
One of the most powerful PreToolUse hook capabilities is input modification. Your hook can change the tool's parameters before execution.
#!/bin/bash
# .claude/hooks/enforce-timeout.sh
# Ensure all bash commands have a reasonable timeout
INPUT=$(cat /dev/stdin)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
TIMEOUT=$(echo "$INPUT" | jq -r '.tool_input.timeout // 0')
if [ "$TOOL_NAME" = "Bash" ] && [ "$TIMEOUT" -gt 300000 ]; then
# Cap timeout at 5 minutes
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: "Timeout capped at 5 minutes",
updatedInput: {
timeout: 300000
}
}
}'
exit 0
fi
exit 0The updatedInput field merges with the original tool input, so you only need to specify the fields you want to change.
PostToolUse Hook: Enforcing Standards After Execution
The PostToolUse hook fires after a tool call succeeds. It receives both the original input and the tool's response, making it ideal for validation, automated follow-up actions, and feedback loops.
Running Linters After Every Write
The most common PostToolUse pattern is running a linter or formatter after Claude writes or edits a file.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-after-write.sh",
"timeout": 30
}
]
}
]
}
}#!/bin/bash
# .claude/hooks/lint-after-write.sh
# Run linter on any file Claude just wrote or edited
INPUT=$(cat /dev/stdin)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path')
EXTENSION="${FILE_PATH##*.}"
LINT_OUTPUT=""
case "$EXTENSION" in
ts|tsx|js|jsx)
LINT_OUTPUT=$(npx eslint "$FILE_PATH" --no-error-on-unmatched-pattern 2>&1) || true
;;
py)
LINT_OUTPUT=$(python -m ruff check "$FILE_PATH" 2>&1) || true
;;
rs)
LINT_OUTPUT=$(cargo clippy --message-format=short 2>&1 | head -20) || true
;;
go)
LINT_OUTPUT=$(golangci-lint run "$FILE_PATH" 2>&1 | head -20) || true
;;
esac
if [ -n "$LINT_OUTPUT" ]; then
jq -n --arg lint "$LINT_OUTPUT" --arg file "$FILE_PATH" '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: ("Lint issues found in " + $file + ":\n" + $lint + "\nPlease fix these issues.")
}
}'
else
exit 0
fiThe key insight here is additionalContext. This field injects text directly into Claude's context, so Claude sees the lint errors and can fix them immediately without you having to ask. The feedback loop is automatic.
Running Tests After Code Changes
A more aggressive pattern is running the test suite after every code change and feeding results back.
#!/bin/bash
# .claude/hooks/run-tests-after-edit.sh
# Run related tests after Claude modifies source code
INPUT=$(cat /dev/stdin)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path')
# Only run for source files, not test files or configs
if echo "$FILE_PATH" | grep -qE '\.(test|spec)\.(ts|tsx|js|jsx)$'; then
exit 0
fi
if ! echo "$FILE_PATH" | grep -qE '\.(ts|tsx|js|jsx)$'; then
exit 0
fi
# Find related test file
TEST_FILE=$(echo "$FILE_PATH" | sed 's/\.\(ts\|tsx\|js\|jsx\)$/.test.\1/')
if [ ! -f "$TEST_FILE" ]; then
TEST_FILE=$(echo "$FILE_PATH" | sed 's/\.\(ts\|tsx\|js\|jsx\)$/.spec.\1/')
fi
if [ -f "$TEST_FILE" ]; then
TEST_OUTPUT=$(npx jest "$TEST_FILE" --no-coverage 2>&1 | tail -30)
TEST_EXIT=$?
if [ $TEST_EXIT -ne 0 ]; then
jq -n --arg output "$TEST_OUTPUT" --arg testFile "$TEST_FILE" '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: ("Tests failed in " + $testFile + ":\n" + $output + "\nPlease fix the failing tests.")
}
}'
exit 0
fi
fi
exit 0This creates a tight feedback loop. Claude edits a file, the related tests run automatically, and if they fail, Claude sees the output and fixes the issue in the same turn. No manual intervention required.
Logging All Tool Operations
PostToolUse is also valuable for audit logging. Every operation Claude performs can be logged to a file, database, or external service.
#!/bin/bash
# .claude/hooks/audit-log.sh
# Log all tool operations for audit trail
INPUT=$(cat /dev/stdin)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
LOG_DIR="$CLAUDE_PROJECT_DIR/.claude/logs"
mkdir -p "$LOG_DIR"
echo "{\"timestamp\":\"$TIMESTAMP\",\"session\":\"$SESSION_ID\",\"tool\":\"$TOOL_NAME\",\"input\":$TOOL_INPUT}" >> "$LOG_DIR/audit.jsonl"
exit 0Since this hook exits with code 0 and produces no JSON output, it is completely transparent to Claude. The logging happens silently in the background of every tool call.
Stop and SubagentStop: Controlling How Work Ends
The Stop hook fires when Claude finishes responding, and SubagentStop fires when a subagent completes. These hooks let you validate Claude's final output before the turn ends.
Preventing Incomplete Work
A Stop hook can check whether Claude actually completed the task or just gave up.
#!/bin/bash
# .claude/hooks/validate-completion.sh
# Ensure Claude does not stop prematurely during multi-step tasks
INPUT=$(cat /dev/stdin)
HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name')
# Check if there are uncommitted changes that suggest incomplete work
UNCOMMITTED=$(git diff --name-only 2>/dev/null | wc -l)
UNTRACKED=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l)
if [ "$UNCOMMITTED" -gt 5 ] || [ "$UNTRACKED" -gt 3 ]; then
jq -n --arg uncommitted "$UNCOMMITTED" --arg untracked "$UNTRACKED" '{
decision: "block",
reason: ("There are " + $uncommitted + " uncommitted changes and " + $untracked + " untracked files. Please review and organize these changes before finishing.")
}'
exit 0
fi
exit 0When a Stop hook blocks with decision: "block", Claude continues working instead of finishing. The reason is injected as context so Claude knows what to address.
Validating Subagent Output
SubagentStop hooks are especially useful for ensuring subagents (spawned via the Agent tool) meet quality standards.
{
"hooks": {
"SubagentStop": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/validate-subagent.sh"
}
]
}
]
}
}The SubagentStop event provides the subagent's last message and transcript path, allowing your hook to analyze what the subagent actually accomplished.
Matcher Patterns: Targeting the Right Events
Matchers are regex patterns that filter when hooks fire. They are the precision mechanism that keeps your hooks from being too broad or too narrow.
Tool Name Matching
For PreToolUse and PostToolUse, the matcher matches against the tool name.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "/path/to/bash-validator.sh" }]
},
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "/path/to/write-validator.sh" }]
},
{
"matcher": "mcp__.*",
"hooks": [{ "type": "command", "command": "/path/to/mcp-validator.sh" }]
}
]
}
}Common tool names you will match against: Bash, Write, Edit, Read, Glob, Grep, WebFetch, WebSearch, Agent, AskUserQuestion.
MCP Tool Matching
MCP tools follow the naming pattern mcp__<server>__<tool>. This means you can match against specific servers or specific tools within servers.
{
"matcher": "mcp__memory__.*",
"hooks": [{ "type": "command", "command": "/path/to/memory-audit.sh" }]
}This hooks into every tool call to the memory MCP server. You could also match a specific tool:
{
"matcher": "mcp__github__create_pull_request",
"hooks": [{ "type": "command", "command": "/path/to/pr-validation.sh" }]
}SessionStart Matching
For SessionStart, the matcher matches against how the session started: startup, resume, clear, or compact.
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [{ "type": "command", "command": "/path/to/full-init.sh" }]
},
{
"matcher": "resume",
"hooks": [{ "type": "command", "command": "/path/to/quick-refresh.sh" }]
}
]
}
}This lets you run full initialization on fresh sessions but only a lightweight state refresh when resuming.
The Conditional if Filter
For even finer control, command hooks support an if field that uses permission rule syntax to filter tool calls.
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(rm *)",
"command": "/path/to/block-rm.sh"
}
]
}The if field prevents the hook from even executing unless the tool call matches the pattern. This is more efficient than having the hook script do the filtering itself, because the hook process is never spawned for non-matching calls.
Hook Handler Types: Commands, HTTP, Prompts, and Agents
Claude Code supports four types of hook handlers, each suited to different use cases.
Command Hooks
Command hooks execute shell commands. They are the most common and most flexible type. The hook receives JSON on stdin and communicates via exit codes and stdout.
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/my-hook.sh",
"timeout": 30,
"statusMessage": "Validating command safety..."
}The statusMessage field controls what the user sees in the spinner while the hook runs. It is a small UX detail that makes hooks feel polished.
Key fields:
command(required): The shell command to run.timeout(optional): Seconds before the hook is killed. Default is 600 (10 minutes), but most hooks should be much faster.async(optional): If true, runs in the background without blocking.shell(optional):"bash"(default) or"powershell"for Windows.statusMessage(optional): Custom spinner text.
HTTP Hooks
HTTP hooks send the event JSON as a POST request to an endpoint. They are ideal for integrating with external services, CI/CD pipelines, or centralized validation servers.
{
"type": "http",
"url": "http://localhost:8080/hooks/pre-tool-use",
"headers": {
"Authorization": "Bearer $API_TOKEN",
"X-Project": "my-project"
},
"allowedEnvVars": ["API_TOKEN"],
"timeout": 30
}HTTP hooks follow the same decision protocol as command hooks, but through HTTP response codes and JSON bodies. A 2xx response with a blocking decision JSON blocks the operation. A 2xx with an empty body allows it.
Prompt Hooks
Prompt hooks send a prompt to Claude for single-turn evaluation. This is useful when you want AI-powered validation that is too complex for a bash script.
{
"type": "prompt",
"prompt": "Review this bash command for security issues. The command is: $ARGUMENTS. Respond with a JSON object containing permissionDecision: 'allow' or 'deny' and a reason.",
"model": "fast-model",
"timeout": 30
}Prompt hooks use a separate, fast model evaluation. They do not consume your main conversation's context window.
Agent Hooks
Agent hooks spawn a full subagent for complex verification tasks. Use these sparingly, as they are the most expensive hook type.
{
"type": "agent",
"prompt": "Review the deployment configuration in $ARGUMENTS for security issues, missing environment variables, and potential downtime risks. Report any findings.",
"timeout": 60
}Agent hooks are best reserved for high-stakes operations like deployment validation or security audits where a simple script is insufficient.
Exit Codes and JSON Output: Communicating Decisions
How your hook communicates its decision is the most important thing to get right. The exit code determines the broad category, and the JSON output provides fine-grained control.
Exit Code Reference
| Exit Code | Meaning | Effect |
|---|---|---|
0 |
Success | Stdout is parsed for JSON. If no JSON, operation proceeds. |
2 |
Blocking error | Operation is blocked. Stderr is shown to Claude as context. |
| Any other | Non-blocking error | Stderr shown in verbose mode. Operation continues. |
JSON Output for PreToolUse
The most detailed JSON output format is for PreToolUse hooks, where you have four decision options:
# Allow: skip permission prompt entirely
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: "Safe command auto-approved"
}
}'
# Deny: block the operation
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Command violates security policy"
}
}'
# Ask: force user confirmation
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: "Sensitive operation requires manual approval"
}
}'
# Modify and allow: change the input
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: "Command modified for safety",
updatedInput: {
command: "npm run test -- --bail"
}
}
}'Universal Output Fields
Several JSON fields work across all hook events:
{
"continue": false,
"stopReason": "Critical policy violation detected",
"suppressOutput": true,
"systemMessage": "Warning: approaching rate limit"
}continue: falsestops Claude entirely. The session ends. Use this for critical policy violations.stopReasonprovides the message shown when stopping.suppressOutput: truehides the hook's output from verbose mode.systemMessageshows a warning to the user without blocking anything.
PostToolUse Decision Format
PostToolUse uses a simpler top-level decision format:
jq -n '{
decision: "block",
reason: "Generated code has lint errors. See additionalContext for details.",
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: "ESLint errors:\n- line 15: unused variable\n- line 23: missing semicolon"
}
}'When a PostToolUse hook blocks, it does not undo the operation (the tool already ran). Instead, the block reason is fed back to Claude as an error message, prompting Claude to fix the issue.
Environment Variables Available to Hooks
Every hook has access to environment variables that provide context about the current session and project.
| Variable | Description | Available In |
|---|---|---|
CLAUDE_PROJECT_DIR |
Absolute path to the project root | All hooks |
CLAUDE_ENV_FILE |
Path to write persistent env vars | SessionStart, CwdChanged, FileChanged |
CLAUDE_CODE_REMOTE |
"true" if running in remote web environment |
All hooks |
CLAUDE_PLUGIN_ROOT |
Plugin installation directory | Plugin hooks |
CLAUDE_PLUGIN_DATA |
Plugin persistent data directory | Plugin hooks |
Using CLAUDE_PROJECT_DIR
Always reference scripts using $CLAUDE_PROJECT_DIR rather than hardcoded paths. This ensures hooks work regardless of where the project is cloned.
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/validate.sh"
}Note the double quotes around the variable. This handles project directories with spaces in the path.
Using CLAUDE_ENV_FILE
The CLAUDE_ENV_FILE is only available in SessionStart, CwdChanged, and FileChanged hooks. It is the only way to persist environment variables across an entire Claude Code session.
#!/bin/bash
# Set variables that persist for the entire session
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export DATABASE_URL=postgresql://localhost/mydb' >> "$CLAUDE_ENV_FILE"
echo 'export REDIS_URL=redis://localhost:6379' >> "$CLAUDE_ENV_FILE"
fiThis is more reliable than setting variables in your shell profile because it specifically targets the Claude Code process and its child processes.
Real Workflow Patterns: Beyond Basic Setup
Now that you understand the mechanics, let us look at real workflow patterns that combine multiple hooks into cohesive engineering workflows. These are the patterns that turn Claude Code from an assistant into an enforceable workflow engine.
Pattern 1: The Full Lint-Test-Format Pipeline
This pattern ensures every file Claude writes passes linting, formatting, and related tests automatically.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-file.sh",
"timeout": 15,
"statusMessage": "Formatting..."
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-file.sh",
"timeout": 30,
"statusMessage": "Linting..."
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-related-tests.sh",
"timeout": 60,
"statusMessage": "Running related tests..."
}
]
}
]
}
}The three hooks run sequentially after every Write or Edit. First format, then lint, then test. If the linter finds issues, Claude sees them as additionalContext and fixes them immediately.
#!/bin/bash
# .claude/hooks/format-file.sh
# Auto-format the file Claude just wrote
INPUT=$(cat /dev/stdin)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path')
EXTENSION="${FILE_PATH##*.}"
case "$EXTENSION" in
ts|tsx|js|jsx|json|css|scss|md)
npx prettier --write "$FILE_PATH" 2>/dev/null
;;
py)
python -m black "$FILE_PATH" 2>/dev/null
;;
rs)
rustfmt "$FILE_PATH" 2>/dev/null
;;
go)
gofmt -w "$FILE_PATH" 2>/dev/null
;;
esac
exit 0Pattern 2: Security-First Bash Execution
This pattern creates a layered security model for all Bash commands Claude runs. It combines PreToolUse blocking with PostToolUse auditing.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/security-gate.sh",
"timeout": 5,
"statusMessage": "Security check..."
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/audit-command.sh",
"timeout": 5
}
]
}
]
}
}#!/bin/bash
# .claude/hooks/security-gate.sh
# Multi-layered security validation for bash commands
INPUT=$(cat /dev/stdin)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
# Layer 1: Hard blocks - never allow these
HARD_BLOCKS='rm\s+-rf\s+/|mkfs\.|dd\s+if=/dev|chmod\s+-R\s+777\s+/|curl.*\|\s*bash|wget.*\|\s*sh'
if echo "$COMMAND" | grep -qE "$HARD_BLOCKS"; then
echo "BLOCKED: Destructive or unsafe command pattern detected" >&2
exit 2
fi
# Layer 2: Network restrictions - block outbound data exfiltration
if echo "$COMMAND" | grep -qE 'curl\s+.*-d\s|curl\s+.*--data|wget\s+--post'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: "Command sends data to external service. Please confirm this is intentional."
}
}'
exit 0
fi
# Layer 3: Auto-approve known safe commands
SAFE_PATTERNS='^(ls|cat|head|tail|wc|find|grep|rg|git\s+(status|log|diff|branch|show)|npm\s+(test|run|list)|node\s+--version|python\s+--version)'
if echo "$COMMAND" | grep -qE "$SAFE_PATTERNS"; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: "Auto-approved: safe read-only command"
}
}'
exit 0
fi
# Layer 4: Everything else goes through normal permission flow
exit 0This layered approach gives you hard blocks for dangerous commands, user confirmation for suspicious commands, auto-approval for safe commands, and normal permissions for everything else.
Pattern 3: Environment-Aware Session Initialization
This pattern uses SessionStart to detect the project environment and configure Claude's behavior accordingly.
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/detect-environment.sh"
}
]
}
]
}
}#!/bin/bash
# .claude/hooks/detect-environment.sh
# Detect project type and inject relevant context
PROJECT_DIR="$CLAUDE_PROJECT_DIR"
CONTEXT_PARTS=()
# Detect package manager and project type
if [ -f "$PROJECT_DIR/package.json" ]; then
if [ -f "$PROJECT_DIR/pnpm-lock.yaml" ]; then
PKG_MANAGER="pnpm"
elif [ -f "$PROJECT_DIR/yarn.lock" ]; then
PKG_MANAGER="yarn"
else
PKG_MANAGER="npm"
fi
# Get available scripts
SCRIPTS=$(jq -r '.scripts | keys[]' "$PROJECT_DIR/package.json" 2>/dev/null | tr '\n' ', ')
CONTEXT_PARTS+=("Package manager: $PKG_MANAGER")
CONTEXT_PARTS+=("Available scripts: $SCRIPTS")
# Check for TypeScript
if [ -f "$PROJECT_DIR/tsconfig.json" ]; then
CONTEXT_PARTS+=("TypeScript project detected")
fi
# Check for common frameworks
DEPS=$(jq -r '(.dependencies // {}) + (.devDependencies // {}) | keys[]' "$PROJECT_DIR/package.json" 2>/dev/null)
if echo "$DEPS" | grep -q "next"; then
CONTEXT_PARTS+=("Framework: Next.js")
elif echo "$DEPS" | grep -q "react"; then
CONTEXT_PARTS+=("Framework: React")
elif echo "$DEPS" | grep -q "vue"; then
CONTEXT_PARTS+=("Framework: Vue")
fi
fi
if [ -f "$PROJECT_DIR/Cargo.toml" ]; then
CONTEXT_PARTS+=("Rust project (Cargo)")
fi
if [ -f "$PROJECT_DIR/go.mod" ]; then
MODULE=$(head -1 "$PROJECT_DIR/go.mod" | awk '{print $2}')
CONTEXT_PARTS+=("Go module: $MODULE")
fi
if [ -f "$PROJECT_DIR/pyproject.toml" ] || [ -f "$PROJECT_DIR/setup.py" ]; then
CONTEXT_PARTS+=("Python project")
if [ -f "$PROJECT_DIR/.python-version" ]; then
PY_VERSION=$(cat "$PROJECT_DIR/.python-version")
CONTEXT_PARTS+=("Python version: $PY_VERSION")
fi
fi
# Git state
BRANCH=$(git -C "$PROJECT_DIR" branch --show-current 2>/dev/null)
if [ -n "$BRANCH" ]; then
CONTEXT_PARTS+=("Git branch: $BRANCH")
AHEAD=$(git -C "$PROJECT_DIR" rev-list --count HEAD...origin/$BRANCH 2>/dev/null || echo "unknown")
CONTEXT_PARTS+=("Commits ahead of remote: $AHEAD")
fi
# Docker state
if [ -f "$PROJECT_DIR/docker-compose.yml" ] || [ -f "$PROJECT_DIR/docker-compose.yaml" ]; then
RUNNING=$(docker compose -f "$PROJECT_DIR/docker-compose.yml" ps --format "{{.Name}}: {{.Status}}" 2>/dev/null | head -5)
if [ -n "$RUNNING" ]; then
CONTEXT_PARTS+=("Running containers: $RUNNING")
else
CONTEXT_PARTS+=("Docker Compose project detected but no containers running")
fi
fi
# Set environment variables
if [ -n "$CLAUDE_ENV_FILE" ]; then
[ -n "$PKG_MANAGER" ] && echo "export PKG_MANAGER=$PKG_MANAGER" >> "$CLAUDE_ENV_FILE"
[ -n "$BRANCH" ] && echo "export GIT_BRANCH=$BRANCH" >> "$CLAUDE_ENV_FILE"
fi
# Build and output context
FULL_CONTEXT=$(printf '%s\n' "${CONTEXT_PARTS[@]}")
jq -n --arg ctx "$FULL_CONTEXT" '{
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: $ctx
}
}'This hook means Claude immediately knows the project type, available scripts, current branch, and running services. No need for the "let me explore the project" phase that wastes the first few turns of every session.
Pattern 4: Notification Integration
This pattern routes Claude Code notifications to external systems like Slack or a desktop notification daemon.
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/notify-slack.sh",
"async": true
}
]
}
]
}
}#!/bin/bash
# .claude/hooks/notify-slack.sh
# Send Claude Code notifications to Slack
INPUT=$(cat /dev/stdin)
MESSAGE=$(echo "$INPUT" | jq -r '.message')
TITLE=$(echo "$INPUT" | jq -r '.title // "Claude Code"')
TYPE=$(echo "$INPUT" | jq -r '.notification_type')
SLACK_WEBHOOK="${SLACK_WEBHOOK_URL:-}"
if [ -z "$SLACK_WEBHOOK" ]; then
exit 0
fi
PAYLOAD=$(jq -n \
--arg text "*$TITLE*\n$MESSAGE\n_Type: $TYPE_" \
'{text: $text}')
curl -s -X POST "$SLACK_WEBHOOK" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" > /dev/null 2>&1
exit 0The async: true flag is important here. You do not want a Slack webhook call to block Claude's workflow. The notification fires in the background, and Claude continues working.
Pattern 5: Protected Branch Workflow
This pattern prevents Claude from making direct commits or pushes to protected branches, enforcing a PR-based workflow.
#!/bin/bash
# .claude/hooks/protect-branches.sh
# Block direct commits and pushes to protected branches
INPUT=$(cat /dev/stdin)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
CURRENT_BRANCH=$(git branch --show-current 2>/dev/null)
PROTECTED_BRANCHES="main|master|production|staging|release"
# Check if on a protected branch
if ! echo "$CURRENT_BRANCH" | grep -qE "^($PROTECTED_BRANCHES)$"; then
exit 0
fi
# Block direct commits to protected branches
if echo "$COMMAND" | grep -qE '^git\s+commit'; then
jq -n --arg branch "$CURRENT_BRANCH" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("Cannot commit directly to " + $branch + ". Create a feature branch first with: git checkout -b feature/your-feature")
}
}'
exit 0
fi
# Block pushes to protected branches
if echo "$COMMAND" | grep -qE '^git\s+push'; then
jq -n --arg branch "$CURRENT_BRANCH" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("Cannot push directly to " + $branch + ". Use a pull request workflow.")
}
}'
exit 0
fi
exit 0{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-branches.sh"
}
]
}
]
}
}The if filter ensures this hook only runs for git-related commands, not for every Bash call.
Pattern 6: Cost-Conscious Subagent Control
This pattern limits how many subagents Claude can spawn and how long they run, preventing runaway costs.
{
"hooks": {
"SubagentStart": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/track-subagents.sh"
}
]
}
],
"SubagentStop": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/cleanup-subagent-tracking.sh"
}
]
}
]
}
}#!/bin/bash
# .claude/hooks/track-subagents.sh
# Track active subagents and warn if too many are running
INPUT=$(cat /dev/stdin)
AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
TRACKING_DIR="/tmp/claude-subagents-$SESSION_ID"
mkdir -p "$TRACKING_DIR"
# Record this subagent
echo "$(date +%s)" > "$TRACKING_DIR/$AGENT_ID"
# Count active subagents
ACTIVE_COUNT=$(ls "$TRACKING_DIR" 2>/dev/null | wc -l)
if [ "$ACTIVE_COUNT" -gt 5 ]; then
jq -n --arg count "$ACTIVE_COUNT" '{
hookSpecificOutput: {
hookEventName: "SubagentStart",
additionalContext: ("Warning: " + $count + " subagents are now active. Consider reducing parallelism to control costs.")
}
}'
exit 0
fi
exit 0Hooks in Team Settings: Sharing Across Engineers
One of the most powerful aspects of Claude Code hooks is that they can be shared through version control. When you put hooks in .claude/settings.json, every team member gets the same enforcement automatically.
Project Structure for Team Hooks
Here is a recommended directory structure for team-shared hooks:
your-project/
.claude/
settings.json # Hook configuration (committed)
settings.local.json # Personal overrides (gitignored)
hooks/
session-init.sh # SessionStart hook
validate-bash.sh # PreToolUse Bash validator
validate-writes.sh # PreToolUse Write validator
lint-after-write.sh # PostToolUse linter
audit-log.sh # PostToolUse audit logger
protect-branches.sh # PreToolUse branch protectionThe Complete Team Configuration
Here is what a production .claude/settings.json looks like with all the patterns combined:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-init.sh",
"statusMessage": "Initializing environment..."
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/security-gate.sh",
"timeout": 5,
"statusMessage": "Security check..."
},
{
"type": "command",
"if": "Bash(git *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-branches.sh",
"timeout": 5
}
]
},
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/validate-writes.sh",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-file.sh",
"timeout": 15,
"statusMessage": "Formatting..."
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-file.sh",
"timeout": 30,
"statusMessage": "Linting..."
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/audit-log.sh",
"timeout": 5,
"async": true
}
]
}
]
}
}Gitignore Setup
Make sure your .gitignore includes the local settings file but not the shared one:
# .gitignore
.claude/settings.local.json
.claude/logs/The .claude/settings.json and .claude/hooks/ directory should be committed and reviewed like any other code.
Debugging and Troubleshooting Hooks
Hooks run as external processes, which means they can fail silently or produce unexpected behavior. Here are the most common issues and how to fix them.
Use the /hooks Menu
Type /hooks in Claude Code to see all configured hooks. This menu shows every hook event, the hook type, matcher pattern, and source file. It is the fastest way to verify your configuration is loaded correctly.
JSON Validation Errors
The most common hook failure is invalid JSON output. If your shell profile prints messages on startup (like "Welcome to zsh" or conda environment notices), those messages contaminate the JSON output.
Fix: Redirect startup output in your hook scripts:
#!/bin/bash
# Suppress any shell startup output
exec 2>/dev/null
# Your hook logic here
INPUT=$(cat /dev/stdin)
# ...Or better, ensure your hook scripts produce clean output by only writing to stdout when you intentionally return JSON.
Hook Not Running
If a hook is not running, check these things in order:
- Matcher pattern: Does the regex match the actual tool name? Tool names are case-sensitive.
bashwill not matchBash. - File permissions: On Linux/macOS, your hook scripts need execute permission:
chmod +x .claude/hooks/my-hook.sh. ifcondition: If you use theiffield, verify the permission rule syntax matches the actual tool call.- Scope: Is the hook in the right settings file? Project hooks go in
.claude/settings.json, not~/.claude/settings.json.
Testing Hooks Locally
You can test hook scripts outside of Claude Code by piping JSON to stdin:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"},"hook_event_name":"PreToolUse"}' | bash .claude/hooks/security-gate.sh
echo "Exit code: $?"This lets you iterate on your hook logic without waiting for Claude to trigger it.
Large Output Handling
Hook outputs larger than 10,000 characters are truncated. If your hook produces verbose output (like full test suite results), pipe through tail or head to keep the output manageable.
TEST_OUTPUT=$(npm test 2>&1 | tail -30)Disabling Hooks Temporarily
If a hook is causing problems, you can disable all hooks without removing the configuration:
{
"disableAllHooks": true
}Add this to your .claude/settings.local.json to disable hooks locally without affecting the team.
Frequently Asked Questions
What is a Claude Code hook?
A Claude Code hook is a user-defined shell command, HTTP endpoint, or LLM prompt that executes automatically at specific points in Claude Code's lifecycle. Hooks can validate, block, modify, or log every operation Claude performs. They are configured in settings.json and run deterministically outside of Claude's reasoning.
How do I create my first hook?
Create a .claude/settings.json file in your project root with a hook configuration. Start with a simple PostToolUse hook that logs operations:
{
"hooks": {
"PostToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "echo \"Tool used: $(cat /dev/stdin | jq -r .tool_name)\" >> /tmp/claude-log.txt"
}
]
}
]
}
}Can hooks modify what Claude does?
Yes. PreToolUse hooks can modify tool inputs through the updatedInput field. For example, you can change a Bash command, modify a file path, or adjust timeout values before the tool executes. PostToolUse hooks cannot undo operations but can provide feedback that causes Claude to make corrections.
Do hooks work with subagents?
Yes. Hooks fire for all tool calls, including those made by subagents spawned via the Agent tool. Additionally, SubagentStart and SubagentStop hooks let you intercept subagent creation and completion specifically.
What happens if a hook times out?
If a command hook exceeds its timeout value (default 600 seconds), it is killed and treated as a non-blocking error. The original operation proceeds as if the hook had not run. Set explicit timeouts on your hooks to prevent long hangs.
Can hooks access the full conversation history?
Yes. Every hook receives the transcript_path field in its input JSON, which points to the full conversation transcript as a JSONL file. Your hook can read this file to analyze conversation context, though this is an advanced use case.
Do hooks slow down Claude Code?
Synchronous hooks add latency to every matched operation. A hook with a 30-second timeout that runs on every Bash call will be noticeable. Keep hooks fast (under 1-2 seconds for PreToolUse hooks), use async: true for non-blocking side effects, and use specific matchers to avoid running hooks on irrelevant operations.
What is the difference between hooks and CLAUDE.md instructions?
CLAUDE.md instructions are soft guidance that Claude follows through its reasoning. They can be ignored, forgotten during long conversations, or lost during context compaction. Hooks are hard enforcement points that execute deterministically outside of Claude's reasoning. A hook that blocks rm -rf will always block it, regardless of what Claude's context window looks like.
Can I use hooks in headless or CI mode?
Yes. Hooks work with claude -p (non-interactive/piped mode) and in CI environments. The defer permission decision is specifically designed for CI/SDK integrations where a calling process handles permission decisions externally.
How do I share hooks with my team?
Put your hook configuration in .claude/settings.json and your hook scripts in .claude/hooks/. Commit both to version control. Every team member who clones the repo gets the same hooks automatically. Use $CLAUDE_PROJECT_DIR in command paths to keep hooks portable.
Key Takeaways
Claude Code hooks transform Claude from a chat assistant into an enforceable engineering workflow. Here is what you should remember:
-
Hooks are hard rules, not soft suggestions. Unlike prompt instructions, hooks execute deterministically. A PreToolUse hook that blocks a command will always block it.
-
Start with PreToolUse and PostToolUse. These two events cover the most important workflow patterns: validating operations before they happen and enforcing standards after they complete.
-
Use SessionStart for environment setup. Inject context, set environment variables, and detect project configuration so Claude starts every session fully informed.
-
Matchers give you precision. Use regex patterns to target specific tools, MCP servers, or event types. Do not run expensive hooks on every operation.
-
Exit code 2 blocks, exit code 0 allows (or returns JSON). Master this protocol and you can build any validation logic.
-
Share hooks through version control. Put them in
.claude/settings.jsonand.claude/hooks/, commit them, and every team member gets the same enforcement. -
Keep hooks fast. Synchronous hooks add latency. Aim for under 1-2 seconds for PreToolUse hooks, and use
async: truefor side effects. -
Layer your security. Combine hard blocks for dangerous patterns, user confirmation for sensitive operations, auto-approval for safe operations, and normal permissions for everything else.
Claude Code hooks are the mechanism that closes the gap between "AI that writes code" and "AI-powered engineering workflow." The prompts tell Claude what to do. The hooks ensure it is done right.
If you are using Claude Code without hooks, you are leaving enforcement on the table. Start with a single PreToolUse hook that blocks one dangerous pattern. Then add a PostToolUse hook that runs your linter. Then add a SessionStart hook that injects your project context. Build up from there.
The goal is not to restrict Claude. The goal is to make Claude's output reliably match your engineering standards every single time, without anyone having to remember the rules.
Written & published by Chaitanya Prabuddha