Current section
Files
Jump to
Current section
Files
docs/design/hooks_implementation.md
# Claude Code Hooks Implementation for Elixir SDK
**Version:** 1.0
**Date:** 2025-10-16
**Status:** Design Proposal
**Authors:** Claude Code Development Team
---
## Table of Contents
1. [Executive Summary](#executive-summary)
2. [Background](#background)
3. [Requirements](#requirements)
4. [Architecture Overview](#architecture-overview)
5. [Detailed Design](#detailed-design)
6. [Implementation Plan](#implementation-plan)
7. [Examples](#examples)
8. [Testing Strategy](#testing-strategy)
9. [References](#references)
---
## Executive Summary
This document proposes adding Claude Code Hooks support to the Elixir SDK, bringing it to feature parity with the Python SDK. Hooks are callback functions that execute at specific lifecycle events during Claude's agent loop, enabling:
- **Intercepting tool calls** before/after execution
- **Adding contextual information** automatically
- **Controlling execution flow** based on runtime conditions
- **Implementing security policies** and validation
- **Monitoring and auditing** agent behavior
The implementation will follow the Python SDK's architecture while adapting to Elixir's functional, process-based paradigm.
---
## Background
### What are Claude Code Hooks?
Claude Code Hooks are functions invoked by the Claude Code CLI at specific points in the agent execution loop. Unlike tools (which Claude calls), hooks are invoked by the CLI application itself to provide deterministic processing and feedback.
**Key Characteristics:**
- **Not visible to Claude** - Hooks are infrastructure-level callbacks
- **Synchronous execution** - Block the agent loop until complete
- **Bidirectional control** - Can modify behavior, add context, or block actions
- **Pattern-based matching** - Target specific tools or events
### Current State
**Python SDK (`claude-agent-sdk-python`):**
- ✅ Full hooks implementation (v0.0.20+)
- ✅ All hook events supported (except SessionStart/SessionEnd/Notification due to setup limitations)
- ✅ Control protocol integration
- ✅ Comprehensive examples and tests
**Elixir SDK (`claude_agent_sdk`):**
- ❌ No hooks support
- ✅ Basic control protocol infrastructure exists (stdin/stdout streaming)
- ✅ Options system for CLI arguments
- ⚠️ Would need bidirectional communication for hooks
### Why Add Hooks?
1. **Feature Parity** - Match Python SDK capabilities
2. **Security** - Implement runtime validation and blocking
3. **Automation** - Add context automatically based on events
4. **Compliance** - Audit and log tool usage
5. **User Demand** - Common request from SDK users
---
## Requirements
### Functional Requirements
#### FR1: Hook Event Support
Support the following hook events:
- **PreToolUse** - Before tool execution
- **PostToolUse** - After tool execution
- **UserPromptSubmit** - When user submits a prompt
- **Stop** - When agent finishes
- **SubagentStop** - When subagent finishes
- **PreCompact** - Before context compaction
*Note: SessionStart, SessionEnd, and Notification hooks are not supported in SDK mode per Python SDK limitations.*
#### FR2: Hook Callback Interface
Hooks must be Elixir functions with signature:
```elixir
@type hook_callback ::
(input :: map(), tool_use_id :: String.t() | nil, context :: map() ->
hook_output :: map())
```
#### FR3: Hook Matcher Configuration
Support pattern-based matching:
```elixir
%{
"PreToolUse" => [
%HookMatcher{
matcher: "Bash", # Match specific tool
hooks: [callback_fn]
},
%HookMatcher{
matcher: "Write|Edit", # Regex pattern
hooks: [another_callback]
}
]
}
```
#### FR4: Hook Output Control
Hooks must return maps with control fields:
- `continue` - Whether to continue execution (boolean)
- `stopReason` - Message when stopping (string)
- `systemMessage` - User-visible message (string)
- `reason` - Claude-visible feedback (string)
- `suppressOutput` - Hide from transcript (boolean)
- `hookSpecificOutput` - Event-specific control (map)
#### FR5: Permission Decision Control
PreToolUse hooks must support:
```elixir
%{
"hookSpecificOutput" => %{
"hookEventName" => "PreToolUse",
"permissionDecision" => "allow" | "deny" | "ask",
"permissionDecisionReason" => "..."
}
}
```
#### FR6: Additional Context Injection
PostToolUse and UserPromptSubmit hooks must support:
```elixir
%{
"hookSpecificOutput" => %{
"hookEventName" => "PostToolUse",
"additionalContext" => "Context for Claude to consider"
}
}
```
### Non-Functional Requirements
#### NFR1: Performance
- Hook invocation overhead < 10ms
- No blocking on hook registration
- Efficient callback storage and lookup
#### NFR2: Reliability
- Graceful handling of hook errors
- Timeout protection (default 60s per hook)
- No crashes from malformed hook output
#### NFR3: Developer Experience
- Clear error messages for invalid hook configurations
- Comprehensive examples in documentation
- Type specs for all hook-related functions
#### NFR4: Compatibility
- Maintain backward compatibility with existing SDK code
- Optional feature - works without hooks configured
- Compatible with Claude CLI 2.0.0+
---
## Architecture Overview
### High-Level Components
```
┌─────────────────────────────────────────────────────────────┐
│ User Application │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ ClaudeAgentSDK.Client (New) │ │
│ │ - Bidirectional streaming │ │
│ │ - Control protocol handler │ │
│ │ - Hook callback registry │ │
│ └────────────────────────────────────────────────────┘ │
│ │ │
└───────────────────────────┼──────────────────────────────────┘
│
┌───────────▼──────────┐
│ Control Protocol │
│ (JSON over stdio) │
└───────────┬──────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
┌───▼────┐ ┌───────▼────────┐ ┌────▼─────┐
│ Claude │ │ Hook Callbacks │ │ Messages │
│ CLI │◄────►│ Invocation │ │ Streaming│
└────────┘ └─────────────────┘ └──────────┘
```
### Control Protocol Flow
```
┌──────────┐ ┌──────────┐
│ Elixir │ │ Claude │
│ SDK │ │ CLI │
└────┬─────┘ └─────┬────┘
│ │
│ 1. Send initialize with hooks config │
├───────────────────────────────────────────────>│
│ │
│ 2. CLI registers hooks │
│ ┌─────▼────┐
│ │ Register │
│ │ Hooks │
│ └─────┬────┘
│ │
│ 3. Agent runs, hook triggers │
│ ┌─────▼────────┐
│ │PreToolUse: │
│ │ Bash cmd │
│ └─────┬────────┘
│ │
│ 4. CLI sends hook_callback request │
│<───────────────────────────────────────────────┤
│ {type: "control_request", │
│ request: {subtype: "hook_callback", │
│ callback_id: "hook_0", │
│ input: {...}}} │
│ │
│ 5. SDK invokes registered callback │
┌───▼──────────┐ │
│ Find hook_0 │ │
│ Execute fn │ │
└───┬──────────┘ │
│ │
│ 6. SDK sends control_response │
├───────────────────────────────────────────────>│
│ {type: "control_response", │
│ response: {subtype: "success", │
│ response: {...}}} │
│ │
│ 7. CLI processes hook output │
│ ┌─────▼────────┐
│ │ Process │
│ │ Decision │
│ └─────┬────────┘
│ │
│ 8. Continue or block based on hook │
│ │
```
### Key Design Decisions
#### Decision 1: Bidirectional Communication Required
**Problem:** Current Elixir SDK uses one-way streaming (SDK → CLI → SDK messages). Hooks require bidirectional communication where CLI can request SDK to invoke callbacks.
**Solution:** Implement `ClaudeAgentSDK.Client` module that:
- Maintains persistent connection via GenServer
- Handles both incoming messages and control requests
- Uses separate process for reading messages
**Alternatives Considered:**
- Polling CLI for hook requests ❌ (too slow, not supported by CLI)
- External hook scripts only ❌ (defeats purpose of SDK hooks)
#### Decision 2: Callback Registry with Unique IDs
**Problem:** How to reference Elixir functions from CLI when it triggers hooks?
**Solution:** Assign each callback a unique ID during initialization:
```elixir
# During init
hook_callbacks = %{
"hook_0" => &MyModule.check_bash/3,
"hook_1" => &MyModule.review_output/3
}
# In initialize request
%{
"PreToolUse" => [
%{
"matcher" => "Bash",
"hookCallbackIds" => ["hook_0"]
}
]
}
```
**Alternatives Considered:**
- Function names as strings ❌ (not safe, serialization issues)
- Separate process per hook ❌ (too heavy, complex lifecycle)
#### Decision 3: Synchronous Hook Execution
**Problem:** Should hooks be async or sync?
**Solution:** Synchronous execution within GenServer to match Python SDK and CLI expectations:
- CLI blocks waiting for response
- Simpler error handling
- Predictable execution order
**Timeout Protection:**
- Default 60s timeout per hook
- Configurable via options
- Automatic error response on timeout
#### Decision 4: Options Structure
Add `hooks` field to existing `Options` struct:
```elixir
defstruct [
# ... existing fields ...
:hooks # %{hook_event => [%HookMatcher{}]}
]
```
**Why not separate module?** Keep configuration centralized, matches Python SDK structure.
---
## Detailed Design
### Module Structure
```
lib/claude_agent_sdk/
├── client.ex # NEW: Bidirectional client (GenServer)
├── hooks/
│ ├── hooks.ex # NEW: Type definitions and utilities
│ ├── matcher.ex # NEW: HookMatcher struct
│ ├── output.ex # NEW: HookOutput struct and helpers
│ └── registry.ex # NEW: Callback registry (internal)
├── control_protocol/
│ ├── protocol.ex # NEW: Control message encoding/decoding
│ ├── request.ex # NEW: Request types
│ └── response.ex # NEW: Response types
├── options.ex # MODIFIED: Add hooks field
├── process.ex # EXISTING: Process management
└── ...
```
### Type Definitions
#### `lib/claude_agent_sdk/hooks/hooks.ex`
```elixir
defmodule ClaudeAgentSDK.Hooks do
@moduledoc """
Type definitions and utilities for Claude Code Hooks.
See: https://docs.anthropic.com/en/docs/claude-code/hooks
"""
alias ClaudeAgentSDK.Hooks.{Matcher, Output}
@type hook_event ::
:pre_tool_use
| :post_tool_use
| :user_prompt_submit
| :stop
| :subagent_stop
| :pre_compact
@type hook_input :: %{
required(:hook_event_name) => String.t(),
required(:session_id) => String.t(),
required(:transcript_path) => String.t(),
required(:cwd) => String.t(),
optional(:tool_name) => String.t(),
optional(:tool_input) => map(),
optional(:tool_response) => term(),
optional(:prompt) => String.t(),
optional(:message) => String.t(),
optional(:trigger) => String.t(),
optional(:custom_instructions) => String.t(),
optional(:stop_hook_active) => boolean()
}
@type hook_context :: %{
optional(:signal) => reference()
}
@type hook_callback ::
(hook_input(), String.t() | nil, hook_context() -> Output.t())
@type hook_config :: %{
hook_event() => [Matcher.t()]
}
@doc """
Converts an Elixir hook event atom to CLI string format.
## Examples
iex> event_to_string(:pre_tool_use)
"PreToolUse"
"""
@spec event_to_string(hook_event()) :: String.t()
def event_to_string(:pre_tool_use), do: "PreToolUse"
def event_to_string(:post_tool_use), do: "PostToolUse"
def event_to_string(:user_prompt_submit), do: "UserPromptSubmit"
def event_to_string(:stop), do: "Stop"
def event_to_string(:subagent_stop), do: "SubagentStop"
def event_to_string(:pre_compact), do: "PreCompact"
@doc """
Converts a CLI hook event string to Elixir atom.
"""
@spec string_to_event(String.t()) :: hook_event() | nil
def string_to_event("PreToolUse"), do: :pre_tool_use
def string_to_event("PostToolUse"), do: :post_tool_use
def string_to_event("UserPromptSubmit"), do: :user_prompt_submit
def string_to_event("Stop"), do: :stop
def string_to_event("SubagentStop"), do: :subagent_stop
def string_to_event("PreCompact"), do: :pre_compact
def string_to_event(_), do: nil
@doc """
Validates a hook configuration.
Returns `:ok` if valid, `{:error, reason}` otherwise.
"""
@spec validate_config(hook_config()) :: :ok | {:error, String.t()}
def validate_config(config) when is_map(config) do
config
|> Enum.reduce_while(:ok, fn {event, matchers}, _acc ->
cond do
not is_atom(event) ->
{:halt, {:error, "Hook event must be an atom, got: #{inspect(event)}"}}
event not in [:pre_tool_use, :post_tool_use, :user_prompt_submit,
:stop, :subagent_stop, :pre_compact] ->
{:halt, {:error, "Invalid hook event: #{event}"}}
not is_list(matchers) ->
{:halt, {:error, "Matchers must be a list for event #{event}"}}
true ->
case validate_matchers(matchers) do
:ok -> {:cont, :ok}
error -> {:halt, error}
end
end
end)
end
def validate_config(_), do: {:error, "Hook config must be a map"}
defp validate_matchers(matchers) do
Enum.reduce_while(matchers, :ok, fn matcher, _acc ->
if match?(%Matcher{}, matcher) do
{:cont, :ok}
else
{:halt, {:error, "Each matcher must be a HookMatcher struct"}}
end
end)
end
end
```
#### `lib/claude_agent_sdk/hooks/matcher.ex`
```elixir
defmodule ClaudeAgentSDK.Hooks.Matcher do
@moduledoc """
Hook matcher configuration.
Defines which hooks should run for which tool patterns.
"""
alias ClaudeAgentSDK.Hooks
@type t :: %__MODULE__{
matcher: String.t() | nil,
hooks: [Hooks.hook_callback()]
}
@enforce_keys [:hooks]
defstruct [:matcher, :hooks]
@doc """
Creates a new hook matcher.
## Parameters
- `matcher` - Tool name pattern (e.g., "Bash", "Write|Edit", "*")
`nil` matches all tools
- `hooks` - List of callback functions
## Examples
# Match specific tool
Matcher.new("Bash", [&MyModule.check_bash/3])
# Match multiple tools with regex
Matcher.new("Write|Edit", [&check_file_edit/3])
# Match all tools
Matcher.new(nil, [&log_all_tools/3])
"""
@spec new(String.t() | nil, [Hooks.hook_callback()]) :: t()
def new(matcher, hooks) when is_list(hooks) do
%__MODULE__{
matcher: matcher,
hooks: hooks
}
end
@doc """
Converts matcher to CLI format for initialization.
"""
@spec to_cli_format(t(), (Hooks.hook_callback() -> String.t())) :: map()
def to_cli_format(%__MODULE__{} = matcher, callback_id_fn) do
callback_ids = Enum.map(matcher.hooks, callback_id_fn)
%{
"matcher" => matcher.matcher,
"hookCallbackIds" => callback_ids
}
end
end
```
#### `lib/claude_agent_sdk/hooks/output.ex`
```elixir
defmodule ClaudeAgentSDK.Hooks.Output do
@moduledoc """
Hook output structure and helpers.
Represents the return value from hook callbacks.
See: https://docs.anthropic.com/en/docs/claude-code/hooks#hook-output
"""
@type permission_decision :: :allow | :deny | :ask
@type hook_specific_output ::
pre_tool_use_output()
| post_tool_use_output()
| user_prompt_submit_output()
@type pre_tool_use_output :: %{
hookEventName: String.t(),
permissionDecision: permission_decision(),
permissionDecisionReason: String.t()
}
@type post_tool_use_output :: %{
hookEventName: String.t(),
additionalContext: String.t()
}
@type user_prompt_submit_output :: %{
hookEventName: String.t(),
additionalContext: String.t()
}
@type t :: %{
optional(:continue) => boolean(),
optional(:stopReason) => String.t(),
optional(:suppressOutput) => boolean(),
optional(:systemMessage) => String.t(),
optional(:reason) => String.t(),
optional(:decision) => :block,
optional(:hookSpecificOutput) => hook_specific_output()
}
@doc """
Creates hook output to allow a PreToolUse.
"""
@spec allow(String.t()) :: t()
def allow(reason \\ "Approved") do
%{
hookSpecificOutput: %{
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: reason
}
}
end
@doc """
Creates hook output to deny a PreToolUse.
"""
@spec deny(String.t()) :: t()
def deny(reason) do
%{
hookSpecificOutput: %{
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: reason
}
}
end
@doc """
Creates hook output to add context.
"""
@spec add_context(String.t(), String.t()) :: t()
def add_context(event_name, context) do
%{
hookSpecificOutput: %{
hookEventName: event_name,
additionalContext: context
}
}
end
@doc """
Creates hook output to stop execution.
"""
@spec stop(String.t()) :: t()
def stop(reason) do
%{
continue: false,
stopReason: reason
}
end
@doc """
Validates hook output structure.
"""
@spec validate(t()) :: :ok | {:error, String.t()}
def validate(output) when is_map(output) do
# Basic validation - more comprehensive checks possible
:ok
end
def validate(_), do: {:error, "Hook output must be a map"}
@doc """
Converts Elixir output to JSON-compatible map for CLI.
"""
@spec to_json_map(t()) :: map()
def to_json_map(output) when is_map(output) do
output
|> Enum.map(fn
{:continue, val} -> {"continue", val}
{key, val} when is_atom(key) -> {Atom.to_string(key), val}
{key, val} -> {key, val}
end)
|> Map.new()
end
end
```
### Client Implementation
#### `lib/claude_agent_sdk/client.ex`
```elixir
defmodule ClaudeAgentSDK.Client do
@moduledoc """
Bidirectional client for Claude Code with hook support.
This GenServer maintains a persistent connection to the Claude CLI
process, handles control protocol messages, and invokes hook callbacks.
## Usage
# Start client
{:ok, pid} = Client.start_link(options)
# Send query
:ok = Client.query(pid, "Write a function")
# Receive messages
stream = Client.stream_messages(pid)
# Stop client
:ok = Client.stop(pid)
## With Hooks
options = %Options{
hooks: %{
pre_tool_use: [
Matcher.new("Bash", [&check_bash_command/3])
]
}
}
{:ok, pid} = Client.start_link(options)
"""
use GenServer
require Logger
alias ClaudeAgentSDK.{Options, Hooks, ControlProtocol}
alias ClaudeAgentSDK.Hooks.{Matcher, Registry}
@type state :: %{
port: port(),
options: Options.t(),
registry: Registry.t(),
message_queue: :queue.queue(),
subscribers: [pid()],
pending_requests: %{String.t() => reference()},
initialized: boolean()
}
## Public API
@doc """
Starts the client GenServer.
"""
@spec start_link(Options.t()) :: GenServer.on_start()
def start_link(%Options{} = options) do
GenServer.start_link(__MODULE__, options)
end
@doc """
Sends a query to Claude.
"""
@spec query(pid(), String.t()) :: :ok
def query(pid, prompt) when is_binary(prompt) do
GenServer.call(pid, {:query, prompt})
end
@doc """
Returns a stream of messages from Claude.
"""
@spec stream_messages(pid()) :: Enumerable.t()
def stream_messages(pid) do
Stream.resource(
fn -> subscribe(pid) end,
fn state -> receive_next_message(state) end,
fn _state -> :ok end
)
end
@doc """
Stops the client.
"""
@spec stop(pid()) :: :ok
def stop(pid) do
GenServer.stop(pid)
end
## GenServer Callbacks
@impl true
def init(%Options{} = options) do
# Validate hooks configuration
case validate_hooks(options.hooks) do
:ok ->
start_cli_process(options)
{:error, reason} ->
{:stop, {:hooks_validation_failed, reason}}
end
end
@impl true
def handle_call({:query, prompt}, _from, state) do
case send_query(state.port, prompt) do
:ok -> {:reply, :ok, state}
error -> {:reply, error, state}
end
end
@impl true
def handle_info({port, {:data, data}}, %{port: port} = state) do
case parse_cli_output(data) do
{:control_request, request} ->
handle_control_request(request, state)
{:message, message} ->
broadcast_message(message, state)
{:noreply, state}
{:error, reason} ->
Logger.error("Failed to parse CLI output: #{reason}")
{:noreply, state}
end
end
@impl true
def handle_info({:DOWN, _ref, :port, port, reason}, %{port: port} = state) do
Logger.info("CLI process exited: #{inspect(reason)}")
{:stop, :normal, state}
end
## Private Functions
defp start_cli_process(options) do
# Build CLI command
cmd = build_cli_command(options)
# Open port
port = Port.open({:spawn, cmd}, [:binary, :exit_status, :use_stdio])
# Create hook registry
registry = Registry.new()
registry = register_hooks(registry, options.hooks)
# Send initialize request
state = %{
port: port,
options: options,
registry: registry,
message_queue: :queue.new(),
subscribers: [],
pending_requests: %{},
initialized: false
}
case send_initialize(state) do
{:ok, new_state} -> {:ok, new_state}
{:error, reason} -> {:stop, {:init_failed, reason}}
end
end
defp handle_control_request(request, state) do
request_id = request["request_id"]
request_data = request["request"]
case request_data["subtype"] do
"hook_callback" ->
handle_hook_callback(request_id, request_data, state)
other ->
Logger.warn("Unsupported control request: #{other}")
send_error_response(state.port, request_id, "Unsupported request")
{:noreply, state}
end
end
defp handle_hook_callback(request_id, request_data, state) do
callback_id = request_data["callback_id"]
input = request_data["input"]
tool_use_id = request_data["tool_use_id"]
# Look up callback in registry
case Registry.get_callback(state.registry, callback_id) do
{:ok, callback_fn} ->
# Invoke callback (with timeout protection)
task = Task.async(fn ->
try do
context = %{signal: nil}
callback_fn.(input, tool_use_id, context)
rescue
e -> {:error, Exception.message(e)}
end
end)
result = case Task.yield(task, 60_000) || Task.shutdown(task) do
{:ok, output} when is_map(output) ->
{:ok, output}
{:ok, {:error, reason}} ->
{:error, reason}
nil ->
{:error, "Hook callback timeout after 60s"}
end
case result do
{:ok, output} ->
send_success_response(state.port, request_id, output)
{:error, reason} ->
send_error_response(state.port, request_id, reason)
end
{:noreply, state}
:error ->
send_error_response(state.port, request_id,
"Callback not found: #{callback_id}")
{:noreply, state}
end
end
defp register_hooks(registry, nil), do: registry
defp register_hooks(registry, hooks) when is_map(hooks) do
Enum.reduce(hooks, registry, fn {_event, matchers}, acc ->
Enum.reduce(matchers, acc, fn matcher, reg ->
Enum.reduce(matcher.hooks, reg, fn callback, r ->
Registry.register(r, callback)
end)
end)
end)
end
defp send_initialize(state) do
# Build hooks config
hooks_config = build_hooks_config(state.registry, state.options.hooks)
# Send initialize control request
request = %{
"type" => "control_request",
"request_id" => generate_request_id(),
"request" => %{
"subtype" => "initialize",
"hooks" => hooks_config
}
}
json = Jason.encode!(request)
Port.command(state.port, json <> "\n")
# Wait for response...
{:ok, %{state | initialized: true}}
end
defp build_hooks_config(_registry, nil), do: nil
defp build_hooks_config(registry, hooks) do
hooks
|> Enum.map(fn {event, matchers} ->
event_str = Hooks.event_to_string(event)
matchers_config = Enum.map(matchers, fn matcher ->
callback_ids = Enum.map(matcher.hooks, fn callback ->
Registry.get_id(registry, callback)
end)
%{
"matcher" => matcher.matcher,
"hookCallbackIds" => callback_ids
}
end)
{event_str, matchers_config}
end)
|> Map.new()
end
defp validate_hooks(nil), do: :ok
defp validate_hooks(hooks), do: Hooks.validate_config(hooks)
# ... more private functions ...
end
```
#### `lib/claude_agent_sdk/hooks/registry.ex`
```elixir
defmodule ClaudeAgentSDK.Hooks.Registry do
@moduledoc false
# Internal module for managing hook callback registration
alias ClaudeAgentSDK.Hooks
@type t :: %__MODULE__{
callbacks: %{String.t() => Hooks.hook_callback()},
reverse_map: %{Hooks.hook_callback() => String.t()},
counter: non_neg_integer()
}
defstruct callbacks: %{},
reverse_map: %{},
counter: 0
@doc """
Creates a new registry.
"""
@spec new() :: t()
def new do
%__MODULE__{}
end
@doc """
Registers a callback and returns updated registry.
If callback already registered, returns existing ID.
"""
@spec register(t(), Hooks.hook_callback()) :: t()
def register(%__MODULE__{} = registry, callback) when is_function(callback, 3) do
case Map.get(registry.reverse_map, callback) do
nil ->
# New callback
id = "hook_#{registry.counter}"
%{registry |
callbacks: Map.put(registry.callbacks, id, callback),
reverse_map: Map.put(registry.reverse_map, callback, id),
counter: registry.counter + 1
}
_existing_id ->
# Already registered
registry
end
end
@doc """
Gets callback by ID.
"""
@spec get_callback(t(), String.t()) :: {:ok, Hooks.hook_callback()} | :error
def get_callback(%__MODULE__{} = registry, id) do
case Map.get(registry.callbacks, id) do
nil -> :error
callback -> {:ok, callback}
end
end
@doc """
Gets ID for a callback.
"""
@spec get_id(t(), Hooks.hook_callback()) :: String.t() | nil
def get_id(%__MODULE__{} = registry, callback) do
Map.get(registry.reverse_map, callback)
end
end
```
### Options Integration
#### Modifications to `lib/claude_agent_sdk/options.ex`
```elixir
# Add to defstruct
defstruct [
# ... existing fields ...
:hooks # %{atom() => [Matcher.t()]} | nil
]
# Add to @type t
@type t :: %__MODULE__{
# ... existing fields ...
hooks: Hooks.hook_config() | nil
}
# Note: No CLI argument conversion needed for hooks
# Hooks are sent via control protocol, not CLI args
```
---
## Implementation Plan
### Phase 1: Core Infrastructure (Week 1)
**Tasks:**
1. Create module structure
- `lib/claude_agent_sdk/hooks/hooks.ex`
- `lib/claude_agent_sdk/hooks/matcher.ex`
- `lib/claude_agent_sdk/hooks/output.ex`
- `lib/claude_agent_sdk/hooks/registry.ex`
2. Implement type definitions and utilities
- Hook event conversions
- Validation functions
- Output helpers
3. Add hooks field to Options
- Update struct
- Update type specs
- Add validation
**Deliverables:**
- ✅ Type modules with full specs
- ✅ Unit tests for utilities
- ✅ Documentation
### Phase 2: Control Protocol (Week 2)
**Tasks:**
1. Create control protocol modules
- `lib/claude_agent_sdk/control_protocol/protocol.ex`
- Request/response encoding/decoding
2. Implement message parsing
- Distinguish control messages from SDK messages
- Handle JSON-RPC format
3. Add error handling
- Malformed messages
- Unknown request types
**Deliverables:**
- ✅ Protocol encoder/decoder
- ✅ Unit tests with real CLI messages
- ✅ Error case handling
### Phase 3: Client Implementation (Week 3)
**Tasks:**
1. Implement `ClaudeAgentSDK.Client` GenServer
- Port management
- Message routing
- Hook registry
2. Implement initialization handshake
- Send hooks configuration
- Handle initialize response
3. Implement hook callback handling
- Receive hook_callback requests
- Invoke user callbacks with timeout
- Send responses back to CLI
**Deliverables:**
- ✅ Working Client module
- ✅ Integration tests with mock CLI
- ✅ Timeout protection
### Phase 4: Testing & Examples (Week 4)
**Tasks:**
1. Create comprehensive test suite
- Unit tests for all modules
- Integration tests with real CLI
- Error scenarios
2. Write examples
- Basic PreToolUse hook
- PostToolUse with context
- Complex multi-hook scenario
- Error handling examples
3. Update documentation
- API reference
- Hook guide
- Migration guide
**Deliverables:**
- ✅ 90%+ test coverage
- ✅ 5+ working examples
- ✅ Complete documentation
### Phase 5: Polish & Release (Week 5)
**Tasks:**
1. Performance optimization
- Benchmark hook overhead
- Optimize message parsing
- Reduce allocations
2. Developer experience
- Better error messages
- Helpful warnings
- Type specs everywhere
3. Release preparation
- Changelog
- Migration guide
- Blog post
**Deliverables:**
- ✅ v0.3.0 release
- ✅ Release notes
- ✅ Announcement
---
## Examples
### Example 1: Block Dangerous Bash Commands
```elixir
defmodule MyApp.Hooks do
alias ClaudeAgentSDK.Hooks.Output
def check_bash_command(input, _tool_use_id, _context) do
case input do
%{"tool_name" => "Bash", "tool_input" => %{"command" => command}} ->
dangerous_patterns = ["rm -rf", "dd if=", "mkfs", "> /dev/"]
if Enum.any?(dangerous_patterns, &String.contains?(command, &1)) do
Output.deny("Dangerous command blocked: #{command}")
else
Output.allow()
end
_ ->
%{} # Not a Bash command, allow
end
end
end
# Usage
options = %Options{
allowed_tools: ["Bash"],
hooks: %{
pre_tool_use: [
Matcher.new("Bash", [&MyApp.Hooks.check_bash_command/3])
]
}
}
{:ok, pid} = Client.start_link(options)
Client.query(pid, "Delete all files with rm -rf /")
# Hook will block this command!
```
### Example 2: Add Context from Environment
```elixir
defmodule MyApp.Hooks do
alias ClaudeAgentSDK.Hooks.Output
def add_project_context(_input, _tool_use_id, _context) do
# Read project info
recent_issues = get_recent_github_issues()
current_branch = get_current_git_branch()
context = """
Project Context:
- Current branch: #{current_branch}
- Recent issues: #{Enum.join(recent_issues, ", ")}
- Last deploy: #{get_last_deploy_time()}
"""
Output.add_context("SessionStart", context)
end
defp get_recent_github_issues do
# GitHub API call
["#123: Fix auth", "#124: Add logging"]
end
defp get_current_git_branch do
{result, 0} = System.cmd("git", ["branch", "--show-current"])
String.trim(result)
end
defp get_last_deploy_time do
# Check deployment log
"2 hours ago"
end
end
# Usage
options = %Options{
hooks: %{
session_start: [
Matcher.new(nil, [&MyApp.Hooks.add_project_context/3])
]
}
}
```
### Example 3: Log All Tool Usage
```elixir
defmodule MyApp.Hooks do
require Logger
def log_tool_use(input, tool_use_id, _context) do
tool_name = input["tool_name"]
tool_input = input["tool_input"]
Logger.info("Tool used",
tool: tool_name,
tool_use_id: tool_use_id,
input: tool_input
)
# Don't modify behavior, just log
%{}
end
def log_tool_result(input, tool_use_id, _context) do
tool_name = input["tool_name"]
tool_response = input["tool_response"]
Logger.info("Tool completed",
tool: tool_name,
tool_use_id: tool_use_id,
success: not Map.get(tool_response, "is_error", false)
)
%{}
end
end
# Usage
options = %Options{
hooks: %{
pre_tool_use: [
Matcher.new("*", [&MyApp.Hooks.log_tool_use/3])
],
post_tool_use: [
Matcher.new("*", [&MyApp.Hooks.log_tool_result/3])
]
}
}
```
### Example 4: Enforce File Edit Policy
```elixir
defmodule MyApp.SecurityPolicy do
alias ClaudeAgentSDK.Hooks.Output
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
String.ends_with?(path, ".env") ->
Output.deny("Cannot modify .env files")
String.contains?(path, "/config/secrets") ->
Output.deny("Cannot modify secrets directory")
not String.starts_with?(path, "/allowed/path") ->
Output.deny("Can only modify files in /allowed/path")
true ->
Output.allow()
end
_ ->
%{}
end
end
end
# Usage
options = %Options{
allowed_tools: ["Write", "Edit", "Read"],
hooks: %{
pre_tool_use: [
Matcher.new("Write|Edit", [&MyApp.SecurityPolicy.enforce_file_policy/3])
]
}
}
```
---
## Testing Strategy
### Unit Tests
**Test Coverage:**
- `Hooks` module: 100%
- `Matcher` module: 100%
- `Output` module: 100%
- `Registry` module: 100%
- `Client` module: 90%+ (excluding complex integration scenarios)
**Key Test Cases:**
1. Type conversions (event atoms ↔ strings)
2. Hook configuration validation
3. Output structure validation
4. Registry registration and lookup
5. Matcher pattern matching
6. Error handling for malformed input
### Integration Tests
**Test Scenarios:**
1. **Successful Hook Execution**
- Hook receives correct input
- Hook returns valid output
- CLI processes decision correctly
2. **Hook Blocks Action**
- PreToolUse denies tool
- CLI doesn't execute tool
- Claude receives feedback
3. **Hook Adds Context**
- PostToolUse adds context
- Context appears in next message to Claude
4. **Hook Timeout**
- Hook takes > 60s
- Client sends timeout error
- CLI continues gracefully
5. **Hook Exception**
- Hook raises exception
- Client catches and reports
- CLI continues gracefully
6. **Multiple Hooks**
- Multiple hooks for same event
- All hooks execute
- Decisions are combined correctly
7. **Complex Workflow**
- PreToolUse allows
- Tool executes
- PostToolUse reviews result
- Claude receives feedback
### E2E Tests
**Real CLI Integration:**
```elixir
defmodule ClaudeAgentSDK.HooksE2ETest do
use ExUnit.Case
@moduletag :integration
@moduletag timeout: 120_000
test "PreToolUse blocks dangerous bash command" do
options = %Options{
allowed_tools: ["Bash"],
hooks: %{
pre_tool_use: [
Matcher.new("Bash", [&TestHooks.block_rm/3])
]
}
}
{:ok, pid} = Client.start_link(options)
# Send dangerous command
Client.query(pid, "Run: rm -rf /tmp/important")
# Collect messages
messages = Client.stream_messages(pid) |> Enum.to_list()
# Verify command was blocked
assert Enum.any?(messages, fn msg ->
match?(%{type: :assistant, data: %{content: content}} when
String.contains?(content, "blocked"), msg)
end)
end
test "PostToolUse adds monitoring context" do
# Test that PostToolUse hooks can add context
end
test "Multiple hooks execute in order" do
# Test hook execution order
end
end
```
### Performance Tests
**Benchmarks:**
```elixir
defmodule ClaudeAgentSDK.HooksBenchmark do
use Benchfella
bench "hook invocation overhead" do
# Measure time from hook trigger to callback invocation
end
bench "registry lookup" do
# Measure registry.get_callback performance
end
bench "output serialization" do
# Measure JSON encoding of hook output
end
end
```
**Target Metrics:**
- Hook invocation overhead: < 10ms
- Registry lookup: < 1ms
- Output serialization: < 5ms
- Memory per registered hook: < 1KB
---
## References
### Documentation
- [Claude Code Hooks Reference](https://docs.anthropic.com/en/docs/claude-code/hooks)
- [Python SDK Hooks Example](https://github.com/anthropics/claude-agent-sdk-python/blob/main/examples/hooks.py)
- [Control Protocol Specification](https://docs.anthropic.com/en/docs/claude-code/sdk)
### Implementation References
- Python SDK `query.py`: Control protocol handling
- Python SDK `types.py`: Hook type definitions
- Python SDK `client.py`: Hook callback invocation
### Related Issues
- Feature request: "Add hooks support to Elixir SDK"
- Discussion: "Control protocol best practices"
---
## Appendix A: Control Protocol Message Examples
### Initialize Request (SDK → CLI)
```json
{
"type": "control_request",
"request_id": "req_1_a3f2c9",
"request": {
"subtype": "initialize",
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hookCallbackIds": ["hook_0", "hook_1"]
}
],
"PostToolUse": [
{
"matcher": "*",
"hookCallbackIds": ["hook_2"]
}
]
}
}
}
```
### Initialize Response (CLI → SDK)
```json
{
"type": "control_response",
"response": {
"subtype": "success",
"request_id": "req_1_a3f2c9",
"response": {
"commands": [...],
"capabilities": {...}
}
}
}
```
### Hook Callback Request (CLI → SDK)
```json
{
"type": "control_request",
"request_id": "req_2_b7e4d1",
"request": {
"subtype": "hook_callback",
"callback_id": "hook_0",
"tool_use_id": "toolu_01ABC123",
"input": {
"hook_event_name": "PreToolUse",
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/project/dir",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/data"
}
}
}
}
```
### Hook Callback Response (SDK → CLI)
```json
{
"type": "control_response",
"response": {
"subtype": "success",
"request_id": "req_2_b7e4d1",
"response": {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Dangerous rm -rf command blocked"
},
"systemMessage": "Security policy blocked this command",
"reason": "rm -rf is not allowed"
}
}
}
```
---
## Appendix B: Comparison with Python SDK
| Feature | Python SDK | Elixir SDK (Proposed) | Notes |
|---------|-----------|----------------------|-------|
| **Hook Events** | | | |
| PreToolUse | ✅ | ✅ | Identical behavior |
| PostToolUse | ✅ | ✅ | Identical behavior |
| UserPromptSubmit | ✅ | ✅ | Identical behavior |
| Stop | ✅ | ✅ | Identical behavior |
| SubagentStop | ✅ | ✅ | Identical behavior |
| PreCompact | ✅ | ✅ | Identical behavior |
| SessionStart | ❌ | ❌ | Not in SDK mode |
| SessionEnd | ❌ | ❌ | Not in SDK mode |
| Notification | ❌ | ❌ | Not in SDK mode |
| **Configuration** | | | |
| Matcher patterns | ✅ | ✅ | Same syntax |
| Regex support | ✅ | ✅ | Same syntax |
| Multiple hooks | ✅ | ✅ | Same behavior |
| **Hook Output** | | | |
| permissionDecision | ✅ | ✅ | Identical |
| additionalContext | ✅ | ✅ | Identical |
| continue/stopReason | ✅ | ✅ | Identical |
| systemMessage/reason | ✅ | ✅ | Identical |
| **Implementation** | | | |
| Callback signature | `(dict, str, ctx) -> dict` | `(map, str, map) -> map` | Same semantics |
| Async callbacks | ✅ (async/await) | ✅ (Task) | Different mechanism |
| Timeout protection | ✅ 60s | ✅ 60s | Same default |
| Error handling | ✅ Exception | ✅ try/rescue | Different mechanism |
| **Developer Experience** | | | |
| Type hints | ✅ TypedDict | ✅ @type/@spec | Different system |
| Examples | ✅ hooks.py | ✅ examples/ | Similar coverage |
| Documentation | ✅ Docstrings | ✅ @moduledoc | Different format |
---
**End of Document**