Claude Agent SDK Hooks: Block, Modify, and Audit Tool Calls (2026)
Register PreToolUse and PostToolUse hooks in the Claude Agent SDK to block dangerous tool calls, rewrite a tool's arguments, and audit every action. Copy-paste runnable Python and TypeScript, August 2026.
On this page
Quick answer
(August 2026) Hooks in the Claude Agent SDK are callback functions you register in options.hooks that run your own code at fixed points in the agent's lifecycle, like right before a tool runs (PreToolUse) or right after (PostToolUse). A PreToolUse hook can block a tool call by returning permissionDecision: "deny", rewrite its arguments with updatedInput, or auto-approve it with "allow". You wire them in with a HookMatcher in Python or a plain matcher object in TypeScript, and a matcher string like "Bash" or "Write|Edit" limits which tools fire the hook. Hooks are how you add deterministic, non-negotiable rules to an otherwise probabilistic agent. Every snippet below is copy-paste runnable against the 2026 SDK.
A model decides what to do. A hook decides what the model is allowed to actually do. That is the whole point. You can tell Claude in a system prompt "never touch
.env files," and most of the time it will listen. Hooks make "most of the time" into "every time," because they are ordinary code that runs unconditionally, outside the model's judgment. This tutorial builds up from a single blocking hook to a full audit layer, in both Python and TypeScript, using the exact API shipped in the 2026 SDK.
SDK hooks vs Claude Code hooks (they are not the same thing)
Search for "claude hooks" and you get two different mechanisms tangled together. Getting them straight saves an afternoon.
- Claude Code hooks are shell commands you list in a settings file (
.claude/settings.json). Claude Code runs the command, passes event JSON on stdin, and reads your decision from stdout. They work without writing any SDK code. - Claude Agent SDK hooks (this tutorial) are in-process callback functions you pass to
options.hooksin Python or TypeScript. No subprocess, no JSON-over-stdin. Your function receives typed input and returns a decision object directly.
They share the same event names and the same JSON output shape, so the concepts transfer. But if you are building an application with the SDK, you want callback hooks: they run in your process, share your imports and state, and skip the shell round-trip. You can still load shell-command hooks from a settings file by passing setting_sources=["project"] (settingSources: ["project"] in TypeScript), which matters for two events that are shell-only in Python, covered near the end.
Prerequisites
- Python 3.10+ with
claude-agent-sdkinstalled (pip install claude-agent-sdk), or Node 18+ with@anthropic-ai/claude-agent-sdk. ANTHROPIC_API_KEYset in your environment.- A working agent already running. If you do not have one, start with the Python quickstart, then come back.
Register your first PreToolUse hook
A hook callback takes three arguments, input_data, tool_use_id, and context, and returns a decision object. Returning {} means "allow, no changes." Here is the canonical guard: block any attempt to write a .env file.
import asyncio
from claude_agent_sdk import (
AssistantMessage,
ClaudeSDKClient,
ClaudeAgentOptions,
HookMatcher,
ResultMessage,
)
async def protect_env_files(input_data, tool_use_id, context):
file_path = input_data["tool_input"].get("file_path", "")
file_name = file_path.split("/")[-1]
if file_name == ".env":
return {
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "deny",
"permissionDecisionReason": "Cannot modify .env files",
}
}
# Empty object means allow, unchanged
return {}
async def main():
options = ClaudeAgentOptions(
hooks={
"PreToolUse": [HookMatcher(matcher="Write|Edit", hooks=[protect_env_files])]
}
)
async with ClaudeSDKClient(options=options) as client:
await client.query("Create a .env file with the standard local dev database config")
async for message in client.receive_response():
if isinstance(message, (AssistantMessage, ResultMessage)):
print(message)
asyncio.run(main())
The same hook in TypeScript. Note that input is typed loosely, so you cast it to the specific hook input to reach tool_input:
import { query, HookCallback, PreToolUseHookInput } from "@anthropic-ai/claude-agent-sdk";
const protectEnvFiles: HookCallback = async (input, toolUseID, { signal }) => {
const preInput = input as PreToolUseHookInput;
const toolInput = preInput.tool_input as Record;
const filePath = toolInput?.file_path as string;
const fileName = filePath?.split("/").pop();
if (fileName === ".env") {
return {
hookSpecificOutput: {
hookEventName: preInput.hook_event_name,
permissionDecision: "deny",
permissionDecisionReason: "Cannot modify .env files",
},
};
}
return {};
};
for await (const message of query({
prompt: "Create a .env file with the standard local dev database config",
options: {
hooks: {
PreToolUse: [{ matcher: "Write|Edit", hooks: [protectEnvFiles] }],
},
},
})) {
if (message.type === "assistant" || message.type === "result") {
console.log(message);
}
}
Run it and Claude tries to create the file, the hook denies the call, and Claude's final answer explains it cannot write .env files. The matcher string "Write|Edit" means the hook only fires for the Write and Edit tools. Omit the matcher and it fires for every tool.
Block a dangerous shell command with a reason
The .env guard keys off a file path. Real guards often key off the command itself. This PreToolUse hook watches Bash calls and refuses destructive deletes, and it adds a systemMessage so the person watching the session sees why:
async def block_destructive_bash(input_data, tool_use_id, context):
command = input_data["tool_input"].get("command", "")
banned = ["rm -rf /", "rm -rf ~", "mkfs", ":(){ :|:& };:"]
if any(token in command for token in banned):
return {
"systemMessage": "Blocked a destructive shell command.",
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "deny",
"permissionDecisionReason": (
"This command can destroy the machine or filesystem and is not allowed."
),
},
}
return {}
options = ClaudeAgentOptions(
hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[block_destructive_bash])]}
)
Two fields do two different jobs. permissionDecisionReason is fed back to the model so it understands the refusal and does not blindly retry. systemMessage is shown to the user, not the model. Keep them distinct: the model needs a reason it can act on, the human needs a status line.
Rewrite a tool's input before it runs
A hook does not have to be a wall. It can be a filter. Return updatedInput inside hookSpecificOutput and the agent runs the tool with your modified arguments. This example sandboxes every write by prepending /tmp/sandbox to the path:
async def redirect_to_sandbox(input_data, tool_use_id, context):
if input_data["hook_event_name"] != "PreToolUse":
return {}
if input_data["tool_name"] == "Write":
original_path = input_data["tool_input"].get("file_path", "")
return {
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "allow",
"updatedInput": {
**input_data["tool_input"],
"file_path": f"/tmp/sandbox{original_path}",
},
}
}
return {}
Three rules keep this from silently failing:
updatedInputmust live insidehookSpecificOutput, never at the top level. This is the single most common mistake.- Always build a new dict (
{**input_data["tool_input"], ...}); do not mutate the originaltool_input. - Pair it with
permissionDecision: "allow"to auto-approve the rewrite, or"ask"to show the changed input to the user. If you omitpermissionDecisionthe modified input still applies and flows through normal permission checks. Do not pair it with"defer", which drops the change.
Audit every tool call with PostToolUse
PreToolUse fires before a tool runs; PostToolUse fires after, with the result attached. It is where logging and audit trails belong. This hook records every tool call to a JSONL file:
import json
from datetime import datetime, timezone
async def audit_logger(input_data, tool_use_id, context):
if input_data["hook_event_name"] != "PostToolUse":
return {}
record = {
"ts": datetime.now(timezone.utc).isoformat(),
"tool": input_data["tool_name"],
"tool_use_id": tool_use_id,
"session": input_data.get("session_id"),
}
with open("tool_audit.jsonl", "a") as fh:
fh.write(json.dumps(record) + "\n")
return {}
options = ClaudeAgentOptions(
hooks={"PostToolUse": [HookMatcher(hooks=[audit_logger])]}
)
No matcher here, so it logs every tool. The tool_use_id is the same value your PreToolUse hook saw, so you can correlate the two events for one call, for example to measure how long each tool took.
Fire-and-forget side effects that do not slow the agent
By default the agent waits for your hook to return before continuing. For a guard, that is correct: you want the decision before the tool runs. But for pure side effects (send a webhook, ping Slack, write a metric), waiting is wasted latency. Return an async output and the agent proceeds immediately:
import asyncio
async def async_notifier(input_data, tool_use_id, context):
# Kick off the side effect, then return without waiting
asyncio.create_task(send_to_logging_service(input_data))
return {"async_": True, "asyncTimeout": 30000}
In TypeScript the field is async: true; in Python it is async_ to dodge the reserved keyword. The catch: an async output cannot block, deny, or modify anything, because the agent has already moved on. Use it only for logging, metrics, and notifications. When you make real network calls inside a hook, catch your own errors: an unhandled exception in a hook can interrupt the agent.
The full hook event catalog (Python vs TypeScript)
The SDK fires more than two dozen events, but the two SDKs do not expose the same set as callbacks. This is the parity gap that trips people up. The events available in both SDKs as callbacks:
Scroll to see more
| Event | Fires when | Typical use |
|---|---|---|
PreToolUse | A tool is about to run (can block or modify) | Block dangerous commands |
PostToolUse | A tool returned a result | Audit trail, transform output |
PostToolUseFailure | A tool call failed | Log or handle tool errors |
UserPromptSubmit | The user submitted a prompt | Inject context before the model sees it |
Stop | The agent finished a turn | Save session state |
SubagentStart / SubagentStop | A subagent began or finished | Track parallel subagents |
PreCompact | Conversation is about to be compacted | Archive the full transcript first |
PermissionRequest | A tool call needs a permission decision | Custom permission logic |
Notification | The agent emits a status notification | Forward to Slack or PagerDuty |
TypeScript exposes many more callback events that Python does not, including SessionStart, SessionEnd, PostToolBatch, MessageDisplay, TaskCreated, TaskCompleted, FileChanged, and WorktreeCreate. In Python, SessionStart and SessionEnd are not available as callback hooks at all: the Python HookEvent type omits them. If you need session lifecycle logic in Python, either load them as shell-command hooks (setting_sources=["project"]) or use the first message from client.receive_response() as your "session started" trigger.
The output object: the rules people get wrong
Every hook returns a plain dict (object). Two families of fields:
- Top-level fields apply to any event.
systemMessageshows text to the user.continue(continue_in Python) decides whether the agent keeps running after the hook. hookSpecificOutputcontrols the current operation. ForPreToolUsethis is wherepermissionDecision,permissionDecisionReason, andupdatedInputgo. ForPostToolUseyou can setadditionalContextto append text the model sees, orupdatedToolOutputto replace the tool result before the model reads it.
The precedence rule matters the moment you register more than one hook: deny beats defer beats ask beats allow. When an event fires, all matching hooks run in parallel, and if any one returns deny, the tool is blocked no matter what the others say. Completion order is non-deterministic, so write each hook to stand on its own; never assume another hook ran first. And always include hookEventName inside hookSpecificOutput, or the SDK cannot tell which hook type your output is for.
Timeouts and version gotchas (2026)
Each callback runs under a timeout you set with the timeout field (in seconds) on its HookMatcher. Leave it off and Claude Code applies the event default: 600 seconds for most events, 30 for UserPromptSubmit, 10 for MessageDisplay, and a tight 1.5 seconds for SessionEnd during shutdown. When a callback exceeds its timeout the SDK cancels it, discards its output, and continues.
Two version notes worth pinning if you run unattended agents:
- Before v2.1.210, a timed-out
PreToolUsecallback was reported to Claude as a user rejection, which made headless sessions stop and wait for input. On v2.1.210+ the turn continues with a timeout notice instead. - Before v2.1.227, a hook's
systemMessagesurfaced in the message stream only forSessionStartandSetuphooks. On v2.1.227+ it can surface as anSDKInformationalMessagefor more events. If yoursystemMessageseems to vanish, this is usually why; to pass information to the model rather than the user, returnadditionalContextinstead.
Common mistakes
- Wrong casing. Event keys are case sensitive:
PreToolUse, notpreToolUse. A misspelled key silently registers nothing. - Matching on the wrong thing.
matchertests the tool name only, never a file path or argument. To filter by path, let the hook fire and checktool_input.file_pathinside the callback. updatedInputat the top level. It must sit insidehookSpecificOutput, or the change is ignored.- Throwing from a hook. An unhandled exception can interrupt the agent. Wrap network calls in try/except and log instead of raising.
- Recursive loops. A
UserPromptSubmithook that spawns subagents can trigger itself again through those subagents. Guard against it by checking for a subagent indicator in the input or tracking state before you spawn.
Where hooks fit
Hooks are the deterministic layer around a non-deterministic core. Custom tools decide what an agent can do; hooks decide what it is allowed to do, and record what it did. If you are still assembling the agent itself, the custom tools tutorial is the natural next step, and for the reference-level detail on every field and event, the official hooks documentation, the runnable hooks.py example, and the Claude Code hooks reference are the sources this guide was verified against.
FAQ
Common questions about Claude Agent SDK hooks, answered.
Written by
Ren OkabeRen builds agent infrastructure and writes copy-paste tutorials for engineers shipping LLM tool-use systems.
Frequently asked questions
What are hooks in the Claude Agent SDK?
Hooks are callback functions you register in the hooks field of ClaudeAgentOptions (Python) or the options object (TypeScript). The SDK runs them at fixed lifecycle points, such as before a tool call (PreToolUse) or after one (PostToolUse). A hook can block an action, rewrite its input, auto-approve it, or just log it, which lets you add deterministic rules to an otherwise probabilistic agent.
How do I block a tool call with a Claude Agent SDK hook?
Register a PreToolUse hook and return a dict with hookSpecificOutput containing permissionDecision set to "deny" and a permissionDecisionReason. The reason is fed back to the model so it does not retry. If any registered hook returns deny, the tool is blocked, because deny takes precedence over defer, ask, and allow.
What is the difference between Claude Agent SDK hooks and Claude Code hooks?
Claude Code hooks are shell commands defined in a settings file that receive event JSON on stdin. Claude Agent SDK hooks are in-process callback functions passed to options.hooks in Python or TypeScript. They share the same event names and JSON output shape, but SDK callback hooks run inside your application process with no subprocess or stdin round-trip.
Why is my Claude Agent SDK hook not firing?
The usual causes are a case-sensitive event name typo (use PreToolUse, not preToolUse), a matcher that does not match the tool name (matchers test the tool name only, never file paths or arguments), or registering the hook under the wrong event key. Hooks also may not run if the agent hits its max_turns limit and the session ends first.
Are SessionStart and SessionEnd hooks available in the Python SDK?
Not as callback hooks. The Python HookEvent type omits SessionStart and SessionEnd, so in Python they are only available as shell-command hooks loaded from a settings file via setting_sources. In TypeScript they can be registered as normal callback hooks. As a Python workaround, use the first message from client.receive_response() as your session-start trigger.
Related tutorials
Claude Agent SDK Custom Tools: Give Your Agent Its Own Functions (2026)
A runnable 2026 guide to custom tools in the Claude Agent SDK: define functions with tool() / @tool, bundle them into an in-process MCP server, register with allowedTools, and gate risky calls per argument with canUseTool. TypeScript and Python.
Claude Agent SDK Subagents: Isolate Context and Run Tasks in Parallel (2026)
Define subagents in the Claude Agent SDK with the agents parameter and AgentDefinition: isolate context, run focused subtasks in parallel, restrict each one's tools, and confirm delegation. Runnable Python and TypeScript, August 2026.
How to Build an AI Agent With the Claude Agent SDK in Python (2026)
A runnable Python quickstart: install the Claude Agent SDK, stream a run with query(), then give the agent your own tools with the @tool decorator and ClaudeSDKClient. Builds a weather agent that chains two tools.