# Hooks Guide
Hooks are callback functions that allow you to intercept and control Claude Agent SDK execution at specific lifecycle events. They provide a powerful mechanism for implementing security policies, audit logging, context injection, and custom execution logic.
## Table of Contents
1. [What Are Hooks and Why Use Them](#what-are-hooks-and-why-use-them)
2. [Hook Events](#hook-events)
3. [Matcher Configuration](#matcher-configuration)
4. [Hook Callback Signature and Parameters](#hook-callback-signature-and-parameters)
5. [Output Module](#output-module)
6. [Security Hook Examples](#security-hook-examples)
7. [Audit Logging Examples](#audit-logging-examples)
8. [Context Injection Examples](#context-injection-examples)
9. [Combining Hooks with Streaming](#combining-hooks-with-streaming)
10. [Best Practices](#best-practices)
---
## What Are Hooks and Why Use Them
Hooks are functions invoked by the Claude Code CLI at specific moments during agent execution. They enable you to:
- **Intercept tool calls** before and after execution
- **Implement security policies** to block dangerous operations
- **Add contextual information** automatically to conversations
- **Create audit trails** of all agent activity
- **Control execution flow** based on runtime conditions
- **Monitor and observe** agent behavior
### Basic Hook Configuration
```elixir
alias ClaudeAgentSDK.{Client, Options}
alias ClaudeAgentSDK.Hooks.{Matcher, Output}
# Define a hook callback
def my_hook(input, tool_use_id, context) do
IO.puts("Tool #{input["tool_name"]} called")
Output.allow()
end
# Configure hooks in options
options = %Options{
hooks: %{
pre_tool_use: [
Matcher.new("Bash", [&my_hook/3])
]
},
model: "haiku",
max_turns: 5
}
# Start client with hooks
{:ok, client} = Client.start_link(options)
```
When hooks are configured, the SDK automatically uses the control protocol transport which enables bidirectional communication with the CLI for hook callbacks.
---
## Hook Events
The SDK supports all 12 hook event types from the Python SDK, each triggered at specific moments:
| Event | Description | Common Use Cases |
|-------|-------------|------------------|
| `:pre_tool_use` | Before a tool executes | Security validation, permission checks |
| `:post_tool_use` | After a tool executes | Audit logging, result processing |
| `:post_tool_use_failure` | After a tool execution fails | Error tracking, retry logic |
| `:user_prompt_submit` | When user submits a prompt | Context injection, preprocessing |
| `:stop` | When the agent finishes | Finalization, reporting |
| `:subagent_start` | When a subagent is spawned | Subagent tracking, resource limits |
| `:subagent_stop` | When a subagent finishes | Subagent result processing |
| `:pre_compact` | Before context compaction | Preserve important context |
| `:notification` | CLI notification received | Alert routing, logging |
| `:permission_request` | Permission dialog triggered | Programmatic permission responses |
| `:session_start` | Session begins | Initialization, context setup |
| `:session_end` | Session ends | Cleanup, final reporting |
### Event Configuration Examples
```elixir
alias ClaudeAgentSDK.Hooks.{Matcher, Output}
hooks = %{
# Security check before tool execution
pre_tool_use: [
Matcher.new("Bash", [&check_bash_command/3]),
Matcher.new("Write|Edit", [&check_file_access/3])
],
# Audit logging after tool execution
post_tool_use: [
Matcher.new("*", [&log_tool_result/3])
],
# Context injection on prompt submit
user_prompt_submit: [
Matcher.new(nil, [&add_project_context/3])
]
}
```
### Converting Between Event Formats
The SDK provides utilities for converting between Elixir atoms and CLI string formats:
```elixir
alias ClaudeAgentSDK.Hooks
# Atom to string
Hooks.event_to_string(:pre_tool_use) # => "PreToolUse"
Hooks.event_to_string(:post_tool_use) # => "PostToolUse"
Hooks.event_to_string(:user_prompt_submit) # => "UserPromptSubmit"
# String to atom
Hooks.string_to_event("PreToolUse") # => :pre_tool_use
Hooks.string_to_event("PostToolUse") # => :post_tool_use
# Get all valid events
Hooks.all_valid_events()
# => [:pre_tool_use, :post_tool_use, :post_tool_use_failure, :user_prompt_submit,
# :stop, :subagent_start, :subagent_stop, :pre_compact, :notification,
# :permission_request, :session_start, :session_end]
```
---
## Matcher Configuration
Matchers determine which hooks run for which tools. The `Matcher` module supports several matching patterns:
### Exact Tool Matching
Match a specific tool by name:
```elixir
alias ClaudeAgentSDK.Hooks.Matcher
# Only matches the Bash tool
Matcher.new("Bash", [&check_bash/3])
# Only matches the Write tool
Matcher.new("Write", [&check_write/3])
```
### Regex Pattern Matching
Match multiple tools using regex patterns:
```elixir
# Match Write OR Edit tools
Matcher.new("Write|Edit", [&check_file_modification/3])
# Match Read OR Glob OR Grep tools (file reading operations)
Matcher.new("Read|Glob|Grep", [&audit_file_access/3])
```
### Wildcard Matching
Match all tools using `"*"` or `nil`:
```elixir
# Match all tools (both syntaxes are equivalent)
Matcher.new("*", [&log_all_tools/3])
Matcher.new(nil, [&log_all_tools/3])
```
### Multiple Callbacks Per Matcher
A single matcher can invoke multiple callbacks in sequence:
```elixir
# Both callbacks run for every Bash command
Matcher.new("Bash", [
&security_check/3, # First: security validation
&audit_log/3 # Second: logging
])
```
### Matcher Timeout Configuration
Set a per-matcher timeout for callback execution:
```elixir
# Timeout after 5 seconds (minimum is 1000ms; sent to CLI as seconds)
Matcher.new("Bash", [&slow_check/3], timeout_ms: 5000)
# Timeout after 30 seconds for expensive operations
Matcher.new("*", [&expensive_analysis/3], timeout_ms: 30_000)
```
The SDK converts `timeout_ms` to seconds in the control initialization payload to match the CLI's expected units.
### Complete Matcher Examples
```elixir
alias ClaudeAgentSDK.Hooks.Matcher
hooks = %{
pre_tool_use: [
# High-priority security check on Bash with short timeout
Matcher.new("Bash", [&block_dangerous_commands/3], timeout_ms: 2000),
# File operation validation
Matcher.new("Write|Edit", [&validate_file_path/3, &check_file_size/3]),
# General logging for all tools
Matcher.new("*", [&log_tool_invocation/3])
],
post_tool_use: [
# Audit all tool completions
Matcher.new("*", [&audit_tool_completion/3])
]
}
```
---
## Hook Callback Signature and Parameters
Every hook callback must follow this signature:
```elixir
@spec callback(input :: map(), tool_use_id :: String.t() | nil, context :: map()) :: Output.t()
```
### Parameters
#### `input` - Hook Input Data
The input map varies by event type but always includes:
```elixir
%{
"hook_event_name" => "PreToolUse", # String event name
"session_id" => "abc123", # Session identifier
"transcript_path" => "/path/to/...", # Path to conversation transcript
"cwd" => "/current/working/dir" # Current working directory
}
```
**Event-specific fields:**
| Event | Additional Fields |
|-------|-------------------|
| `:pre_tool_use` | `tool_name`, `tool_input` |
| `:post_tool_use` | `tool_name`, `tool_input`, `tool_response` |
| `:post_tool_use_failure` | `tool_name`, `tool_input`, `error` |
| `:user_prompt_submit` | `prompt` |
| `:stop` | `stop_hook_active`, `reason` |
| `:subagent_start` | `agent_id`, `agent_type`, `agent_transcript_path` |
| `:subagent_stop` | `stop_hook_active`, `agent_id`, `agent_type` |
| `:pre_compact` | `trigger`, `custom_instructions` |
| `:notification` | `message`, `title`, `notification_type`, `is_interrupt` |
| `:permission_request` | `tool_name`, `tool_input`, `permission_suggestions`, `permission_mode` |
| `:session_start` | `source` |
| `:session_end` | `reason` |
#### `tool_use_id` - Tool Invocation ID
For tool-related hooks (`:pre_tool_use`, `:post_tool_use`), this is a unique identifier for the specific tool invocation. For other events, it may be `nil`.
#### `context` - Execution Context
Contains runtime context information:
```elixir
%{
signal: %AbortSignal{} # Optional abort signal for cooperative cancellation
}
```
### Example Callback Implementations
```elixir
defmodule MyHooks do
alias ClaudeAgentSDK.Hooks.Output
# PreToolUse: Check tool before execution
def check_bash(input, tool_use_id, _context) do
case input do
%{"tool_name" => "Bash", "tool_input" => %{"command" => command}} ->
IO.puts("Checking command: #{command}")
IO.puts("Tool use ID: #{tool_use_id}")
if String.contains?(command, "rm -rf") do
Output.deny("Dangerous command blocked")
else
Output.allow()
end
_ ->
# Not a Bash command, pass through
%{}
end
end
# PostToolUse: Log results after execution
def log_result(input, tool_use_id, _context) do
tool_name = input["tool_name"]
response = input["tool_response"]
is_error = get_in(response, ["is_error"]) || false
status = if is_error, do: "FAILED", else: "SUCCESS"
IO.puts("[AUDIT] #{tool_name} (#{tool_use_id}): #{status}")
# Don't modify behavior, just log
%{}
end
# UserPromptSubmit: Add context before processing
def add_context(_input, _tool_use_id, _context) do
context = """
Current time: #{DateTime.utc_now()}
Environment: #{System.get_env("MIX_ENV", "dev")}
"""
Output.add_context("UserPromptSubmit", context)
end
end
```
---
## Output Module
The `ClaudeAgentSDK.Hooks.Output` module provides helper functions for constructing hook responses.
### Permission Decisions (PreToolUse)
#### `Output.allow/0` and `Output.allow/1`
Allow the tool to execute:
```elixir
alias ClaudeAgentSDK.Hooks.Output
# Simple allow
Output.allow()
# Allow with reason
Output.allow("Security check passed")
# Returns:
# %{
# hookSpecificOutput: %{
# hookEventName: "PreToolUse",
# permissionDecision: "allow",
# permissionDecisionReason: "Security check passed"
# }
# }
```
#### `Output.deny/1`
Block the tool from executing:
```elixir
Output.deny("Dangerous command detected")
# Returns:
# %{
# hookSpecificOutput: %{
# hookEventName: "PreToolUse",
# permissionDecision: "deny",
# permissionDecisionReason: "Dangerous command detected"
# }
# }
```
#### `Output.ask/1`
Prompt the user for confirmation:
```elixir
Output.ask("This will delete 100 files. Continue?")
# Returns:
# %{
# hookSpecificOutput: %{
# hookEventName: "PreToolUse",
# permissionDecision: "ask",
# permissionDecisionReason: "This will delete 100 files. Continue?"
# }
# }
```
### Context Injection
#### `Output.add_context/2`
Add contextual information for Claude:
```elixir
# For UserPromptSubmit
Output.add_context("UserPromptSubmit", "Current user: admin")
# For PostToolUse
Output.add_context("PostToolUse", "Command completed in 2.3 seconds")
# For SessionStart
Output.add_context("SessionStart", "Active issues: #123, #456")
```
### Execution Control
#### `Output.stop/1`
Stop agent execution entirely:
```elixir
Output.stop("Critical error: resource limit exceeded")
# Returns:
# %{continue: false, stopReason: "Critical error: resource limit exceeded"}
```
#### `Output.continue/0`
Explicitly continue execution:
```elixir
Output.continue()
# Returns:
# %{continue: true}
```
#### `Output.block/1`
Block with feedback to Claude:
```elixir
Output.block("Tool execution failed validation")
# Returns:
# %{decision: "block", reason: "Tool execution failed validation"}
```
### Output Modifiers
Chain modifiers to add additional information:
#### `Output.with_system_message/2`
Add a user-visible message (not shown to Claude):
```elixir
Output.deny("Command blocked")
|> Output.with_system_message("Security policy violation detected")
```
#### `Output.with_reason/2`
Add a Claude-visible explanation:
```elixir
Output.deny("Path not allowed")
|> Output.with_reason("File path must be within /allowed directory")
```
#### `Output.suppress_output/1`
Hide the output from the transcript:
```elixir
Output.allow()
|> Output.suppress_output()
```
#### `Output.with_additional_context/2`
Add additional context to hook-specific output:
```elixir
Output.allow("Approved")
|> Output.with_additional_context("Command took 2.3 seconds")
```
#### `Output.with_updated_mcp_output/2`
Set updated MCP tool output in PostToolUse hooks:
```elixir
Output.continue()
|> Output.with_updated_mcp_output(%{"content" => [%{"type" => "text", "text" => "filtered"}]})
```
#### `Output.permission_decision/1`
Create a PermissionRequest hook output with a permission decision. Accepts a `Permission.Result` struct or a raw decision map:
```elixir
alias ClaudeAgentSDK.Permission.Result
# From a Permission.Result struct
Output.permission_decision(Result.allow())
Output.permission_decision(Result.deny("Not allowed"))
# Raw map passthrough
Output.permission_decision(%{"type" => "allow"})
```
#### `Output.permission_allow/0` and `Output.permission_deny/1`
Shorthand helpers for PermissionRequest hooks:
```elixir
# Allow the tool
Output.permission_allow()
# Deny with reason
Output.permission_deny("Tool not permitted in this context")
```
#### `Output.async/1`
Mark the hook output as asynchronous (Python SDK parity):
```elixir
Output.allow()
|> Output.async()
# Returns:
# %{
# hookSpecificOutput: %{...},
# async: true
# }
```
#### `Output.with_async_timeout/2`
Set a timeout for asynchronous hook processing:
```elixir
Output.allow()
|> Output.async()
|> Output.with_async_timeout(5000)
# Returns:
# %{
# hookSpecificOutput: %{...},
# async: true,
# asyncTimeout: 5000
# }
```
### Complete Output Examples
```elixir
alias ClaudeAgentSDK.Hooks.Output
# Security denial with full context
Output.deny("Dangerous command blocked")
|> Output.with_system_message("Security policy violation")
|> Output.with_reason("Command matches blocked pattern: rm -rf")
# Allow with logging suppression
Output.allow("Approved by policy")
|> Output.suppress_output()
# Context injection with system message
Output.add_context("UserPromptSubmit", "Project: my-app, Branch: main")
|> Output.with_system_message("Context injected")
```
---
## Security Hook Examples
### Blocking Dangerous Bash Commands
```elixir
defmodule SecurityHooks do
alias ClaudeAgentSDK.Hooks.Output
@dangerous_patterns [
"rm -rf",
"dd if=",
"mkfs",
"> /dev/",
"chmod 777",
"sudo",
":(){:|:&};:" # Fork bomb
]
def check_bash_command(input, _tool_use_id, _context) do
case input do
%{"tool_name" => "Bash", "tool_input" => %{"command" => command}} ->
if dangerous_command?(command) do
Output.deny("Dangerous command blocked: #{summarize(command)}")
|> Output.with_system_message("Security policy violation")
|> Output.with_reason("Command matches blocked pattern")
else
Output.allow("Security check passed")
end
_ ->
%{} # Not a Bash command
end
end
defp dangerous_command?(command) do
Enum.any?(@dangerous_patterns, &String.contains?(command, &1))
end
defp summarize(command) do
if String.length(command) > 50 do
String.slice(command, 0, 47) <> "..."
else
command
end
end
end
# Usage
hooks = %{
pre_tool_use: [
Matcher.new("Bash", [&SecurityHooks.check_bash_command/3])
]
}
```
### File Access Control
```elixir
defmodule FileSecurityHooks do
alias ClaudeAgentSDK.Hooks.Output
@forbidden_files [".env", "secrets.yml", "credentials.json", ".ssh/"]
@allowed_directories ["/tmp/sandbox", "/home/user/project"]
def check_file_access(input, _tool_use_id, _context) do
case input do
%{"tool_name" => tool, "tool_input" => %{"file_path" => path}}
when tool in ["Write", "Edit", "Read"] ->
cond do
forbidden_file?(path) ->
Output.deny("Cannot access sensitive file: #{Path.basename(path)}")
|> Output.with_system_message("Access denied: sensitive file")
not in_allowed_directory?(path) ->
Output.deny("File path outside allowed directories")
|> Output.with_reason("Must operate within: #{inspect(@allowed_directories)}")
true ->
Output.allow("File access permitted")
end
_ ->
%{}
end
end
defp forbidden_file?(path) do
Enum.any?(@forbidden_files, fn pattern ->
String.contains?(path, pattern)
end)
end
defp in_allowed_directory?(path) do
Enum.any?(@allowed_directories, fn dir ->
String.starts_with?(Path.expand(path), Path.expand(dir))
end)
end
end
# Usage
hooks = %{
pre_tool_use: [
Matcher.new("Write|Edit|Read", [&FileSecurityHooks.check_file_access/3])
]
}
```
### Rate Limiting
```elixir
defmodule RateLimitHooks do
alias ClaudeAgentSDK.Hooks.Output
@max_calls_per_minute 10
@ets_table :rate_limit_hooks
def init_table do
:ets.new(@ets_table, [:named_table, :public, :set])
end
def check_rate_limit(input, _tool_use_id, _context) do
tool_name = input["tool_name"]
current_minute = div(System.system_time(:second), 60)
key = {tool_name, current_minute}
count = case :ets.lookup(@ets_table, key) do
[{^key, n}] -> n
_ -> 0
end
if count >= @max_calls_per_minute do
Output.deny("Rate limit exceeded for #{tool_name}")
|> Output.with_system_message("Please wait before using this tool again")
else
:ets.insert(@ets_table, {key, count + 1})
Output.allow()
end
end
end
# Usage
RateLimitHooks.init_table()
hooks = %{
pre_tool_use: [
Matcher.new("*", [&RateLimitHooks.check_rate_limit/3])
]
}
```
---
## Audit Logging Examples
### Comprehensive Tool Audit Trail
```elixir
defmodule AuditHooks do
alias ClaudeAgentSDK.Hooks.Output
@log_file "/var/log/claude_audit.log"
def log_tool_invocation(input, tool_use_id, _context) do
entry = %{
timestamp: DateTime.utc_now() |> DateTime.to_iso8601(),
event: "tool_invocation",
tool: input["tool_name"],
tool_use_id: tool_use_id,
session_id: input["session_id"],
input: input["tool_input"]
}
write_log(entry)
# Don't modify behavior
%{}
end
def log_tool_completion(input, tool_use_id, _context) do
response = input["tool_response"]
is_error = get_in(response, ["is_error"]) || false
entry = %{
timestamp: DateTime.utc_now() |> DateTime.to_iso8601(),
event: "tool_completion",
tool: input["tool_name"],
tool_use_id: tool_use_id,
session_id: input["session_id"],
status: if(is_error, do: "error", else: "success")
}
write_log(entry)
# Don't modify behavior
%{}
end
defp write_log(entry) do
json = Jason.encode!(entry)
File.write!(@log_file, json <> "\n", [:append])
end
end
# Usage
hooks = %{
pre_tool_use: [
Matcher.new("*", [&AuditHooks.log_tool_invocation/3])
],
post_tool_use: [
Matcher.new("*", [&AuditHooks.log_tool_completion/3])
]
}
```
### Conversation Lifecycle Logging
```elixir
defmodule SessionAuditHooks do
def log_stop(input, _tool_use_id, _context) do
session_id = input["session_id"]
IO.puts("[AUDIT] Session #{session_id} ended at #{DateTime.utc_now()}")
%{}
end
def log_prompt_submit(input, _tool_use_id, _context) do
prompt = input["prompt"] || ""
truncated = String.slice(prompt, 0, 100)
IO.puts("[AUDIT] Prompt submitted: #{truncated}...")
%{}
end
end
# Usage
hooks = %{
user_prompt_submit: [
Matcher.new(nil, [&SessionAuditHooks.log_prompt_submit/3])
],
stop: [
Matcher.new(nil, [&SessionAuditHooks.log_stop/3])
]
}
```
### ETS-Based Metrics Collection
```elixir
defmodule MetricsHooks do
@table :claude_metrics
def init do
:ets.new(@table, [:named_table, :public, :set])
end
def track_tool_use(input, _tool_use_id, _context) do
tool = input["tool_name"]
:ets.update_counter(@table, {:tool_count, tool}, {2, 1}, {{:tool_count, tool}, 0})
:ets.update_counter(@table, :total_tools, {2, 1}, {:total_tools, 0})
%{}
end
def track_tool_error(input, _tool_use_id, _context) do
response = input["tool_response"]
is_error = get_in(response, ["is_error"]) || false
if is_error do
tool = input["tool_name"]
:ets.update_counter(@table, {:error_count, tool}, {2, 1}, {{:error_count, tool}, 0})
end
%{}
end
def get_metrics do
%{
total_tools: get_counter(:total_tools),
tool_counts: get_all_tool_counts(),
error_counts: get_all_error_counts()
}
end
defp get_counter(key) do
case :ets.lookup(@table, key) do
[{^key, n}] -> n
_ -> 0
end
end
defp get_all_tool_counts do
:ets.match(@table, {{:tool_count, :"$1"}, :"$2"})
|> Enum.map(fn [tool, count] -> {tool, count} end)
|> Map.new()
end
defp get_all_error_counts do
:ets.match(@table, {{:error_count, :"$1"}, :"$2"})
|> Enum.map(fn [tool, count] -> {tool, count} end)
|> Map.new()
end
end
# Usage
MetricsHooks.init()
hooks = %{
pre_tool_use: [Matcher.new("*", [&MetricsHooks.track_tool_use/3])],
post_tool_use: [Matcher.new("*", [&MetricsHooks.track_tool_error/3])]
}
# Later: get metrics
MetricsHooks.get_metrics()
# => %{total_tools: 42, tool_counts: %{"Bash" => 15, "Read" => 27}, error_counts: %{"Bash" => 2}}
```
---
## Context Injection Examples
### Project Information Injection
```elixir
defmodule ContextHooks do
alias ClaudeAgentSDK.Hooks.Output
def add_project_context(_input, _tool_use_id, _context) do
git_branch = get_git_branch()
environment = System.get_env("MIX_ENV", "dev")
context = """
## Project Context (Auto-Injected)
- **Timestamp:** #{DateTime.utc_now() |> DateTime.to_iso8601()}
- **Environment:** #{environment}
- **Git Branch:** #{git_branch}
- **Working Directory:** #{File.cwd!()}
"""
Output.add_context("UserPromptSubmit", context)
end
defp get_git_branch do
case System.cmd("git", ["branch", "--show-current"], stderr_to_stdout: true) do
{branch, 0} -> String.trim(branch)
_ -> "unknown"
end
end
end
# Usage
hooks = %{
user_prompt_submit: [
Matcher.new(nil, [&ContextHooks.add_project_context/3])
]
}
```
### Database Context Injection
```elixir
defmodule DatabaseContextHooks do
alias ClaudeAgentSDK.Hooks.Output
def add_database_schema(_input, _tool_use_id, _context) do
# In a real app, query your database for schema info
schema_info = """
## Database Schema Context
### Users Table
- id: integer (primary key)
- email: string (unique)
- name: string
- created_at: datetime
### Posts Table
- id: integer (primary key)
- user_id: integer (foreign key -> users.id)
- title: string
- content: text
- published_at: datetime
"""
Output.add_context("UserPromptSubmit", schema_info)
end
end
```
### Dynamic Tool Result Enhancement
```elixir
defmodule ResultEnhancementHooks do
alias ClaudeAgentSDK.Hooks.Output
def enhance_bash_result(input, _tool_use_id, _context) do
case input do
%{"tool_name" => "Bash", "tool_response" => response} ->
duration = calculate_duration()
context = """
[Hook Note] Command execution took #{duration}ms.
Exit code: #{get_exit_code(response)}
"""
Output.add_context("PostToolUse", context)
_ ->
%{}
end
end
defp calculate_duration do
# In practice, measure actual duration
:rand.uniform(1000)
end
defp get_exit_code(response) do
get_in(response, ["exit_code"]) || 0
end
end
# Usage
hooks = %{
post_tool_use: [
Matcher.new("Bash", [&ResultEnhancementHooks.enhance_bash_result/3])
]
}
```
---
## Combining Hooks with Streaming
When using hooks with the streaming API, the SDK automatically uses the control client transport. Here is a complete example:
```elixir
defmodule StreamingWithHooks do
alias ClaudeAgentSDK.{Client, ContentExtractor, Message, Options}
alias ClaudeAgentSDK.Hooks.{Matcher, Output}
def run do
# Define hooks
hooks = %{
pre_tool_use: [
Matcher.new("Bash", [&check_bash/3]),
Matcher.new("*", [&log_tool/3])
],
post_tool_use: [
Matcher.new("*", [&log_result/3])
],
user_prompt_submit: [
Matcher.new(nil, [&add_context/3])
]
}
# Configure options with hooks
options = %Options{
tools: ["Bash", "Read"],
allowed_tools: ["Bash", "Read"],
hooks: hooks,
model: "haiku",
max_turns: 3,
permission_mode: :default
}
# Start client
{:ok, client} = Client.start_link(options)
# Stream messages asynchronously
task = Task.async(fn ->
Client.stream_messages(client)
|> Enum.reduce_while([], fn message, acc ->
case message do
%Message{type: :assistant} = msg ->
text = ContentExtractor.extract_text(msg)
if text && text != "", do: IO.write(text)
{:cont, [message | acc]}
%Message{type: :result} ->
{:halt, Enum.reverse([message | acc])}
_ ->
{:cont, [message | acc]}
end
end)
end)
# Send message and wait for response
Process.sleep(50)
:ok = Client.send_message(client, "Run: echo 'Hello from streaming with hooks!'")
messages = Task.await(task, 120_000)
# Cleanup
Client.stop(client)
messages
end
# Hook callbacks
def check_bash(input, _id, _ctx) do
case input do
%{"tool_name" => "Bash", "tool_input" => %{"command" => cmd}} ->
if String.contains?(cmd, "rm -rf") do
Output.deny("Blocked dangerous command")
else
Output.allow()
end
_ -> %{}
end
end
def log_tool(input, id, _ctx) do
IO.puts("\n[Hook] Tool invoked: #{input["tool_name"]} (#{id})")
%{}
end
def log_result(input, id, _ctx) do
IO.puts("[Hook] Tool completed: #{input["tool_name"]} (#{id})")
%{}
end
def add_context(_input, _id, _ctx) do
Output.add_context("UserPromptSubmit", "Environment: #{Mix.env()}")
end
end
# Run the example
StreamingWithHooks.run()
```
### Using with Streaming.start_session
You can also use hooks with the `Streaming` module:
```elixir
alias ClaudeAgentSDK.{Streaming, Options}
alias ClaudeAgentSDK.Hooks.{Matcher, Output}
hooks = %{
pre_tool_use: [Matcher.new("*", [&my_hook/3])]
}
options = %Options{
hooks: hooks,
model: "haiku",
max_turns: 2
}
# Hooks are automatically active in the session
{:ok, session} = Streaming.start_session(options)
Streaming.send_message(session, "Hello!")
|> Enum.each(fn
%{type: :text_delta, text: text} -> IO.write(text)
%{type: :message_stop} -> IO.puts("")
_ -> :ok
end)
Streaming.close_session(session)
```
---
## Best Practices
### 1. Keep Hooks Fast
Hooks are executed synchronously and can impact response latency. Keep expensive operations to a minimum:
```elixir
# Good: Fast, synchronous check
def fast_check(input, _id, _ctx) do
if input["tool_name"] == "Bash" and dangerous?(input) do
Output.deny("Blocked")
else
Output.allow()
end
end
# Bad: Slow HTTP call in hook
def slow_check(input, _id, _ctx) do
# This blocks the entire agent!
HTTPoison.post!("https://api.example.com/check", input)
Output.allow()
end
```
For slow operations, consider:
- Using ETS for fast lookups
- Pre-loading data at startup
- Using async logging (write to queue, process separately)
### 2. Use Appropriate Matcher Specificity
Match only the tools you need to handle:
```elixir
# Good: Only processes Bash commands
Matcher.new("Bash", [&check_bash/3])
# Wasteful: Runs for every tool but only handles Bash
Matcher.new("*", [fn input, _, _ ->
if input["tool_name"] == "Bash" do
# ...
else
%{}
end
end])
```
### 3. Handle All Cases Gracefully
Always return a valid output, even for unexpected input:
```elixir
def robust_hook(input, _id, _ctx) do
case input do
%{"tool_name" => "Bash", "tool_input" => %{"command" => command}}
when is_binary(command) ->
# Handle the expected case
check_command(command)
%{"tool_name" => "Bash"} ->
# Missing or malformed tool_input
Output.deny("Invalid Bash input")
_ ->
# Not our tool, pass through
%{}
end
end
```
### 4. Supervise Callback Tasks
Hook callbacks run in async tasks. For production, add the SDK task supervisor
so callback processes are supervised and crash handling works as expected:
```elixir
children = [
ClaudeAgentSDK.TaskSupervisor,
{ClaudeAgentSDK.Client, options}
]
```
If you use a custom supervisor name, configure the SDK to match:
```elixir
children = [
{ClaudeAgentSDK.TaskSupervisor, name: MyApp.ClaudeTaskSupervisor}
]
config :claude_agent_sdk, task_supervisor: MyApp.ClaudeTaskSupervisor
```
If an explicitly configured supervisor is missing at runtime, the SDK logs a warning and
falls back to `Task.start/1`. With default settings, missing
`ClaudeAgentSDK.TaskSupervisor` falls back silently for backward compatibility.
For stricter behavior in dev/test:
```elixir
config :claude_agent_sdk, task_supervisor_strict: true
```
In strict mode, `ClaudeAgentSDK.TaskSupervisor.start_child/2` returns
`{:error, {:task_supervisor_unavailable, supervisor}}` instead of creating
an unsupervised fallback task.
SDK MCP `tools/call` requests are dispatched asynchronously, so long-running tool
execution no longer blocks the main `Client` callback path.
### 5. Use ETS for Stateful Hooks
When hooks need to maintain state (counters, caches, etc.), use ETS:
```elixir
defmodule StatefulHooks do
@table :hook_state
def init do
:ets.new(@table, [:named_table, :public, :set])
end
def counting_hook(input, _id, _ctx) do
tool = input["tool_name"]
:ets.update_counter(@table, {:count, tool}, {2, 1}, {{:count, tool}, 0})
%{}
end
def get_counts do
:ets.match(@table, {{:count, :"$1"}, :"$2"})
|> Map.new(fn [k, v] -> {k, v} end)
end
end
```
### 6. Layer Security Hooks
Apply multiple layers of security validation:
```elixir
hooks = %{
pre_tool_use: [
# Layer 1: Audit logging (always runs)
Matcher.new("*", [&audit_log/3]),
# Layer 2: Rate limiting
Matcher.new("*", [&check_rate_limit/3]),
# Layer 3: Tool-specific security
Matcher.new("Bash", [&check_bash_security/3]),
Matcher.new("Write|Edit", [&check_file_security/3])
]
}
```
### 7. Test Hooks in Isolation
Test hook logic separately from the SDK:
```elixir
defmodule SecurityHooksTest do
use ExUnit.Case
alias MyApp.SecurityHooks
alias ClaudeAgentSDK.Hooks.Output
test "blocks rm -rf commands" do
input = %{
"tool_name" => "Bash",
"tool_input" => %{"command" => "rm -rf /"}
}
result = SecurityHooks.check_bash(input, "test-id", %{})
assert result.hookSpecificOutput.permissionDecision == "deny"
end
test "allows safe commands" do
input = %{
"tool_name" => "Bash",
"tool_input" => %{"command" => "echo hello"}
}
result = SecurityHooks.check_bash(input, "test-id", %{})
assert result.hookSpecificOutput.permissionDecision == "allow"
end
end
```
### 8. Clean Up Resources
Always clean up ETS tables and other resources:
```elixir
# In your application supervision tree or test setup
def start_hooks do
:ets.new(:my_hook_table, [:named_table, :public, :set])
end
def stop_hooks do
if :ets.whereis(:my_hook_table) != :undefined do
:ets.delete(:my_hook_table)
end
end
```
### 9. Validate Hook Configuration
Use the SDK's validation before using hooks:
```elixir
alias ClaudeAgentSDK.Hooks
hooks = %{
pre_tool_use: [Matcher.new("Bash", [&my_hook/3])]
}
case Hooks.validate_config(hooks) do
:ok ->
# Configuration is valid
{:ok, client} = Client.start_link(%Options{hooks: hooks})
{:error, reason} ->
IO.puts("Invalid hook configuration: #{reason}")
end
```
### 10. Document Your Hooks
Keep hooks well-documented for maintainability:
```elixir
defmodule MyApp.SecurityHooks do
@moduledoc """
Security hooks for Claude Agent SDK.
## Hooks
- `check_bash_command/3` - Blocks dangerous Bash commands
- `check_file_access/3` - Enforces file access policies
## Configuration
Set `@allowed_directories` to control file access.
Set `@dangerous_patterns` to add blocked command patterns.
"""
@doc """
PreToolUse hook that validates Bash commands.
Blocks commands matching patterns in `@dangerous_patterns`.
## Examples
iex> check_bash_command(%{"tool_name" => "Bash", "tool_input" => %{"command" => "rm -rf /"}}, nil, %{})
%{hookSpecificOutput: %{permissionDecision: "deny", ...}}
"""
def check_bash_command(input, tool_use_id, context) do
# ...
end
end
```
---
## Summary
Hooks provide a powerful mechanism for controlling Claude Agent SDK behavior:
| Use Case | Hook Event | Key Functions |
|----------|------------|---------------|
| Security validation | `:pre_tool_use` | `Output.allow/0`, `Output.deny/1` |
| Audit logging | `:pre_tool_use`, `:post_tool_use` | Return `%{}` |
| Error tracking | `:post_tool_use_failure` | Return `%{}` |
| Context injection | `:user_prompt_submit`, `:session_start`, `:pre_compact` | `Output.add_context/2`, `Output.with_additional_context/2` |
| Permission automation | `:permission_request` | `Output.permission_allow/0`, `Output.permission_deny/1` |
| Notification routing | `:notification` | Return `%{}` |
| Agent lifecycle | `:subagent_start`, `:subagent_stop` | Return `%{}` |
| Session lifecycle | `:session_start`, `:session_end` | `Output.add_context/2` |
| Rate limiting | `:pre_tool_use` | ETS counters + `Output.deny/1` |
| Execution control | Any | `Output.stop/1`, `Output.continue/0` |
Key modules:
- `ClaudeAgentSDK.Hooks` - Event types and validation
- `ClaudeAgentSDK.Hooks.Matcher` - Tool pattern matching
- `ClaudeAgentSDK.Hooks.Output` - Response builders
For more examples, see the `examples/hooks/` directory in the SDK source.