Hook Configuration
View SourceQuick 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
Event | Expands To | Description |
---|---|---|
: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
Event | Expands To | Description |
---|---|---|
: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
Event | Expands To | Description |
---|---|---|
: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
Option | Type | Default | Description |
---|---|---|---|
:when | atom, [atom], string | "*" | Tool/event matcher |
:command | string, regex | - | Command pattern (Bash only) |
:halt_pipeline? | boolean | false | Stop on failure |
:blocking? | boolean | true | Treat as blocking error |
:env | map | %{} | 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
):
Variable | Description | Example Value |
---|---|---|
{{tool_input.file_path}} | File being operated on | /lib/my_app/module.ex |
{{tool_input.command}} | Bash command executed | git commit -m "Update" |
{{tool_name}} | Name of tool used | Write , Edit , Bash |
{{hook_event_name}} | Event that triggered | PostToolUse , 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):
Code | Meaning | Effect |
---|---|---|
0 | Success | Continue to next hook |
Non-zero | Failure | Behavior depends on options |
Option Effects:
blocking?: true
(default) - Converts any non-zero to exit code 2blocking?: false
- Preserves original exit codehalt_pipeline?: true
- Stops all subsequent hooks on non-zerohalt_pipeline?: false
(default) - Continues despite failures
How Hooks Actually Work (Behind the Scenes)
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" }] }]
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
No JSON from Hooks: Hooks communicate via exit codes only, not JSON output
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}
]
}
}