Skip to main content
Hooks provide advanced control over the SDK’s behavior at different stages of execution. Use hooks to add custom logic, validate inputs, modify behavior, or stop execution based on conditions.

Hook Types

The SDK supports several hook types:
  • PreToolUse - Runs before a tool is executed (permission control)
  • PostToolUse - Runs after a tool completes (result validation)
  • UserPromptSubmit - Runs when a user prompt is submitted (context injection)
  • SessionStart - Runs when a session starts (initialization)

Basic PreToolUse Hook

Prevent specific bash commands from executing:
import logging
from claude_agent_sdk import (
    ClaudeSDKClient,
    ClaudeAgentOptions,
    HookContext,
    HookInput,
    HookJSONOutput,
    HookMatcher,
)

logger = logging.getLogger(__name__)

async def check_bash_command(
    input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
    """Prevent certain bash commands from being executed."""
    tool_name = input_data["tool_name"]
    tool_input = input_data["tool_input"]

    if tool_name != "Bash":
        return {}

    command = tool_input.get("command", "")
    block_patterns = ["foo.sh"]

    for pattern in block_patterns:
        if pattern in command:
            logger.warning(f"Blocked command: {command}")
            return {
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "deny",
                    "permissionDecisionReason": f"Command contains invalid pattern: {pattern}",
                }
            }

    return {}

# Configure hooks using ClaudeAgentOptions
options = ClaudeAgentOptions(
    allowed_tools=["Bash"],
    hooks={
        "PreToolUse": [
            HookMatcher(matcher="Bash", hooks=[check_bash_command]),
        ],
    }
)

async with ClaudeSDKClient(options=options) as client:
    await client.query("Run the bash command: ./foo.sh --help")
    async for msg in client.receive_response():
        # Handle messages...
        pass

UserPromptSubmit Hook

