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
- An event fires, such as
PreToolUsewhen Claude asks to run a tool. - 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). - Matchers filter them. A hook with
matcher: "Bash"only runs for Bash. No matcher means every occurrence. - Each callback runs with details of the event.
- 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
| Event | Python | Fires when | Good for |
|---|---|---|---|
PreToolUse | Yes | Claude requests a tool. Can block or modify. | Guardrails, input rewriting |
PostToolUse | Yes | A tool returned | Audit logs, adding context, replacing output |
PostToolUseFailure | Yes | A tool errored | Error reporting |
PostToolBatch | No | A whole batch of tool calls has resolved, once, before the next model call | Adding one note for the batch |
PermissionRequest | Yes | A call needs a permission decision | Custom approval logic |
PermissionDenied | No | Auto mode denied a call, including denials without a classifier verdict | Logging; telling the model it may retry (retry: true is ignored for no-verdict denials) |
Prompt and message events
| Event | Python | Fires when | Good for |
|---|---|---|---|
UserPromptSubmit | Yes | A prompt is submitted, including turns Claude Code starts itself | Injecting context |
UserPromptExpansion | No | A typed command or MCP prompt expands into a prompt. Not when Claude invokes a skill itself. | Blocking direct use of a command |
MessageDisplay | No | An assistant text message completes, once per message | Redacting or reformatting what is shown, without changing the transcript |
Lifecycle events
| Event | Python | Fires when |
|---|---|---|
Stop | Yes | The agent stops |
StopFailure | No | The turn ends with an API error |
SubagentStart | Yes | A subagent starts |
SubagentStop | Yes | A subagent finishes |
PreCompact | Yes | Compaction is requested |
PostCompact | No | Compaction finished |
PreModelSwitch | No | Before a requested model switch (can block) |
PostModelSwitch | No | The model changed, including automatic fallback |
SessionStart | No | The session starts |
SessionEnd | No | The session ends |
Setup | No | Session setup or maintenance |
Notification | Yes | Agent status notifications |
ConfigChange | No | A configuration file changed |
InstructionsLoaded | No | A CLAUDE.md or rules file entered context |
CwdChanged | No | The working directory changed |
FileChanged | No | A watched file was created, changed or deleted |
DirectoryAdded | No | A working directory was added mid-session |
WorktreeCreate | No | A git worktree was created |
WorktreeRemove | No | A worktree created by a WorktreeCreate hook is being removed |
Team, task and MCP events (TypeScript only)
| Event | Fires when |
|---|---|
TeammateIdle | A teammate goes idle |
TaskCreated | A task is created with TaskCreate |
TaskCompleted | A task is marked done |
Elicitation | An MCP server asks for user input mid-task |
ElicitationResult | A 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:
| Field | Type | Meaning |
|---|---|---|
matcher | string, optional | Pattern 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. |
hooks | callback[] | Required. The callbacks to run. |
timeout | number, optional | Seconds. 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:
- Input data. A typed object for the event. All events include
session_id,cwdandhook_event_name.PreToolUseaddstool_nameandtool_input;Notificationaddsmessage. Inside a subagent,agent_idandagent_typeare set: in TypeScript on every event's base input, in Python as optional fields onPreToolUse,PostToolUse,PostToolUseFailureandPermissionRequestand as required fields onSubagentStartandSubagentStop. - Tool use ID. Lets you pair a
PreToolUsewith itsPostToolUse. - Context. In TypeScript, it carries
signal, anAbortSignalthat 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.
| Event | Field | Effect |
|---|---|---|
PreToolUse | permissionDecision | allow, deny, ask or defer |
PreToolUse | permissionDecisionReason | Explanation Claude sees |
PreToolUse | updatedInput | Replacement tool input |
PostToolUse | additionalContext | Text appended to the tool result |
PostToolUse | updatedToolOutput | Replaces the tool output Claude sees, for any tool |
PostToolUse | updatedMCPToolOutput | Older field; replaces MCP tool output only |
PostToolUse (TS) | classifierContext | Short 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
allowto auto-approve the rewrite, oraskto show it to the user. With no decision, the new input goes through normal permission checks. Withdefer, 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 yourcanUseToolcallback (TypeScript SDK v0.3.233+ or Python SDK v0.2.139+);elicitation_completeandelicitation_responsein 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:
| Event | Default |
|---|---|
| Most events | 600 s |
UserPromptSubmit, PreModelSwitch, PostModelSwitch | 30 s |
MessageDisplay | 10 s |
SessionEnd | Runs during shutdown under the SessionEnd budget, 1.5 s by default |
A timed-out callback is cancelled and its output discarded. What happens next:
| Event | On timeout |
|---|---|
PreToolUse | The 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, PostToolUseFailure | Tool result kept, turn continues |
UserPromptSubmit, UserPromptExpansion | Prompt blocked with a message naming the hook. Never let through unscreened. (Before v2.1.208 the query ended with error_during_execution.) |
Stop, SubagentStop | Counts as no decision; other hooks' decisions still apply. (Before v2.1.273 it counted as a failure and other decisions were discarded.) |
SessionStart | Counts as no output; others still apply |
PreModelSwitch | Switch blocked |
| Others | Logged, 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, notpreToolUse. - The matcher must match the tool name.
- Check the hook is under the right event key.
Stopignores matchers;NotificationandSubagentStopmatch other fields.- Hitting
maxTurnscan 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.