Hook Configuration

View Source

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

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}

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}
    ]
  }
}