Add custom context when prompts are submitted:
async def add_custom_instructions(
    input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
    """Add custom instructions when a session starts."""
    return {
        "hookSpecificOutput": {
            "hookEventName": "SessionStart",
            "additionalContext": "My favorite color is hot pink",
        }
    }

options = ClaudeAgentOptions(
    hooks={
        "UserPromptSubmit": [
            HookMatcher(matcher=None, hooks=[add_custom_instructions]),
        ],
    }
)

async with ClaudeSDKClient(options=options) as client:
    await client.query("What's my favorite color?")
    async for msg in client.receive_response():
        # Claude will know your favorite color from the hook
        pass
When matcher=None, the hook applies to all operations regardless of tool name.

PostToolUse Hook

Review tool output and provide feedback:
async def review_tool_output(
    input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
    """Review tool output and provide additional context or warnings."""
    tool_response = input_data.get("tool_response", "")

    # If the tool produced an error, add helpful context
    if "error" in str(tool_response).lower():
        return {
            "systemMessage": "⚠️ The command produced an error",
            "reason": "Tool execution failed - consider checking the command syntax",
            "hookSpecificOutput": {
                "hookEventName": "PostToolUse",
                "additionalContext": "The command encountered an error. You may want to try a different approach.",
            }
        }

    return {}

options = ClaudeAgentOptions(
    allowed_tools=["Bash"],
    hooks={
        "PostToolUse": [
            HookMatcher(matcher="Bash", hooks=[review_tool_output]),
        ],
    }
)

Permission Decision Hook

Use permissionDecision to explicitly allow or deny operations:
async def strict_approval_hook(
    input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
    """Demonstrates using permissionDecision to control tool execution."""
    tool_name = input_data.get("tool_name")
    tool_input = input_data.get("tool_input", {})

    # Block any Write operations to specific files
    if tool_name == "Write":
        file_path = tool_input.get("file_path", "")
        if "important" in file_path.lower():
            logger.warning(f"Blocked Write to: {file_path}")
            return {
                "reason": "Writes to files containing 'important' are not allowed",
                "systemMessage": "🚫 Write operation blocked by security policy",
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "deny",
                    "permissionDecisionReason": "Security policy blocks writes to important files",
                },
            }

    # Allow everything else explicitly
    return {
        "reason": "Tool use approved after security review",
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "allow",
            "permissionDecisionReason": "Tool passed security checks",
        },
    }

options = ClaudeAgentOptions(
    allowed_tools=["Write", "Bash"],
    hooks={
        "PreToolUse": [
            HookMatcher(matcher="Write", hooks=[strict_approval_hook]),
        ],
    }
)

Stopping Execution

Use continue_=False to halt execution on critical errors:
async def stop_on_error_hook(
    input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
    """Demonstrates using continue=False to stop execution on certain conditions."""
    tool_response = input_data.get("tool_response", "")

    # Stop execution if we see a critical error
    if "critical" in str(tool_response).lower():
        logger.error("Critical error detected - stopping execution")
        return {
            "continue_": False,
            "stopReason": "Critical error detected in tool output - execution halted for safety",
            "systemMessage": "🛑 Execution stopped due to critical error",
        }

    return {"continue_": True}

options = ClaudeAgentOptions(
    allowed_tools=["Bash"],
    hooks={
        "PostToolUse": [
            HookMatcher(matcher="Bash", hooks=[stop_on_error_hook]),
        ],
    }
)

Hook Response Format

Hooks return a HookJSONOutput dictionary with optional fields:
return {
    # Display a message to the user
    "systemMessage": "⚠️ Warning message",
    
    # Explain why this decision was made
    "reason": "Detailed explanation for logging",
    
    # Control execution flow
    "continue_": True,  # False to stop execution
    "stopReason": "Why execution was stopped",
    
    # Hook-specific output
    "hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "allow",  # or "deny"
        "permissionDecisionReason": "Why this decision was made",
        "additionalContext": "Extra context for Claude",
    }
}

Multiple Hooks

You can attach multiple hooks to the same event:
options = ClaudeAgentOptions(
    hooks={
        "PreToolUse": [
            # First matcher: check bash commands
            HookMatcher(matcher="Bash", hooks=[check_bash_command]),
            # Second matcher: strict approval for writes
            HookMatcher(matcher="Write", hooks=[strict_approval_hook]),
        ],
        "PostToolUse": [
            # Review all tool outputs
            HookMatcher(matcher=None, hooks=[review_tool_output]),
        ],
    }
)

Complete Example

#!/usr/bin/env python
"""Example of using hooks with Claude Code SDK via ClaudeAgentOptions.

This file demonstrates various hook patterns using the hooks parameter
in ClaudeAgentOptions.
"""

import asyncio
import logging
import sys
from typing import Any

from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from claude_agent_sdk.types import (
    AssistantMessage,
    HookContext,
    HookInput,
    HookJSONOutput,
    HookMatcher,
    Message,
    ResultMessage,
    TextBlock,
)

# Set up logging to see what's happening
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s")
logger = logging.getLogger(__name__)


def display_message(msg: Message) -> None:
    """Standardized message display function."""
    if isinstance(msg, AssistantMessage):
        for block in msg.content:
            if isinstance(block, TextBlock):
                print(f"Claude: {block.text}")
    elif isinstance(msg, ResultMessage):
        print("Result ended")


##### Hook callback functions
async def check_bash_command(
    input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
    """Prevent certain bash commands from being executed."""
    tool_name = input_data["tool_name"]
    tool_input = input_data["tool_input"]

    if tool_name != "Bash":
        return {}

    command = tool_input.get("command", "")
    block_patterns = ["foo.sh"]

    for pattern in block_patterns:
        if pattern in command:
            logger.warning(f"Blocked command: {command}")
            return {
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "deny",
                    "permissionDecisionReason": f"Command contains invalid pattern: {pattern}",
                }
            }

    return {}


async def add_custom_instructions(
    input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
    """Add custom instructions when a session starts."""
    return {
        "hookSpecificOutput": {
            "hookEventName": "SessionStart",
            "additionalContext": "My favorite color is hot pink",
        }
    }


async def review_tool_output(
    input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
    """Review tool output and provide additional context or warnings."""
    tool_response = input_data.get("tool_response", "")

    # If the tool produced an error, add helpful context
    if "error" in str(tool_response).lower():
        return {
            "systemMessage": "⚠️ The command produced an error",
            "reason": "Tool execution failed - consider checking the command syntax",
            "hookSpecificOutput": {
                "hookEventName": "PostToolUse",
                "additionalContext": "The command encountered an error. You may want to try a different approach.",
            }
        }

    return {}


async def strict_approval_hook(
    input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
    """Demonstrates using permissionDecision to control tool execution."""
    tool_name = input_data.get("tool_name")
    tool_input = input_data.get("tool_input", {})

    # Block any Write operations to specific files
    if tool_name == "Write":
        file_path = tool_input.get("file_path", "")
        if "important" in file_path.lower():
            logger.warning(f"Blocked Write to: {file_path}")
            return {
                "reason": "Writes to files containing 'important' are not allowed for safety",
                "systemMessage": "🚫 Write operation blocked by security policy",
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "deny",
                    "permissionDecisionReason": "Security policy blocks writes to important files",
                },
            }

    # Allow everything else explicitly
    return {
        "reason": "Tool use approved after security review",
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "allow",
            "permissionDecisionReason": "Tool passed security checks",
        },
    }


async def stop_on_error_hook(
    input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
    """Demonstrates using continue=False to stop execution on certain conditions."""
    tool_response = input_data.get("tool_response", "")

    # Stop execution if we see a critical error
    if "critical" in str(tool_response).lower():
        logger.error("Critical error detected - stopping execution")
        return {
            "continue_": False,
            "stopReason": "Critical error detected in tool output - execution halted for safety",
            "systemMessage": "🛑 Execution stopped due to critical error",
        }

    return {"continue_": True}


async def example_pretooluse() -> None:
    """Basic example demonstrating hook protection."""
    print("=== PreToolUse Example ===")
    print("This example demonstrates how PreToolUse can block some bash commands but not others.\n")

    # Configure hooks using ClaudeAgentOptions
    options = ClaudeAgentOptions(
        allowed_tools=["Bash"],
        hooks={
            "PreToolUse": [
                HookMatcher(matcher="Bash", hooks=[check_bash_command]),
            ],
        }
    )

    async with ClaudeSDKClient(options=options) as client:
        # Test 1: Command with forbidden pattern (will be blocked)
        print("Test 1: Trying a command that our PreToolUse hook should block...")
        print("User: Run the bash command: ./foo.sh --help")
        await client.query("Run the bash command: ./foo.sh --help")

        async for msg in client.receive_response():
            display_message(msg)

        print("\n" + "=" * 50 + "\n")

        # Test 2: Safe command that should work
        print("Test 2: Trying a command that our PreToolUse hook should allow...")
        print("User: Run the bash command: echo 'Hello from hooks example!'")
        await client.query("Run the bash command: echo 'Hello from hooks example!'")

        async for msg in client.receive_response():
            display_message(msg)

    print("\n")


async def main() -> None:
    """Run the PreToolUse example."""
    await example_pretooluse()


if __name__ == "__main__":
    print("Starting Claude SDK Hooks Example...")
    print("=" * 50 + "\n")
    asyncio.run(main())

Hook Execution Order

  1. SessionStart - When a new session begins
  2. UserPromptSubmit - When a user submits a prompt
  3. PreToolUse - Before each tool is executed
  4. PostToolUse - After each tool completes
Multiple hooks of the same type execute in the order they’re defined.

Key Takeaways

  • Use hooks for advanced control over SDK behavior
  • PreToolUse hooks control permissions before execution
  • PostToolUse hooks validate and respond to results
  • UserPromptSubmit hooks inject context
  • Use permissionDecision to explicitly allow/deny operations
  • Use continue_=False to stop execution on critical conditions
  • Configure hooks in the hooks parameter of ClaudeAgentOptions

Next Steps

Tool Permissions

Simpler permission control with callbacks

API Reference

Complete hooks API documentation

Build docs developers (and LLMs) love