obsidian/wiki/agent-sdk/sdk-hooks.md
2026-04-17 12:53:08 +01:00

8.7 KiB

title aliases tags sources created updated
Intercept and Control Agent Behavior with SDK Hooks
sdk-hooks
agent-hooks
hook-callbacks
agent-sdk
hooks
python
typescript
security
permissions
raw/Intercept and control agent behavior with hooks.md
2026-04-17 2026-04-17

Intercept and Control Agent Behavior with SDK Hooks

SDK hooks are async callback functions registered in ClaudeAgentOptions that fire at key execution points. Unlike wiki/agent-sdk/hooks-guide (defined in settings files), these run in-process alongside your agent code.

What Hooks Can Do

  • Block dangerous operations before they execute (permissionDecision: "deny")
  • Modify tool inputs before execution (updatedInput)
  • Auto-approve specific tools without user prompts (permissionDecision: "allow")
  • Log / audit every tool call for compliance or debugging
  • Inject context into the conversation the model sees (systemMessage)
  • Track lifecycle — subagent start/stop, session boundaries, compaction

Available Hook Events

Hook Event Python TypeScript Trigger
PreToolUse Yes Yes Before tool executes — can block or modify
PostToolUse Yes Yes After tool succeeds
PostToolUseFailure Yes Yes After tool fails
UserPromptSubmit Yes Yes When user prompt is submitted
Stop Yes Yes Agent execution stops
SubagentStart Yes Yes Subagent initializes
SubagentStop Yes Yes Subagent completes
PreCompact Yes Yes Before conversation compaction
PermissionRequest Yes Yes Before permission dialog
Notification Yes Yes Agent status messages
SessionStart No Yes Session init (Python: use shell hooks instead)
SessionEnd No Yes Session ends
TaskCompleted No Yes Background task finishes
ConfigChange No Yes Config file changes
WorktreeCreate No Yes Git worktree created
WorktreeRemove No Yes Git worktree removed

Configuration

from claude_agent_sdk import ClaudeAgentOptions, HookMatcher

options = ClaudeAgentOptions(
    hooks={
        "PreToolUse": [HookMatcher(matcher="Write|Edit", hooks=[my_callback])]
    }
)
  • Keys = hook event names (case-sensitive: PreToolUse not preToolUse)
  • Values = list of HookMatcher objects

Matchers

HookMatcher(matcher="...", hooks=[...], timeout=60)

  • matcher — regex against tool name (e.g. "Write|Edit", "^mcp__", "Bash")
  • Omit matcher to match all occurrences of the event
  • Matchers filter by tool name only — filter by file path inside the callback

Built-in tool names: Bash, Read, Write, Edit, Glob, Grep, WebFetch, Agent
MCP tools: mcp__<server>__<action> (e.g. mcp__playwright__browser_click)

Callback Inputs & Outputs

Inputs (3 args)

  1. input_data — typed dict with event details; all share session_id, cwd, hook_event_name
  2. tool_use_id — correlates PreToolUsePostToolUse for the same call
  3. contextAbortSignal in TypeScript; reserved in Python

Output Fields

Field Level Effect
systemMessage top-level Injects text into conversation (model sees it)
continue (continue_ in Python) top-level Stop agent after this hook if False
hookSpecificOutput.permissionDecision nested "allow" / "deny" / "ask"
hookSpecificOutput.permissionDecisionReason nested Reason string shown to model
hookSpecificOutput.updatedInput nested Replacement tool input (must also set allow)
hookSpecificOutput.additionalContext nested Appended to tool result (PostToolUse only)

Return {} to allow the operation unchanged.

Priority: deny > ask > allow — if any hook denies, the operation is blocked.

Async (fire-and-forget) output

For side effects (logging, webhooks) that shouldn't block the agent:

async def async_hook(input_data, tool_use_id, context):
    asyncio.create_task(send_to_logging_service(input_data))
    return {"async_": True, "asyncTimeout": 30000}

Cannot block, modify, or inject context — agent has already moved on.

Common Patterns

Block dangerous paths

async def protect_env_files(input_data, tool_use_id, context):
    file_path = input_data["tool_input"].get("file_path", "")
    if file_path.split("/")[-1] == ".env":
        return {
            "hookSpecificOutput": {
                "hookEventName": input_data["hook_event_name"],
                "permissionDecision": "deny",
                "permissionDecisionReason": "Cannot modify .env files",
            }
        }
    return {}

Redirect writes to sandbox

async def redirect_to_sandbox(input_data, tool_use_id, context):
    if input_data["tool_name"] == "Write":
        original = input_data["tool_input"].get("file_path", "")
        return {
            "hookSpecificOutput": {
                "hookEventName": input_data["hook_event_name"],
                "permissionDecision": "allow",          # required with updatedInput
                "updatedInput": {**input_data["tool_input"], "file_path": f"/sandbox{original}"},
            }
        }
    return {}

Auto-approve read-only tools

async def auto_approve_read_only(input_data, tool_use_id, context):
    if input_data["tool_name"] in ["Read", "Glob", "Grep"]:
        return {
            "hookSpecificOutput": {
                "hookEventName": input_data["hook_event_name"],
                "permissionDecision": "allow",
            }
        }
    return {}

Chain hooks (single responsibility per hook)

options = ClaudeAgentOptions(
    hooks={
        "PreToolUse": [
            HookMatcher(hooks=[rate_limiter]),
            HookMatcher(hooks=[authorization_check]),
            HookMatcher(hooks=[input_sanitizer]),
            HookMatcher(hooks=[audit_logger]),
        ]
    }
)

Hooks execute in array order. Chain for complex logic, keep each focused.

Forward notifications to Slack

async def notification_handler(input_data, tool_use_id, context):
    try:
        await asyncio.to_thread(_send_slack, input_data.get("message", ""))
    except Exception as e:
        print(f"Slack failed: {e}")  # swallow — don't interrupt agent
    return {}

options = ClaudeAgentOptions(
    hooks={"Notification": [HookMatcher(hooks=[notification_handler])]}
)

Notification types: permission_prompt, idle_prompt, auth_success, elicitation_dialog.

Troubleshooting

Problem Fix
Hook not firing Check event name case; verify matcher regex; check max_turns not hit
Matcher too broad Empty matcher fires on ALL tools — always use matcher= when possible
updatedInput not applied Must be inside hookSpecificOutput, must also include permissionDecision: "allow", must include hookEventName
SessionStart/SessionEnd missing in Python Not in Python SDK — use shell hooks via setting_sources=["project"]
Subagent permission storms Use PreToolUse to auto-approve tools for subagents; they don't inherit parent permissions
Recursive loops UserPromptSubmit hooks spawning subagents can loop — check agent_id to guard

Key Takeaways

  • SDK hooks (HookMatcher + async callbacks) run in-process; shell command hooks run in .claude/settings.json — they complement each other
  • PreToolUse is the workhorse: block, modify inputs, or auto-approve
  • Always return {} to pass through; never mutate tool_input in place — return a new object
  • deny beats ask beats allow — multiple hooks compose safely
  • SessionStart/SessionEnd are TypeScript-only in the SDK; Python gets them via shell hooks + setting_sources
  • Use async output (async_: True) for logging/webhook side effects to avoid blocking the agent
  • Matchers are tool-name regex only — filter by file path inside the callback body

Source: raw/Intercept and control agent behavior with hooks.md