Skip to content

Hooks in the SDK

Register callback hooks in Agent SDK apps to block, rewrite, log or annotate tool calls and lifecycle events, with every event, output field and timeout.

Hooks are functions in your own process that the SDK calls at defined moments: just before a tool runs, just after, when a subagent finishes, when the session stops, and so on. They are the most reliable control point you have, because a PreToolUse hook runs before every other permission check.

Typical jobs for hooks:

  • refusing dangerous commands or protected paths;
  • writing an audit trail of every tool call;
  • rewriting tool input, for example redirecting writes into a sandbox;
  • inserting context for Claude after a tool runs;
  • posting status updates to Slack or a pager.

The life of a hook

  1. An event fires, such as PreToolUse when Claude asks to run a tool.
  2. The SDK gathers hooks for that event. That means callbacks in options.hooks, plus shell command hooks from settings files when the matching setting source is on (it is by default).
  3. Matchers filter them. A hook with matcher: "Bash" only runs for Bash. No matcher means every occurrence.
  4. Each callback runs with details of the event.
  5. Each callback returns a decision: carry on, block, change the input, or add context.

Here is a complete example that stops the agent touching anything under migrations/applied/, which in one of my projects holds migrations that have already run in production:

import { query, type HookCallback, type PreToolUseHookInput } from "@anthropic-ai/claude-agent-sdk";

const protectAppliedMigrations: HookCallback = async (input) => {
  const pre = input as PreToolUseHookInput;
  const path = String((pre.tool_input as Record<string, unknown>).file_path ?? "");
  if (path.includes("/migrations/applied/")) {
    return {
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: "Applied migrations are immutable. Write a new migration instead."
      }
    };
  }
  return {};
};

for await (const msg of query({
  prompt: "Add a NOT NULL constraint to customers.email",
  options: { hooks: { PreToolUse: [{ matcher: "Write|Edit", hooks: [protectAppliedMigrations] }] } }
})) {
  if (msg.type === "result") console.log(msg.subtype);
}
from claude_agent_sdk import ClaudeAgentOptions, HookMatcher

async def protect_applied_migrations(input_data, tool_use_id, context):
    path = input_data["tool_input"].get("file_path", "")
    if "/migrations/applied/" in path:
        return {"hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": "Applied migrations are immutable. Write a new migration instead.",
        }}
    return {}

opts = ClaudeAgentOptions(hooks={
    "PreToolUse": [HookMatcher(matcher="Write|Edit", hooks=[protect_applied_migrations])]
})

The reason string goes back to Claude, so it learns what to do instead of retrying.

Events

Python supports a subset; TypeScript supports all of them.

Tool events

EventPythonFires whenGood for
PreToolUseYesClaude requests a tool. Can block or modify.Guardrails, input rewriting
PostToolUseYesA tool returnedAudit logs, adding context, replacing output
PostToolUseFailureYesA tool erroredError reporting
PostToolBatchNoA whole batch of tool calls has resolved, once, before the next model callAdding one note for the batch
PermissionRequestYesA call needs a permission decisionCustom approval logic
PermissionDeniedNoAuto mode denied a call, including denials without a classifier verdictLogging; telling the model it may retry (retry: true is ignored for no-verdict denials)

Prompt and message events

EventPythonFires whenGood for
UserPromptSubmitYesA prompt is submitted, including turns Claude Code starts itselfInjecting context
UserPromptExpansionNoA typed command or MCP prompt expands into a prompt. Not when Claude invokes a skill itself.Blocking direct use of a command
MessageDisplayNoAn assistant text message completes, once per messageRedacting or reformatting what is shown, without changing the transcript

Lifecycle events

EventPythonFires when
StopYesThe agent stops
StopFailureNoThe turn ends with an API error
SubagentStartYesA subagent starts
SubagentStopYesA subagent finishes
PreCompactYesCompaction is requested
PostCompactNoCompaction finished
PreModelSwitchNoBefore a requested model switch (can block)
PostModelSwitchNoThe model changed, including automatic fallback
SessionStartNoThe session starts
SessionEndNoThe session ends
SetupNoSession setup or maintenance
NotificationYesAgent status notifications
ConfigChangeNoA configuration file changed
InstructionsLoadedNoA CLAUDE.md or rules file entered context
CwdChangedNoThe working directory changed
FileChangedNoA watched file was created, changed or deleted
DirectoryAddedNoA working directory was added mid-session
WorktreeCreateNoA git worktree was created
WorktreeRemoveNoA worktree created by a WorktreeCreate hook is being removed

Team, task and MCP events (TypeScript only)

