What you will be able to do
- Explain why a hook gives a guaranteed business-rule gate and a prompt instruction does not
- Write and register a PreToolUse callback that denies a tool call and gives a reason
- Design a block that sends the agent to another workflow, such as human escalation, when an action like a refund over $500 breaks policy
- Scope a hook to the right tools with a matcher, and know what the matcher can and cannot express
- Choose a hook over prompt-based enforcement when a business rule requires guaranteed compliance, and a prompt when probabilistic compliance is acceptable
- Write a PostToolUse hook that intercepts tool results and normalizes heterogeneous data formats (Unix timestamps, ISO 8601 strings, numeric status codes) from different MCP tools before the model processes them
Key concept
Deterministic tool-call interception — A hook is your own code that the SDK runs at a fixed point in the agent loop, such as just before a tool executes. Its decision applies whatever the model intended, so a business rule enforced there holds on every call. A rule written in a prompt holds only as often as the model chooses to follow it.
1.Guaranteed compliance versus probable compliance
Suppose a support agent may issue refunds, and the business rule is that no refund above $500 ever goes out without a human. You could enforce this in two places. One is the instructions: a system prompt or CLAUDE.md line such as "never refund more than $500." The other is code that inspects each refund call before it runs.
The SDK's feature guide treats these as different tools for different jobs. Project conventions go in CLAUDE.md. Deterministic logic on tool calls (audit, block, transform) goes in hooks. An instruction shapes what the model is likely to do, but the model still writes every tool call itself. On most turns it follows the instruction, but nothing stops the one turn where a long conversation, a persuasive customer or an ambiguous amount leads it to call the tool anyway. A hook doesn't depend on the model's judgement. It sees the actual call and returns a decision.
That is the distinction between deterministic guarantees and probabilistic compliance. Prompt-based enforcement is probabilistic: it raises the odds of the right behaviour but cannot make it certain. A hook is deterministic: the same input always produces the same decision, and the model cannot talk its way past it. So the rule for choosing is simple. When a business rule requires guaranteed compliance, put it in a hook. When an occasional miss is tolerable, such as a tone preference or a formatting habit, a prompt instruction is enough and cheaper to maintain.
| Mechanism | Feature-guide purpose | What it can guarantee |
|---|---|---|
| CLAUDE.md (loaded via settingSources) | Set project conventions your agent always follows | Nothing at call time: the model reads the convention and decides each tool call itself |
| Hooks (hooks parameter or settings-file hooks) | Run deterministic logic on tool calls (audit, block, transform) | Your code sees every matched call and can stop it before it executes |
Hooks intercept in both directions, and the guarantee argument applies to both. PreToolUse hook patterns intercept outgoing tool calls, so they enforce compliance rules such as blocking a refund above a threshold. PostToolUse hook patterns intercept tool results after the tool has run, so they can transform the data before the model processes it. The hooks page lists transforming inputs and outputs to sanitize data among the things hooks are for. A prompt that says "treat a Unix timestamp as a date" is a probabilistic fix for messy tool output; a PostToolUse hook that rewrites the timestamp is a deterministic one. The rest of this lesson covers the outgoing side first, then the result side.
2.Blocking a call with a PreToolUse hook
PreToolUse is the hook event for outgoing tool calls. In the hooks reference it fires before a tool call executes, and it can block that call. The SDK's event table describes its trigger as a tool call request that the hook can block or modify. Listed uses include blocking dangerous operations before they execute and requiring human approval for sensitive actions.
The callback receives the call's details in its input: tool_name, tool_input (the arguments the model chose) and tool_use_id. It returns a decision. The official example below blocks writes to .env files. A refund-limit rule has the same shape: read the amount from tool_input, compare it to the threshold, and deny when it is too high.
async def protect_env_files(input_data, tool_use_id, context):
# Extract the file path from the tool's input arguments
file_path = input_data["tool_input"].get("file_path", "")
file_name = file_path.split("/")[-1]
# Block the operation if targeting a .env file
if file_name == ".env":
return {
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "deny",
"permissionDecisionReason": "Cannot modify .env files",
}
}
# Return empty object to allow the operation
return {}The decision sits under hookSpecificOutput. It echoes the event name, gives permissionDecision: "deny", and adds a human-readable permissionDecisionReason. A deny means the call does not run. The empty return is subtler than the comment suggests. In the hooks reference, a hook that gives no decision leaves the normal permission flow in charge. An empty object means "this hook has no objection." It does not approve the call over any other permission check.
The permission check still applies. Returning no decision leaves the normal permission flow in place, so the hook has only declined to block the call. It has not approved it over the other checks.
An architect registers three independent PreToolUse hooks for the same charge_card tool: one checks fraud signals, one checks the daily spending cap, and one checks account status. During a live call, the fraud-signal hook returns permissionDecision "deny" while the other two both return "allow". What happens to the tool call?
Correct answer: A — The call is blocked, because when multiple hooks disagree the most restrictive result applies and any single "deny" overrides the other hooks' "allow" decisions
- A. Correct. When multiple hooks apply to the same event, the most restrictive outcome wins: deny takes priority over defer, which takes priority over ask, which takes priority over allow. A single deny blocks the operation no matter how many other hooks allowed it.
- B. All matching hooks for an event run, typically in parallel, rather than short-circuiting after the first one; the fraud, spending-cap, and account-status checks are all evaluated.
- C. There is no majority-vote resolution; the SDK does not count "allow" versus "deny" responses, it always applies the most restrictive decision present.
- D. Conflicting decisions across hooks are an expected and supported pattern, not an error condition; the SDK resolves them by priority rather than failing the session.
3.Denying a call and sending the agent to escalation
Blocking is only half the job. The exam guide's example is a refund above $500 that must be blocked and sent to another workflow, such as human escalation. If the agent sees only a blocked call, it may try again or give up and leave the customer stranded.
The SDK gives you two practical ways to redirect. First, give the agent a sanctioned path, such as a separate escalation tool or a human-approval step. The hooks docs name human approval for sensitive actions as a core hook use. Second, make permissionDecisionReason explain what to do instead, not just "denied." A reason that names the approved alternative tells the agent there is a next step. A generic reason tells it nothing about why the call failed or what else to try. (These doc excerpts show the reason field but do not describe exactly how it is shown to the model. Check the full hooks reference before relying on specific wording.)
Keep the threshold check in the hook, not in the reason text or the prompt. The hook remains the gate: however the model reacts to the reason, a refund over the limit cannot execute. Better wording only reduces wasted retries. The guarantee comes from the deny.
A PreToolUse hook needs to block refunds above $500 only when they target a specific merchant category, based on a field named category inside the refund tool's arguments. The hook is registered with matcher="refund_customer". Where should the category check be implemented?
Correct answer: D — Inside the callback function itself, by reading input_data["tool_input"]["category"] and applying the conditional logic there, since matchers only filter by tool name
- A. PreToolUse hooks do receive tool_input, including arbitrary arguments like category, inside the callback; the claim that arguments can never be inspected is incorrect.
- B. HookMatcher does not expose a separate argument_matcher field; the only filtering field for tool hooks is the tool-name matcher.
- C. Matchers are compared against the tool name only; they have no visibility into tool_input fields, so embedding an argument condition in the matcher string would not work as intended.
- D. Correct. Matchers only filter on the event's target field, which for tool-based hooks is the tool name, not its arguments. Any argument-level condition, like a category field, must be evaluated inside the callback by reading tool_input directly.
Sources2
4.Registering the hook and scoping it with a matcher
A callback does nothing until you register it. Registration pairs an event name with one or more matcher groups. Each group holds a matcher pattern and the callbacks to run when the pattern matches. The Python reference describes the matcher as a tool name or pattern to match, with examples like Bash and Write|Edit. The same HookMatcher also takes an optional timeout in seconds.
options = ClaudeAgentOptions(
hooks={
# Register the hook for PreToolUse events
# The matcher filters to only Write and Edit tool calls
"PreToolUse": [HookMatcher(matcher="Write|Edit", hooks=[protect_env_files])]
}
)For a compliance rule, choose the matcher carefully. Too broad, and the callback runs on unrelated tools. Too narrow, and a refund tool you add later slips past the rule. In the TypeScript SDK, the hook input also carries an optional mcp_server provenance field, so a callback can check which MCP server a tool came from before applying a server-specific rule. These excerpts do not document the naming scheme MCP tools use in matcher patterns. Look it up in the full hooks reference rather than guessing.
One more property matters for guarantees: programmatic hooks also fire inside subagents. The input carries agent_id and agent_type so you can tell which agent made the call. Routing a refund through a subagent does not get around the rule.
A team wants to send every tool call's arguments to an external audit service without slowing down the agent's response time, and this audit step has no bearing on whether the call is allowed to proceed. Which hook output pattern fits this requirement?
Correct answer: A — A PreToolUse hook that fires the audit request and returns {"async": true, "asyncTimeout": 30000} so the agent proceeds without waiting for the request to finish
- A. Correct. Async output is meant precisely for side effects like audit logging that don't need to influence the call's outcome; the agent proceeds immediately while the background request completes, avoiding added latency.
- B. Appending an audit payload to updatedInput would alter the actual arguments sent to the tool, corrupting the real operation instead of simply logging it out-of-band.
- C. "defer" ends the query entirely so it can be resumed later; it introduces a hard pause rather than letting the agent continue immediately, which is the opposite of what a non-blocking audit step needs.
- D. permissionDecision fields belong to hookSpecificOutput for permission-gating hooks like PreToolUse; PostToolUse doesn't gate execution this way, and prompting the user for an audit confirmation adds unwanted friction and latency.
5.Normalizing tool results with a PostToolUse hook
PreToolUse guards what goes out. PostToolUse handles what comes back. The hooks reference says PostToolUse fires after a tool call succeeds, and the SDK's event table gives its trigger as the tool execution result. Among the listed hook uses is transforming inputs and outputs to sanitize data. That is the pattern for data normalization: the tool has already run, its raw result is in the hook's hands, and your code can reshape that result before the model processes it.
Why you would want this: an agent wired to several MCP servers receives heterogeneous data formats. A billing tool returns created_at as a Unix timestamp such as 1717000000. A CRM tool returns the same kind of field as an ISO 8601 string. A shipping tool reports status as numeric status codes, where 2 means shipped, while an inventory tool uses words. You could explain all of this in the system prompt and hope the model converts correctly every time. That is probabilistic compliance again, and a misread code becomes a wrong answer to a customer. A PostToolUse hook that converts every timestamp to ISO 8601 and maps every numeric status code to its label is deterministic. The model only ever sees one format, so it never has to guess.
class PostToolUseHookInput(BaseHookInput):
hook_event_name: Literal["PostToolUse"]
tool_name: str
tool_input: dict[str, Any]
tool_response: Any
tool_use_id: str
agent_id: NotRequired[str]
agent_type: NotRequired[str]| Field | Documented meaning | Use in a normalizer |
|---|---|---|
| tool_name | Name of the tool that was executed | Pick the converter for that tool's format |
| tool_input | Input parameters that were used | Recover context the result omits, such as the requested currency |
| tool_response | Response from the tool execution | The raw data to rewrite: timestamps, dates, status codes |
| mcp_server | McpServerProvenance (TypeScript SDK, optional) | Branch on which MCP server produced the result |
| agent_id | Subagent identifier, present when the hook fires inside a subagent | Confirms results inside subagents are normalized too |
Registration mirrors the PreToolUse example: the same HookMatcher shape, keyed under PostToolUse, with a matcher that names the MCP tools whose output needs cleaning. Inside the callback, read tool_name to choose the converter, then rewrite tool_response into the canonical shape. In the TypeScript SDK the input also carries mcp_server and duration_ms, so a hook can branch on the server rather than on individual tool names.
One honest caveat. These excerpts document the input a PostToolUse callback receives and state that hooks transform outputs, but they do not show the exact output field that replaces the tool result. Look up that field name in the full hooks reference before you ship a normalizer. The design is unchanged either way: normalize in the hook, not in the prompt.
Do not confuse the two events. A PostToolUse hook runs after the tool call has succeeded, so it cannot undo a refund or a file write. Money rules belong in PreToolUse. Data-shape rules belong in PostToolUse.
It has already gone out. PostToolUse fires after a tool call succeeds, so by the time the hook sees tool_response the refund tool has executed. The threshold must sit in a PreToolUse hook, which fires before the call and can block it. PostToolUse is for transforming or logging the result, not for preventing the action.
Exam traps
Each one states something that sounds right. Open it to see what is actually true.
1.A firmly worded system prompt or CLAUDE.md rule ("NEVER refund over $500") is enough when compliance must be guaranteed.Why is that wrong?
Instructions set conventions the model usually follows, but the model still makes each call. Only a hook runs deterministic logic on the call itself and can block it.
2.Returning an empty object from a PreToolUse hook explicitly approves the call and skips any other permission checks.Why is that wrong?
Giving no decision means the hook does not object. The normal permission flow still decides whether the call runs.
Covered in Blocking a call with a PreToolUse hook
3.A PostToolUse hook is the right place to block a policy-violating action, because it can inspect the full result before deciding.Why is that wrong?
PostToolUse fires after the tool call has succeeded, so the side effect has already happened. It can transform or log the result, but only PreToolUse fires before execution and can block the call.
Sources
Every claim above is drawn from one of these pages, quoted as it was written on the date shown.
- 1.
“Set project conventions your agent always follows”
↩︎ Guaranteed compliance versus probable compliance“Run deterministic logic on tool calls (audit, block, transform)”
↩︎ Guaranteed compliance versus probable compliance“These also fire inside subagents.”
↩︎ Registering the hook and scoping it with a matcher“Run deterministic logic on tool calls (audit, block, transform)”
↩︎ Key concept“Run deterministic logic on tool calls (audit, block, transform)”
↩︎ Exam trap 1 - 2.https://code.claude.com/docs/en/agent-sdk/hooksOfficial docs
“Transform inputs and outputs to sanitize data, inject credentials, or redirect file paths”
↩︎ Guaranteed compliance versus probable compliance“Tool call request (can block or modify)”
↩︎ Blocking a call with a PreToolUse hook“Block dangerous operations before they execute, like destructive shell commands or unauthorized file access”
↩︎ Blocking a call with a PreToolUse hook“Require human approval for sensitive actions like database writes or API calls”
↩︎ Denying a call and sending the agent to escalation“Tool execution result”
↩︎ Normalizing tool results with a PostToolUse hook“Transform inputs and outputs to sanitize data, inject credentials, or redirect file paths”
↩︎ Normalizing tool results with a PostToolUse hook - 3.https://code.claude.com/docs/en/hooksOfficial docs
“Before a tool call executes. Can block it”
↩︎ Blocking a call with a PreToolUse hook“no decision; normal permission flow applies”
↩︎ Blocking a call with a PreToolUse hook“After a tool call succeeds”
↩︎ Normalizing tool results with a PostToolUse hook“no decision; normal permission flow applies”
↩︎ Exam trap 2“After a tool call succeeds”
↩︎ Exam trap 3 - 4.https://code.claude.com/docs/en/agent-sdk/pythonOfficial docs
“Tool name or pattern to match”
↩︎ Registering the hook and scoping it with a matcher“Response from the tool execution”
↩︎ Normalizing tool results with a PostToolUse hook - 5.https://claude.com/blog/claude-enterprise-inference-hooksSecondary source
“the tool's response is checked before it's sent back to the model”
↩︎ Normalizing tool results with a PostToolUse hook