Hook Configuration

View Source

⚠️ Stop Hook Loop Prevention

Stop and subagent_stop hooks use blocking?: false by default to prevent infinite loops. When these hooks fail, they provide informational feedback without blocking Claude, preventing situations where Claude gets stuck trying to fix compilation errors repeatedly.

If you need blocking behavior for stop hooks, explicitly set blocking?: true but be aware of the loop risk.

Quick Start

# .claude.exs
%{
  hooks: %{
    stop: [:compile, :format],
    subagent_stop: [:compile, :format],
    post_tool_use: [:compile, :format],
    pre_tool_use: [:compile, :format, :unused_deps]
  }
}

Atom Shortcuts

:compile - Compilation Checking

Runs mix compile --warnings-as-errors and halts pipeline on failure.

# Check compilation after Claude finishes
stop: [:compile]

# Check compilation after sub-agents finish
subagent_stop: [:compile]

# Check compilation after file edits
post_tool_use: [:compile]  # Auto-filters: :write, :edit, :multi_edit

# Check compilation before git commits
pre_tool_use: [:compile]   # Auto-filters: git commit commands

:format - Format Checking

Runs mix format --check-formatted (checks only, doesn't auto-format).

# Check formatting when Claude finishes
stop: [:format]

# Check formatting after sub-agents
subagent_stop: [:format]

# Check specific file after editing
post_tool_use: [:format]  # Uses {{tool_input.file_path}}

# Check formatting before commits
pre_tool_use: [:format]   # Auto-filters: git commit commands

:unused_deps - Dependency Checking

Runs mix deps.unlock --check-unused to detect unused dependencies.

# Check before git commits only
pre_tool_use: [:unused_deps]

Full Expansion Reference

:compile Expansions

EventExpands ToDescription
:stop{"compile --warnings-as-errors", halt_pipeline?: true}Full project compilation
:subagent_stop{"compile --warnings-as-errors", halt_pipeline?: true}Full project compilation
:post_tool_use{"compile --warnings-as-errors", when: [:write, :edit, :multi_edit], halt_pipeline?: true}After file modifications
:pre_tool_use{"compile --warnings-as-errors", when: "Bash", command: ~r/^git commit/, halt_pipeline?: true}Before git commits

:format Expansions

EventExpands ToDescription
:stop"format --check-formatted"Check all files
:subagent_stop"format --check-formatted"Check all files
:post_tool_use{"format --check-formatted {{tool_input.file_path}}", when: [:write, :edit, :multi_edit]}Check modified file
:pre_tool_use{"format --check-formatted", when: "Bash", command: ~r/^git commit/}Check all before commit

:unused_deps Expansions

EventExpands ToDescription
:pre_tool_use{"deps.unlock --check-unused", when: "Bash", command: ~r/^git commit/}Check before commits

Custom Hooks

Basic Formats

%{
  hooks: %{
    # Simple string format
    stop: ["my_custom_task"],
    
    # Tuple with options
    post_tool_use: [
      {"my_task --args", when: [:write, :edit]}
    ],
    
    # Mix atoms with custom
    stop: [
      :compile,
      :format,
      "my_task"
    ]
  }
}

Hook Options

OptionTypeDefaultDescription
:whenatom, [atom], string"*"Tool/event matcher
:commandstring, regex-Command pattern (Bash only)
:halt_pipeline?booleanfalseStop on failure
:blocking?booleantrueTreat as blocking error
:envmap%{}Environment variables
:outputatom:noneOutput verbosity (:none or :full)

Tool Matchers (:when option)

# Match single tool
when: :write

# Match multiple tools  
when: [:write, :edit, :multi_edit]

# Match by tool name
when: "Bash"

# Match Bash with command pattern
when: "Bash(git commit:*)"

# For session_start events
when: :startup    # New session only
when: :resume     # Resumed session only
when: :clear      # Clear session
when: "*"         # Any session

# For pre_compact events
when: "manual"    # Manual /compact command
when: "auto"      # Automatic compaction
when: "*"         # Any compaction

# For user_prompt_submit and notification events
when: "*"         # No specific matchers (always match)

Command Patterns (:command option)

# String prefix matching
command: "git commit"

# Regex pattern
command: ~r/^git (commit|push)/

# Only applies when tool is Bash
{"my_task", when: "Bash", command: ~r/^npm/}

Pipeline Control

# Stop all subsequent hooks if this fails
{"compile", halt_pipeline?: true}

# Continue even if this fails  
{"optional_check", halt_pipeline?: false}

# Non-blocking (informational only)
{"credo --strict", blocking?: false}

Output Control (⚠️ Use Sparingly)

# Default: Only show pipeline summary (prevents context overflow)
{"compile", output: :none}  # Or just "compile" - :none is default

# Full output: Shows complete hook output (WARNING: Can cause context issues)
{"compile", output: :full}  # AVOID - only use for debugging

# Recommended: Always use default :none unless absolutely necessary
stop: [:compile, :format]  # Both use :none by default

Important: The :output option defaults to :none to prevent context overflow. Using :full can cause Claude to run out of context space with long outputs (test failures, compilation errors, etc). Claude can run commands directly when it needs details.

Event Reporting (Experimental)

Note: This feature is experimental and may change in future releases.

Webhook Reporter

Send hook events to external endpoints:

%{
  reporters: [
    {:webhook,
      url: "https://example.com/webhook",
      headers: %{"Authorization" => "Bearer token"},
      timeout: 5000,
      retry_count: 3
    }
  ]
}

Template Variables

Variables replaced at runtime in hook commands (by mix claude.hooks.run):

VariableDescriptionExample Value
{{tool_input.file_path}}File being operated on/lib/my_app/module.ex
{{tool_input.command}}Bash command executedgit commit -m "Update"
{{tool_name}}Name of tool usedWrite, Edit, Bash
{{hook_event_name}}Event that triggeredPostToolUse, Stop

Event Types

pre_tool_use

Runs before tool execution (can block the tool).

pre_tool_use: [
  {"validate_command", when: "Bash"},
  {:compile, when: "Bash", command: ~r/^git commit/}
]

post_tool_use

Runs after tool execution (file edits, etc).

post_tool_use: [
  {:compile, when: [:write, :edit]},
  {:format, when: [:write, :edit]}
]

user_prompt_submit

Runs before processing user prompts (can add context or block).

user_prompt_submit: [
  {"validate_prompt", blocking?: true},
  {"add_context", blocking?: false}
]

notification

Runs when Claude needs permission or input is idle.

notification: [
  {"send_desktop_notification", blocking?: false}
]

stop

Runs when Claude Code finishes responding.

stop: [:compile, :format, "my_final_check"]

subagent_stop

Runs when a sub-agent task completes.

subagent_stop: [:compile, :format]

pre_compact

Runs before context compaction (manual or automatic).

pre_compact: [
  {"save_state", when: "manual"},
  {"cleanup_context", when: "auto"}
]

session_start

Runs when Claude Code starts a new session.

session_start: [
  {"load_env", when: :startup},
  {"restore_state", when: :resume}
]

Common Patterns

Pre-Commit Validation

%{
  hooks: %{
    pre_tool_use: [
      # All checks before git commits
      :compile,
      :format,
      :unused_deps
    ]
  }
}

Immediate Feedback on Edits

%{
  hooks: %{
    post_tool_use: [
      # Fast feedback after editing
      :compile,
      :format
    ]
  }
}

CI-Style Pipeline

%{
  hooks: %{
    stop: [
      # Run all checks, stop on first failure
      {"compile --warnings-as-errors", halt_pipeline?: true},
      {"format --check-formatted", halt_pipeline?: true},
      {"test", halt_pipeline?: true},
      {"credo --strict", halt_pipeline?: true},
      {"dialyzer", halt_pipeline?: true}
    ]
  }
}

Custom Linting

%{
  hooks: %{
    post_tool_use: [
      :format,
      {"credo suggest", when: [:write, :edit], blocking?: false}
    ],
    stop: [
      {"credo --strict", halt_pipeline?: true}
    ]
  }
}

Environment-Specific Hooks

%{
  hooks: %{
    stop: [
      {"test", env: %{"MIX_ENV" => "test"}},
      {"compile", env: %{"MIX_ENV" => "prod"}}
    ]
  }
}

Shell Commands vs Mix Tasks

%{
  hooks: %{
    # Mix tasks (default - no prefix needed)
    stop: [
      "compile",                    # Runs: mix compile
      "test --cover"                # Runs: mix test --cover
    ],
    
    # Shell commands (use "cmd " prefix)
    post_tool_use: [
      {"cmd echo 'File edited'", blocking?: false},
      {"cmd ./scripts/lint.sh", env: %{"MODE" => "strict"}}
    ]
  }
}

Exit Codes

Hooks communicate through exit codes only (no JSON output):

CodeMeaningEffect
0SuccessContinue to next hook
Non-zeroFailureBehavior depends on options

Option Effects:

  • blocking?: true (default) - Converts any non-zero to exit code 2
  • blocking?: false - Preserves original exit code
  • halt_pipeline?: true - Stops all subsequent hooks on non-zero
  • halt_pipeline?: false (default) - Continues despite failures

How Hooks Actually Work (Behind the Scenes)

  1. Single Dispatcher: mix claude.install creates ONE command per event type in .claude/settings.json:

    "PostToolUse": [{
      "matcher": "*",  // Universal matcher - filtering happens later
      "hooks": [{
        "type": "command",
        "command": "cd $CLAUDE_PROJECT_DIR && mix claude.hooks.run post_tool_use"
      }]
    }]
  2. Runtime Resolution: When Claude Code triggers a hook:

    • Runs the dispatcher command with JSON input via stdin
    • Dispatcher reads .claude.exs to get your actual hook configuration
    • Expands atoms to full commands based on event type
    • Filters hooks based on :when matchers
    • Executes matching hooks sequentially
  3. No JSON from Hooks: Hooks communicate via exit codes only, not JSON output

  4. Mix Tasks by Default: Commands run as mix <command> unless prefixed with "cmd "

Debugging

# View your hook configuration
cat .claude.exs

# See what Claude Code will run
cat .claude/settings.json | jq '.hooks'

# Test hook execution manually
echo '{"tool_name":"Write","tool_input":{"file_path":"test.ex"}}' | \
  mix claude.hooks.run post_tool_use

# Run Claude with debug output
claude --debug

Examples by Use Case

Phoenix Project

%{
  hooks: %{
    stop: [:compile, :format],
    post_tool_use: [
      :compile,
      :format,
      {"phx.routes", when: [:write], blocking?: false}
    ],
    pre_tool_use: [:compile, :format, :unused_deps]
  }
}

Library Development

%{
  hooks: %{
    stop: [
      :compile,
      :format,
      "test",
      "docs"
    ],
    post_tool_use: [:compile, :format],
    pre_tool_use: [
      :compile,
      :format,
      {"ex_doc --version", halt_pipeline?: true}
    ]
  }
}

Strict Quality Checks

%{
  hooks: %{
    stop: [
      {"compile --all-warnings", halt_pipeline?: true},
      {"format --check-formatted", halt_pipeline?: true},
      {"test --cover", halt_pipeline?: true},
      {"credo --strict", halt_pipeline?: true},
      {"dialyzer", halt_pipeline?: true},
      {"doctor", halt_pipeline?: true}
    ]
  }
}