Hooks are user-defined shell commands that execute at specific points in Claude Code’s lifecycle. They provide deterministic control: certain actions always happen rather than relying on the LLM to choose.

Hook Events

Ten events available:

EventTriggerCan Block
PreToolUseBefore tool executionYes
PostToolUseAfter tool completesYes
PermissionRequestPermission dialog shownYes
UserPromptSubmitUser submits promptYes
StopMain agent finishesYes
SubagentStopSubagent finishesYes
NotificationNotifications sentNo
PreCompactBefore compactingNo
SessionStartSession beginsNo
SessionEndSession endsNo

Best practice: PreToolUse for policy guards, PostToolUse for cleanup and feedback.

Configuration

Hooks live in settings files:

  • ~/.claude/settings.json (user, applies everywhere)
  • .claude/settings.json (project, shared via git)
  • .claude/settings.local.json (local, gitignored)

Basic structure:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "your-script.sh",
            "timeout": 60
          }
        ]
      }
    ]
  }
}

Use /hooks command for interactive configuration.

Matchers

Filter which tools trigger hooks:

PatternMatches
WriteExact match only
Edit|WriteEither Edit or Write
Notebook.*Regex pattern
* or ""All tools
mcp__memory__.*MCP server tools

Matchers are case-sensitive. bash won’t match Bash.

Exit Codes

CodeBehavior
0Success, continue execution
2Block tool execution (PreToolUse/PermissionRequest only)
OtherNon-blocking error, logged

Input Data (stdin)

Hooks receive JSON via stdin:

{
  "session_id": "abc123",
  "cwd": "/project/path",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test",
    "description": "Run tests"
  },
  "tool_response": {}
}

Extract with jq:

jq -r '.tool_input.command'
jq -r '.tool_input.file_path'

Environment Variables

Available in all hooks:

  • CLAUDE_PROJECT_DIR: Absolute project root path
  • CLAUDE_CODE_REMOTE: "true" for web, empty for CLI

SessionStart only:

  • CLAUDE_ENV_FILE: Path to persist environment variables

Output Control (JSON)

Return JSON to stdout for advanced control:

{
  "continue": true,
  "stopReason": "Message when continue=false",
  "suppressOutput": false,
  "systemMessage": "Warning shown to user",
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "additionalContext": "Context for Claude"
  }
}

PreToolUse Decision Control

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "permissionDecisionReason": "Auto-approved by policy",
    "updatedInput": {
      "command": "modified-command"
    }
  }
}

Permission decisions:

  • allow: Auto-approve, bypass permission system
  • deny: Block tool call
  • ask: Request user confirmation

Stop Hook Control

Used for Ralph Wiggum loops:

{
  "decision": "block",
  "reason": "Tasks incomplete, continue working"
}

Practical Examples

Block Dangerous Commands

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "if [[ \"$CLAUDE_TOOL_INPUT\" == *\"rm -rf\"* ]]; then echo 'Blocked!' >&2 && exit 2; fi"
      }]
    }]
  }
}

Auto-Format TypeScript

{
  "hooks": {
    "PostToolUse": [{
      "matcher": "Edit|Write",
      "hooks": [{
        "type": "command",
        "command": "jq -r '.tool_input.file_path' | { read f; [[ \"$f\" == *.ts ]] && npx prettier --write \"$f\"; }"
      }]
    }]
  }
}

Protect Sensitive Files

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Edit|Write",
      "hooks": [{
        "type": "command",
        "command": "python3 -c \"import json,sys; d=json.load(sys.stdin); p=d.get('tool_input',{}).get('file_path',''); sys.exit(2 if any(x in p for x in ['.env','package-lock.json','.git/']) else 0)\""
      }]
    }]
  }
}

Log All Bash Commands

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "jq -r '\"\\(.tool_input.command) - \\(.tool_input.description // \"No desc\")\"' >> ~/.claude/bash-log.txt"
      }]
    }]
  }
}

Desktop Notifications

{
  "hooks": {
    "Notification": [{
      "matcher": "",
      "hooks": [{
        "type": "command",
        "command": "notify-send 'Claude Code' 'Awaiting input'"
      }]
    }]
  }
}

Persist Environment Variables (SessionStart)

#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
  echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE"
fi
exit 0

Modify Tool Inputs

PreToolUse hooks can modify inputs before execution:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "updatedInput": {
      "command": "npm run lint -- --fix"
    }
  }
}

Prompt-Based Hooks

For Stop/SubagentStop, use LLM evaluation:

{
  "hooks": {
    "Stop": [{
      "hooks": [{
        "type": "prompt",
        "prompt": "Check if all tasks complete. Return {\"ok\": true} or {\"ok\": false, \"reason\": \"why\"}",
        "timeout": 30
      }]
    }]
  }
}

This powers the Ralph Wiggum Loop technique for autonomous loops.

Security Considerations

Hooks run with your terminal’s permissions. Risks:

  • Malicious hooks can exfiltrate data
  • User settings apply to all projects

Best practices:

  • Review hook implementations before adding
  • Validate and quote all variables: "$VAR" not $VAR
  • Avoid touching .env, .git/, secrets

Debugging

claude --debug  # Detailed hook execution logs
/hooks          # Interactive configuration

Sources