EventFires when
TeammateIdleA teammate goes idle
TaskCreatedA task is created with TaskCreate
TaskCompletedA task is marked done
ElicitationAn MCP server asks for user input mid-task
ElicitationResultA user answered an MCP elicitation, before the answer returns to the server

Full input and output schemas for every event are in the hooks reference.

Registering hooks

hooks is a map from event name to a list of matcher entries. Each entry has:

FieldTypeMeaning
matcherstring, optionalPattern tested against the event's filter field. For tool events that is the tool name, such as Bash, Read, Write, Edit, Glob, Grep, WebFetch or Agent. MCP tools are mcp__<server>__<tool>, where <server> is your key in mcpServers.
hookscallback[]Required. The callbacks to run.
timeoutnumber, optionalSeconds. Defaults to the event's own default (see below).

Matchers follow the same rules as settings-file hooks: a pipe-separated list such as Write|Edit|NotebookEdit is an exact list, and something like ^mcp__ is treated as a regular expression. Non-tool events match on other fields (Notification matches the notification type) and Stop ignores matchers. The hooks reference has the exact rules per event.

opts = ClaudeAgentOptions(hooks={
    "PreToolUse": [
        HookMatcher(matcher="Write|Edit|NotebookEdit", hooks=[check_paths]),
        HookMatcher(matcher="^mcp__", hooks=[audit_mcp]),
        HookMatcher(hooks=[log_everything]),
    ],
    "SubagentStop": [HookMatcher(hooks=[record_subagent])],
})

All hooks that match an event run in parallel, and finish in no particular order, so make each one self-contained.

Callback inputs

Every callback receives three arguments:

  1. Input data. A typed object for the event. All events include session_id, cwd and hook_event_name. PreToolUse adds tool_name and tool_input; Notification adds message. Inside a subagent, agent_id and agent_type are set: in TypeScript on every event's base input, in Python as optional fields on PreToolUse, PostToolUse, PostToolUseFailure and PermissionRequest and as required fields on SubagentStart and SubagentStop.
  2. Tool use ID. Lets you pair a PreToolUse with its PostToolUse.
  3. Context. In TypeScript, it carries signal, an AbortSignal that fires on timeout. In Python it is reserved.

Callback outputs

Return {} to let things proceed unchanged. Otherwise return some of:

Top-level fields, accepted on every event (though some events discard them or deliver them elsewhere):

  • systemMessage: a message for the user, not the model.
  • continue (continue_ in Python): whether the agent keeps going after this hook.

hookSpecificOutput, which depends on the event. Always include hookEventName.

EventFieldEffect
PreToolUsepermissionDecisionallow, deny, ask or defer
PreToolUsepermissionDecisionReasonExplanation Claude sees
PreToolUseupdatedInputReplacement tool input
PostToolUseadditionalContextText appended to the tool result
PostToolUseupdatedToolOutputReplaces the tool output Claude sees, for any tool
PostToolUseupdatedMCPToolOutputOlder field; replaces MCP tool output only
PostToolUse (TS)classifierContextShort note for the auto mode classifier (TypeScript SDK v0.3.236+)

defer ends the turn with a result whose stop_reason is tool_deferred, so you can resume the call later. classifierContext is weighed by the auto mode classifier, which may treat a user statement you relay in it as user intent, since your callback runs in your own process. The hooks reference explains its length cap and the synchronous-only rule.

When several hooks or rules disagree, the strictest wins: deny beats defer, which beats ask, which beats allow.

SDK callbacks use the same JSON output format as shell command hooks, so everything in the hooks reference applies.

Rewriting input

async def keep_scratch_writes_local(input_data, tool_use_id, context):
    if input_data["tool_name"] != "Write":
        return {}
    original = input_data["tool_input"]["file_path"]
    if not original.startswith("/tmp/"):
        return {}
    return {"hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "allow",
        "updatedInput": {**input_data["tool_input"], "file_path": "./scratch" + original[4:]},
    }}

Rules for updatedInput:

  • It must sit inside hookSpecificOutput, not at the top level.
  • Pair it with allow to auto-approve the rewrite, or ask to show it to the user. With no decision, the new input goes through normal permission checks. With defer, it is ignored.
  • Build a new object; do not mutate tool_input.

Telling both Claude and the user

const noSecretsDir: HookCallback = async (input) => {
  const pre = input as PreToolUseHookInput;
  const p = String((pre.tool_input as any).file_path ?? "");
  if (!p.startsWith("/srv/secrets")) return {};
  return {
    systemMessage: "Blocked an attempt to write into /srv/secrets.",
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "The secrets directory is managed by Vault, not by hand."
    }
  };
};

