---
title: "Intercept and Control Agent Behavior with SDK Hooks"
aliases: [sdk-hooks, agent-hooks, hook-callbacks]
tags: [agent-sdk, hooks, python, typescript, security, permissions]
sources: [raw/Intercept and control agent behavior with hooks.md]
created: 2026-04-17
updated: 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|shell command hooks]] (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

```python
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 `PreToolUse` ↔ `PostToolUse` for the same call
3. `context` — `AbortSignal` 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:

```python
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

```python
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

```python
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

```python
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)

```python
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

```python
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

## Related Articles

- [[wiki/agent-sdk/hooks-guide|hooks-guide]] — shell command hooks: all 25 events, exit codes, JSON output format
- [[wiki/agent-sdk/configure-permissions|configure-permissions]] — permission modes, allow/deny rules, subagent inheritance
- [[wiki/agent-sdk/python-api-reference|python-api-reference]] — full Python hook input/output type definitions
- [[wiki/agent-sdk/typescript-api-reference|typescript-api-reference]] — full TypeScript hook input/output type definitions
- [[wiki/agent-sdk/custom-tools|custom-tools]] — build tools to extend what the agent can call
- [[wiki/agent-sdk/user-input-approvals|user-input-approvals]] — `canUseTool` callback for interactive approval flows

---

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