
How-Tos
How to block a tool call with Claude Agent SDK hooks
Claude Agent SDK docs show how to register PreToolUse and PostToolUse hooks that can block, change, or log a tool call.
Searcher → Analyst → Writer → Editor · subagentic-20261025-081148
A PreToolUse hook is where a Claude Agent SDK agent can stop or rewrite a tool call before it runs. The event is documented in both the Python and TypeScript SDKs. The event table's example use is blocking dangerous shell commands. PostToolUse, also in both SDKs, runs after the result, and the table's example use is logging file changes to an audit trail. PostToolUseFailure, in both SDKs, fires when tool execution fails.
When an event fires, the SDK collects hooks registered for it. That includes callbacks passed in options.hooks and shell command hooks from settings files when settingSources in TypeScript or setting_sources in Python is enabled. The page says that entry is enabled for default query() options. A matcher filters which hooks run. Without a matcher, the callback runs for every event of that type. The callback receives the tool name, arguments, and session ID, then returns an output that allows the operation, blocks it, modifies the input, or injects context.
Register a PreToolUse hook
Pass hooks in the hooks field of ClaudeAgentOptions in Python, or in the options object passed to query() in TypeScript. Keys are event names such as PreToolUse, PostToolUse, and Stop. Values are arrays of matchers, each with an optional matcher string and a required hooks array of callbacks.
The configure-hooks example matches Bash. my_callback and myCallback stand in for a callback already defined. The matcher only selects Bash calls. It does not deny them.
options = ClaudeAgentOptions(
hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[my_callback])]}
)
async with ClaudeSDKClient(options=options) as client:
await client.query("Your prompt")
async for message in client.receive_response():
print(message)
for await (const message of query({
prompt: "Your prompt",
options: {
hooks: {
PreToolUse: [{ matcher: "Bash", hooks: [myCallback] }]
}
}
})) {
console.log(message);
}
Filter by tool name
For tool hooks, matcher is tested against the tool name. Names on the page include Bash, Read, Write, Edit, Glob, Grep, WebFetch, and Agent. The page points to Tool Input Types for the full list. Fields inside a Bash tool_input are not shown here, so those argument names are unknown.
MCP tools use mcp__<server>__<action>, where <server> is the key in mcpServers. One example registers three PreToolUse matchers: Write|Edit|NotebookEdit for those file tools, ^mcp__ for any name that starts with mcp__, and no matcher for every tool. Exact-string and regular-expression evaluation, including version requirements, follows the settings-file matcher rules.
timeout is seconds. When omitted, Claude Code applies the event's default timeout, and SDK callbacks follow the command hook defaults. Those numbers are not in this text.
Deny the call
A callback receives input data, a tool use ID, and a context. Inputs share session_id, cwd, and hook_event_name. PreToolUse also includes tool_name and tool_input. The tool use ID links that event to PostToolUse for the same call and may be absent. TypeScript context includes signal, an AbortSignal. Python reserves that argument.
Return {} to allow the operation unchanged. systemMessage is shown to the user. continue, written continue_ in Python, decides whether the agent keeps running. Some events discard those fields or deliver them elsewhere. hookSpecificOutput controls the current operation.
For PreToolUse, set permissionDecision, permissionDecisionReason, and updatedInput there. Documented decisions are allow, deny, ask, and defer. Deny stops the call. Allow auto-approves it. Defer ends the turn with a result whose stop_reason is tool_deferred and ignores updatedInput. How to resume is not in this text.
If several PreToolUse hooks disagree, deny outranks defer, which outranks ask, which outranks allow. Matching hooks run in parallel, so write each one to stand alone. One deny blocks the call.
This callback denies a call whose file_path starts with /etc, tells the model why, and shows the user a systemMessage. Register it on PreToolUse with a Write|Edit matcher, in the same hooks-array shape as the Bash example. The page says that if you ask the agent to create a file under /etc, the Write result contains Writing to /etc is not allowed, and no file is created.
async def block_etc_writes(input_data, tool_use_id, context):
file_path = input_data["tool_input"].get("file_path", "")
if file_path.startswith("/etc"):
return {
# Top-level field: message shown to the user
"systemMessage": "Remember: system directories like /etc are protected.",
# hookSpecificOutput: block the operation
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "deny",
"permissionDecisionReason": "Writing to /etc is not allowed",
},
}
return {}
const blockEtcWrites: HookCallback = async (input, toolUseID, { signal }) => {
const preInput = input as PreToolUseHookInput;
const toolInput = preInput.tool_input as Record<string, unknown>;
const filePath = toolInput?.file_path as string;
if (filePath?.startsWith("/etc")) {
return {
// Top-level field: message shown to the user
systemMessage: "Remember: system directories like /etc are protected.",
// hookSpecificOutput: block the operation
hookSpecificOutput: {
hookEventName: preInput.hook_event_name,
permissionDecision: "deny",
permissionDecisionReason: "Writing to /etc is not allowed"
}
};
}
return {};
};
protect_env_files in Python and protectEnvFiles in TypeScript deny a Write or Edit whose file_path ends in /.env, in any letter case, with the same deny fields and a Write|Edit matcher. The check uses the last path segment after a forward-slash split. On Windows, file_path uses backslashes, such as C:\project\.env, and this hook allows that call. If Claude writes the file with a Bash command, the hook does not run. The page points to sandbox configuration for that case.
Rewrite the input, or log the result
Return updatedInput as a new object. Do not mutate tool_input. When tool_name is Write, the redirect example copies the input and sets file_path to /sandbox plus the original path, with permissionDecision allow so the rewrite is auto-approved. Use ask to show the change. If you omit permissionDecision, the modified input still applies and flows through normal permission checks. Defer ignores updatedInput.
macOS does not allow a root-level /sandbox directory. To confirm the redirect, use a writable prefix such as ./sandbox or /tmp/sandbox, then ask the agent to write a file. The Write result should name the path with your prefix.
On PostToolUse, additionalContext appends to the tool result. updatedToolOutput replaces the output before Claude sees it, for any tool in both SDKs. updatedMCPToolOutput is the older field and replaces MCP tool output only. In TypeScript, classifierContext is a note for the auto mode classifier and requires TypeScript Agent SDK v0.3.236 or later. A user statement in that note may be weighed as user intent, because the callback runs in your process. The length cap is not in this text.
For a side effect that must not change the call, return async output so the agent does not wait. Python uses async_ because async is reserved. TypeScript uses async. Optional asyncTimeout is milliseconds, and the example uses 30000. Async output cannot block, modify, or inject context.
What to try next
Read the opening example on the hooks page: a PreToolUse matcher of Write|Edit and a deny return for a file named .env. To check a block, register block_etc_writes or blockEtcWrites under PreToolUse with matcher Write|Edit and ask the agent to create a file under /etc. For an MCP tool, match mcp__<server>__<action> using the server key from mcpServers, or ^mcp__ for every MCP tool name.