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
  2. Hook Events
  3. Matcher Configuration
  4. Hook Callback Signature and Parameters
  5. Output Module
  6. Security Hook Examples
  7. Audit Logging Examples
  8. Context Injection Examples
  9. Combining Hooks with Streaming
  10. 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

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:

EventDescriptionCommon Use Cases
:pre_tool_useBefore a tool executesSecurity validation, permission checks
:post_tool_useAfter a tool executesAudit logging, result processing
:post_tool_use_failureAfter a tool execution failsError tracking, retry logic
:user_prompt_submitWhen user submits a promptContext injection, preprocessing
:stopWhen the agent finishesFinalization, reporting
:subagent_startWhen a subagent is spawnedSubagent tracking, resource limits
:subagent_stopWhen a subagent finishesSubagent result processing
:pre_compactBefore context compactionPreserve important context
:notificationCLI notification receivedAlert routing, logging
:permission_requestPermission dialog triggeredProgrammatic permission responses
:session_startSession beginsInitialization, context setup
:session_endSession endsCleanup, final reporting

Event Configuration Examples

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:

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:

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:

# 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:

# 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:

# 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:

# 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

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:

@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:

%{
  "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:

EventAdditional Fields
:pre_tool_usetool_name, tool_input
:post_tool_usetool_name, tool_input, tool_response
:post_tool_use_failuretool_name, tool_input, error
:user_prompt_submitprompt
:stopstop_hook_active, reason
:subagent_startagent_id, agent_type, agent_transcript_path
:subagent_stopstop_hook_active, agent_id, agent_type
:pre_compacttrigger, custom_instructions
:notificationmessage, title, notification_type, is_interrupt
:permission_requesttool_name, tool_input, permission_suggestions, permission_mode
:session_startsource
:session_endreason

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:

%{
  signal: %AbortSignal{}  # Optional abort signal for cooperative cancellation
}

Example Callback Implementations

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:

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:

Output.deny("Dangerous command detected")

# Returns:
# %{
#   hookSpecificOutput: %{
#     hookEventName: "PreToolUse",
#     permissionDecision: "deny",
#     permissionDecisionReason: "Dangerous command detected"
#   }
# }

Output.ask/1

Prompt the user for confirmation:

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:

# 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:

Output.stop("Critical error: resource limit exceeded")

# Returns:
# %{continue: false, stopReason: "Critical error: resource limit exceeded"}

Output.continue/0

Explicitly continue execution:

Output.continue()

# Returns:
# %{continue: true}

Output.block/1

Block with feedback to Claude:

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):

Output.deny("Command blocked")
|> Output.with_system_message("Security policy violation detected")

Output.with_reason/2

Add a Claude-visible explanation:

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:

Output.allow()
|> Output.suppress_output()

Output.with_additional_context/2

Add additional context to hook-specific output:

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:

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:

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:

# 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):

Output.allow()
|> Output.async()

# Returns:
# %{
#   hookSpecificOutput: %{...},
#   async: true
# }

Output.with_async_timeout/2

Set a timeout for asynchronous hook processing:

Output.allow()
|> Output.async()
|> Output.with_async_timeout(5000)

# Returns:
# %{
#   hookSpecificOutput: %{...},
#   async: true,
#   asyncTimeout: 5000
# }

Complete Output Examples

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

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

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

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

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

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

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

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

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

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:

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:

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:

# 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:

# 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:

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:

children = [
  ClaudeAgentSDK.TaskSupervisor,
  {ClaudeAgentSDK.Client, options}
]

If you use a custom supervisor name, configure the SDK to match:

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:

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:

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:

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:

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:

# 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:

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:

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 CaseHook EventKey Functions
Security validation:pre_tool_useOutput.allow/0, Output.deny/1
Audit logging:pre_tool_use, :post_tool_useReturn %{}
Error tracking:post_tool_use_failureReturn %{}
Context injection:user_prompt_submit, :session_start, :pre_compactOutput.add_context/2, Output.with_additional_context/2
Permission automation:permission_requestOutput.permission_allow/0, Output.permission_deny/1
Notification routing:notificationReturn %{}
Agent lifecycle:subagent_start, :subagent_stopReturn %{}
Session lifecycle:session_start, :session_endOutput.add_context/2
Rate limiting:pre_tool_useETS counters + Output.deny/1
Execution controlAnyOutput.stop/1, Output.continue/0

Key modules:

For more examples, see the examples/hooks/ directory in the SDK source.