Fire-and-forget hooks

If a hook only has side effects (metrics, webhooks), return an async output so the agent does not wait:

const shipMetrics: HookCallback = async (input) => {
  pushToStatsd(input).catch(() => {});
  return { async: true, asyncTimeout: 20000 };
};

In Python the key is async_ (since async is reserved). asyncTimeout is in milliseconds. An async hook cannot block, modify or add context, because the agent has already moved on.

Worked examples

Posting to a webhook after each tool

Catch your own errors; never let a failed HTTP call break the agent. In TypeScript, pass signal to fetch so the request is cancelled if the hook times out.

const notifyOps: HookCallback = async (input, _id, { signal }) => {
  if (input.hook_event_name !== "PostToolUse") return {};
  try {
    await fetch("https://ops.example.internal/agent-events", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ tool: (input as any).tool_name, at: Date.now() }),
      signal
    });
  } catch { /* swallow, including AbortError */ }
  return {};
};

In Python, run blocking HTTP in asyncio.to_thread so you do not stall the event loop.

Recording subagent completions

A SubagentStop input includes agent_id, agent_transcript_path and stop_hook_active:

async def record_subagent(input_data, tool_use_id, context):
    log.info("subagent %s done, transcript at %s",
             input_data["agent_id"], input_data["agent_transcript_path"])
    return {}

Notifications

In SDK sessions, Notification hooks fire for:

  • permission_prompt, once a permission request has been waiting about six seconds on your canUseTool callback (TypeScript SDK v0.3.233+ or Python SDK v0.2.139+);
  • elicitation_complete and elicitation_response in elicitation flows.

Types such as idle_prompt, auth_success and elicitation_dialog come from interactive UI that SDK sessions do not run. Each notification has a message and sometimes a title. I forward permission_prompt to Slack so someone knows an agent is waiting on them.

Timeouts

Each callback has a timeout in seconds, set with timeout on its matcher. Defaults:

EventDefault
Most events600 s
UserPromptSubmit, PreModelSwitch, PostModelSwitch30 s
MessageDisplay10 s
SessionEndRuns during shutdown under the SessionEnd budget, 1.5 s by default

A timed-out callback is cancelled and its output discarded. What happens next:

EventOn timeout
PreToolUseThe tool does not run; Claude gets a result saying the hook did not respond, and the turn continues. An explicit deny from another hook takes precedence. (Before v2.1.210 this looked like a user rejection, which stalled unattended runs.)
PostToolUse, PostToolUseFailureTool result kept, turn continues
UserPromptSubmit, UserPromptExpansionPrompt blocked with a message naming the hook. Never let through unscreened. (Before v2.1.208 the query ended with error_during_execution.)
Stop, SubagentStopCounts as no decision; other hooks' decisions still apply. (Before v2.1.273 it counted as a failure and other decisions were discarded.)
SessionStartCounts as no output; others still apply
PreModelSwitchSwitch blocked
OthersLogged, carry on

The first time a Stop or SessionStart callback times out in the main session, an SDKInformationalMessage appears in the stream saying the app driving the session did not respond. It is not repeated while the app stays unresponsive.

If you interrupt a query while a PreToolUse callback is pending, the tool call is cancelled (from v2.1.208).

Troubleshooting

Hook never fires.

  • Event names are case-sensitive: PreToolUse, not preToolUse.
  • The matcher must match the tool name.
  • Check the hook is under the right event key.
  • Stop ignores matchers; Notification and SubagentStop match other fields.
  • Hitting maxTurns can end the session before hooks run.

Matcher does not filter by path. Matchers only see tool names. Check tool_input.file_path inside the callback.

Something is blocked and you do not know why. Log permissionDecisionReason from every PreToolUse hook, and look for empty matchers that catch everything.

SessionStart and SessionEnd in Python. Python's HookEvent type does not include them. Define them as shell command hooks in .claude/settings.json and make sure setting_sources includes "project". For start-up logic in Python, treat the first message from client.receive_response() as your trigger.

Permission prompts multiply with subagents. Each subagent asks separately. Auto-approve with a PreToolUse hook or with permission rules, which subagents inherit.

Recursive loops. A UserPromptSubmit hook that spawns subagents can re-trigger itself. Track whether you are already inside a subagent (agent_id is set) and skip.

systemMessage is not in the stream. It is for the user, not the model. From v2.1.227 it may appear as an SDKInformationalMessage, depending on the event. Before that, only SessionStart and Setup hook output reached the stream; other output appeared only in the lifecycle events added by includeHookEvents (include_hook_events in Python). To give the model context, use additionalContext.