Claude Code Hooks Guide
View SourceVersion: 0.3.0 Status: ✅ Implemented
Table of Contents
Overview
Claude Code Hooks are callback functions that execute at specific lifecycle events during Claude's agent loop. Unlike tools (which Claude invokes), hooks are invoked by the Claude Code CLI itself, enabling:
- Security & Validation: Block dangerous operations before they execute
- Context Injection: Automatically add relevant information
- Audit & Logging: Track all tool usage comprehensively
- Policy Enforcement: Implement organizational rules
- Monitoring: Observe agent behavior in real-time
Key Characteristics
- Synchronous execution: Hooks block the agent loop until complete
- Bidirectional control: Can approve, deny, or modify behavior
- Pattern-based matching: Target specific tools or all tools
- Not visible to Claude: Infrastructure-level callbacks
- Timeouts: 60-second default per matcher (
timeout_ms, minimum 1s)
Quick Start
Installation
Hooks are included in claude_agent_sdk v0.3.0+:
def deps do
[
{:claude_agent_sdk, "~> 0.3.0"}
]
endBasic Example
alias ClaudeAgentSDK.{Client, Options}
alias ClaudeAgentSDK.Hooks.{Matcher, Output}
# Define a hook callback
def check_bash_command(input, _tool_use_id, _context) do
case input do
%{"tool_name" => "Bash", "tool_input" => %{"command" => cmd}} ->
if String.contains?(cmd, "rm -rf") do
Output.deny("Dangerous command blocked")
else
Output.allow()
end
_ -> %{}
end
end
# Configure hooks
options = %Options{
allowed_tools: ["Bash"],
hooks: %{
pre_tool_use: [
Matcher.new("Bash", [&check_bash_command/3])
]
}
}
# Start client with hooks
{:ok, client} = Client.start_link(options)
# Send message
Client.send_message(client, "Run: rm -rf /tmp/data")
# Receive messages (hook will block the dangerous command!)
Client.stream_messages(client)
|> Enum.each(&IO.inspect/1)
# Stop client
Client.stop(client)Each matcher can set timeout_ms when you need a different execution window; the default is 60_000 ms with a 1-second floor and is shared with the CLI during initialization.
Hook Events
PreToolUse
When: Before a tool executes
Use Cases: Security validation, input transformation, auto-approval
Special Output: Permission decisions (allow, deny, ask)
def pre_tool_use_hook(input, tool_use_id, context) do
tool_name = input["tool_name"]
tool_input = input["tool_input"]
# Validate and return decision
Output.allow("Validation passed")
# or
Output.deny("Validation failed")
# or
Output.ask("User confirmation needed")
endInput Fields:
tool_name- Tool being invoked (e.g., "Bash", "Write")tool_input- Tool parameterssession_id- Current session IDtranscript_path- Path to conversation logcwd- Current working directory
PostToolUse
When: After a tool completes successfully Use Cases: Result validation, context injection, monitoring Special Output: Additional context for Claude
def post_tool_use_hook(input, tool_use_id, context) do
tool_name = input["tool_name"]
tool_response = input["tool_response"]
# Add context about execution
Output.add_context("PostToolUse", "Execution took 2.3s")
endInput Fields:
tool_name- Tool that executedtool_input- Original tool parameterstool_response- Tool execution result- (plus common fields)
UserPromptSubmit
When: When user submits a prompt Use Cases: Context injection, prompt validation Special Output: Additional context, prompt blocking
def user_prompt_submit_hook(input, _tool_use_id, context) do
prompt = input["prompt"]
# Add current project context
context_text = """
Current time: #{DateTime.utc_now()}
Git branch: #{get_current_branch()}
"""
Output.add_context("UserPromptSubmit", context_text)
endInput Fields:
prompt- User's submitted prompt text- (plus common fields)
Stop
When: When the agent finishes responding Use Cases: Session summary, force continuation Special Output: Block stop, continue execution
def stop_hook(input, _tool_use_id, context) do
stop_hook_active = input["stop_hook_active"]
# Force continuation for multi-step workflow
if should_continue?(input) and not stop_hook_active do
Output.block("Continue to next step")
else
%{}
end
endInput Fields:
stop_hook_active- Whether stop hook already triggered (prevent infinite loops)- (plus common fields)
SubagentStop
When: When a subagent (Task tool) finishes Use Cases: Subagent result validation, chaining Special Output: Block stop, continue execution
Similar to Stop hook but for subagents.
PreCompact
When: Before context compaction (auto or manual) Use Cases: Save state, log compaction events Special Output: None (informational only)
def pre_compact_hook(input, _tool_use_id, context) do
trigger = input["trigger"] # "auto" or "manual"
custom_instructions = input["custom_instructions"]
# Log compaction event
Logger.info("Compaction triggered: #{trigger}")
%{}
endInput Fields:
trigger- "auto" or "manual"custom_instructions- User-provided instructions (for manual compact)- (plus common fields)
Hook Output
Hooks return maps with control fields. Use the Output module helpers for type-safe construction.
Permission Decisions (PreToolUse)
# Allow with reason
Output.allow("Security check passed")
# Deny with reason
Output.deny("Command blocked by policy")
# Ask user for confirmation
Output.ask("Confirm deletion of 100 files")Generated output:
%{
hookSpecificOutput: %{
hookEventName: "PreToolUse",
permissionDecision: "allow", # or "deny", "ask"
permissionDecisionReason: "Security check passed"
}
}Add Context (PostToolUse, UserPromptSubmit)
Output.add_context("PostToolUse", "Command took 2.3 seconds")Generated output:
%{
hookSpecificOutput: %{
hookEventName: "PostToolUse",
additionalContext: "Command took 2.3 seconds"
}
}Stop Execution
Output.stop("Critical error detected")Generated output:
%{
continue: false,
stopReason: "Critical error detected"
}Block with Feedback (Stop, SubagentStop, PostToolUse)
Output.block("Must complete verification step")Generated output:
%{
decision: "block",
reason: "Must complete verification step"
}Helper Combinators
# Combine multiple fields
Output.deny("Invalid path")
|> Output.with_system_message("🔒 Access denied")
|> Output.with_reason("Path outside allowed directory")
|> Output.suppress_output()Generated output:
%{
hookSpecificOutput: %{
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Invalid path"
},
systemMessage: "🔒 Access denied",
reason: "Path outside allowed directory",
suppressOutput: true
}Output Fields Reference
| Field | Type | Description | Visible To |
|---|---|---|---|
continue | boolean | Whether to continue execution | CLI |
stopReason | string | Message when stopping | User |
systemMessage | string | User-visible message | User |
reason | string | Claude-visible feedback | Claude |
suppressOutput | boolean | Hide from transcript | CLI |
decision | "block" | Block with feedback | Claude |
hookSpecificOutput | map | Event-specific control | CLI + Claude |
Examples
Example 1: Security - Block Dangerous Commands
defmodule SecurityHooks do
def check_bash_command(input, _tool_use_id, _context) do
case input do
%{"tool_name" => "Bash", "tool_input" => %{"command" => cmd}} ->
dangerous = ["rm -rf", "dd if=", "mkfs", "> /dev/"]
if Enum.any?(dangerous, &String.contains?(cmd, &1)) do
Output.deny("Dangerous command blocked: #{cmd}")
|> Output.with_system_message("🔒 Security policy violation")
else
Output.allow()
end
_ -> %{}
end
end
end
# Use
hooks = %{
pre_tool_use: [
Matcher.new("Bash", [&SecurityHooks.check_bash_command/3])
]
}Example 2: File Access Policy
defmodule FilePolicyHooks do
@allowed_dir "/tmp/sandbox"
@forbidden_files [".env", "secrets.yml"]
def enforce_file_policy(input, _tool_use_id, _context) do
case input do
%{"tool_name" => tool, "tool_input" => %{"file_path" => path}}
when tool in ["Write", "Edit"] ->
cond do
Enum.any?(@forbidden_files, &String.ends_with?(path, &1)) ->
Output.deny("Cannot modify #{Path.basename(path)}")
not String.starts_with?(path, @allowed_dir) ->
Output.deny("Can only modify files in #{@allowed_dir}")
true ->
Output.allow()
end
_ -> %{}
end
end
endExample 3: Automatic Context Injection
defmodule ContextHooks do
def add_project_context(_input, _tool_use_id, _context) do
context_text = """
Current branch: #{get_git_branch()}
Recent issues: #{get_recent_issues()}
Last deploy: #{get_last_deploy()}
"""
Output.add_context("UserPromptSubmit", context_text)
end
defp get_git_branch do
{result, 0} = System.cmd("git", ["branch", "--show-current"])
String.trim(result)
end
endExample 4: Comprehensive Audit Logging
defmodule AuditHooks do
require Logger
def log_tool_invocation(input, tool_use_id, _context) do
Logger.info("Tool invoked",
tool: input["tool_name"],
tool_use_id: tool_use_id,
session: input["session_id"]
)
write_audit_log(%{
event: "tool_invocation",
tool_name: input["tool_name"],
tool_use_id: tool_use_id,
timestamp: DateTime.utc_now()
})
%{} # Don't modify behavior
end
def log_tool_result(input, tool_use_id, _context) do
success = not get_in(input, ["tool_response", "is_error"])
Logger.info("Tool completed",
tool: input["tool_name"],
tool_use_id: tool_use_id,
success: success
)
%{}
end
end
# Use
hooks = %{
pre_tool_use: [
Matcher.new("*", [&AuditHooks.log_tool_invocation/3])
],
post_tool_use: [
Matcher.new("*", [&AuditHooks.log_tool_result/3])
]
}Best Practices
1. Idempotent Hooks
Hooks may be called multiple times. Design them to be idempotent:
# ❌ Bad - accumulates on retry
def bad_hook(input, _tool_use_id, _context) do
:ets.insert(:counters, {:calls, get_count() + 1})
Output.allow()
end
# ✅ Good - idempotent
def good_hook(input, _tool_use_id, _context) do
if valid?(input) do
Output.allow()
else
Output.deny("Invalid input")
end
end2. Fast Execution
Hooks block the agent loop. Keep them fast (< 100ms ideal):
# ❌ Bad - slow external call
def slow_hook(input, _tool_use_id, _context) do
# This blocks for seconds
result = HTTPoison.get!("https://api.example.com/validate")
if result.status_code == 200, do: Output.allow(), else: Output.deny("Failed")
end
# ✅ Good - quick check
def fast_hook(input, _tool_use_id, _context) do
# Fast local validation
if valid_format?(input["tool_input"]) do
Output.allow()
else
Output.deny("Invalid format")
end
end
# ✅ Acceptable - async background logging
def async_logging_hook(input, tool_use_id, _context) do
# Fire and forget
Task.start(fn -> log_to_system(input, tool_use_id) end)
%{} # Return immediately
end3. Error Handling
Handle errors gracefully to avoid breaking the agent loop:
# ✅ Good - handles errors
def safe_hook(input, tool_use_id, _context) do
try do
# Hook logic
validate_and_decide(input)
rescue
e ->
Logger.error("Hook error: #{Exception.message(e)}")
# Fail-open or fail-closed based on policy
Output.allow("Hook error - defaulting to allow")
end
end4. Clear Feedback
Provide helpful messages for both users and Claude:
# ❌ Bad - vague
Output.deny("No")
# ✅ Good - specific and actionable
Output.deny("Cannot delete production database")
|> Output.with_system_message("🚫 Production safety check failed")
|> Output.with_reason("""
You attempted to delete the production database. This is blocked by policy.
If you need to delete data, please use the staging environment instead.
""")5. Matcher Patterns
Use specific matchers for better performance:
# ❌ Inefficient - checks every tool
hooks = %{
pre_tool_use: [
Matcher.new("*", [&check_only_bash/3])
]
}
# ✅ Efficient - targeted matcher
hooks = %{
pre_tool_use: [
Matcher.new("Bash", [&check_bash/3])
]
}
# ✅ Multiple tools
hooks = %{
pre_tool_use: [
Matcher.new("Write|Edit|MultiEdit", [&check_file_ops/3])
]
}6. Prevent Infinite Loops
Check stop_hook_active in Stop/SubagentStop hooks:
def stop_hook(input, _tool_use_id, _context) do
# ✅ Good - prevents infinite continuation
if input["stop_hook_active"] do
%{} # Already continuing, allow stop
else
if needs_more_work?(input) do
Output.block("Continue to complete workflow")
else
%{}
end
end
endAPI Reference
ClaudeAgentSDK.Hooks
Type definitions and utilities.
Functions:
event_to_string/1- Convert atom to CLI stringstring_to_event/1- Convert CLI string to atomall_valid_events/0- List all valid eventsvalidate_config/1- Validate hook configuration
Types:
hook_event()- Event atom (:pre_tool_use, etc.)hook_input()- Input map passed to callbackshook_context()- Context map with abort signalhook_callback()- Callback function typehook_config()- Configuration map type
ClaudeAgentSDK.Hooks.Matcher
Hook matcher for pattern-based filtering.
Functions:
new/3- Create new matcher (timeout_msopt, default 60s, min 1s)to_cli_format/2- Convert to CLI JSON format
Fields:
matcher- Tool pattern (nil, "*", "Tool", "Tool1|Tool2")hooks- List of callback functionstimeout_ms- Optional timeout (ms) sent during initialize
ClaudeAgentSDK.Hooks.Output
Hook output helpers.
Permission Decisions:
allow/1- Allow PreToolUsedeny/1- Deny PreToolUseask/1- Ask user for confirmation
Context Injection:
add_context/2- Add context for Claude
Execution Control:
stop/1- Stop execution with reasonblock/1- Block with feedbackcontinue/0- Continue execution
Combinators:
with_system_message/2- Add user messagewith_reason/2- Add Claude feedbacksuppress_output/1- Hide from transcript
Utilities:
validate/1- Validate output structureto_json_map/1- Convert to JSON-compatible map
ClaudeAgentSDK.Hooks.Registry
Internal registry for callback management.
Functions:
new/0- Create empty registryregister/2- Register callback, get IDget_callback/2- Look up callback by IDget_id/2- Look up ID by callbackall_callbacks/1- Get all registered callbackscount/1- Count registered callbacks
Limitations
Not Supported in SDK Mode
The following hooks are not available when using the SDK (limitation of Claude CLI):
SessionStart- Use initialization logic in your application insteadSessionEnd- Use cleanup logic in your application insteadNotification- Not applicable in SDK mode
These hooks only work in interactive CLI mode.
Timeout
Hooks default to a 60-second timeout (minimum 1 second). Override it per matcher with timeout_ms; the sanitized value is sent to the CLI as "timeout" during initialize and also bounds the Task.yield/2 window for that matcher:
hooks = %{
pre_tool_use: [
# Allow up to 1.5s for pre-tool validation; defaults remain 60s elsewhere
Matcher.new("Bash", [&MyHooks.check_bash/3], timeout_ms: 1_500)
],
user_prompt_submit: [
# Slightly longer budget for gathering context
Matcher.new(nil, [&MyHooks.add_context/3], timeout_ms: 3_000)
]
}If your hook still takes too long:
- Optimize the hook logic
- Move slow operations to background tasks
- Return immediately and process asynchronously
Debugging
Enable Debug Logging
# In config/dev.exs
config :logger, level: :debug
# In your hook
def debug_hook(input, tool_use_id, context) do
require Logger
Logger.debug("Hook called",
input: input,
tool_use_id: tool_use_id
)
result = your_logic(input)
Logger.debug("Hook result", result: result)
result
endTest Hooks Independently
# Test hook without Client
defmodule HookTest do
use ExUnit.Case
test "check_bash_command blocks dangerous commands" do
input = %{
"hook_event_name" => "PreToolUse",
"tool_name" => "Bash",
"tool_input" => %{"command" => "rm -rf /"}
}
result = MyHooks.check_bash_command(input, "test_id", %{})
assert result.hookSpecificOutput.permissionDecision == "deny"
end
endMigration from Claude CLI Hooks
If you're using shell script hooks in settings.json, you can migrate to SDK hooks:
Before (settings.json)
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/path/to/check-bash.sh"
}
]
}
]
}
}After (Elixir SDK)
defmodule MyHooks do
def check_bash(input, _tool_use_id, _context) do
# Same logic as check-bash.sh but in Elixir
# ...
end
end
options = %Options{
hooks: %{
pre_tool_use: [
Matcher.new("Bash", [&MyHooks.check_bash/3])
]
}
}Benefits of SDK Hooks:
- No subprocess overhead
- Type safety
- Easier debugging
- Access to full Elixir ecosystem
- Better error handling
Further Reading
- Claude Code Hooks Reference
- Design Document:
docs/design/hooks_implementation.md(available in source repository) - Examples: See
examples/hooks/directory in the source repository - Test Suite: See
test/claude_agent_sdk/hooks/directory in the source repository
Questions or Issues?
- GitHub Issues: https://github.com/nshkrdotcom/claude_agent_sdk/issues
- Documentation: https://hexdocs.pm/claude_agent_sdk