What you will be able to do
- Place PreToolUse and PostToolUse hooks in the agent loop and explain what each one can change
- Explain why a business rule that must always hold belongs in a hook rather than a prompt instruction
- Write a PreToolUse gate that denies a policy-violating call, such as a refund over $500, and points Claude to an alternative workflow
- Use PostToolUse with updatedToolOutput to replace inconsistent tool results before Claude reads them
- Write matchers that target one MCP server's tools
Key concept
Tool-call interception hook — A hook is a callback in your own application that the SDK runs at a fixed point in every tool call. PreToolUse runs before the tool executes and can allow, deny, or rewrite the call. PostToolUse runs after the tool returns and can change what Claude sees. Because your code makes the decision, a rule in a hook is enforced whether or not the model would have followed it.
1.Where hooks sit in the agent loop
An SDK agent works in a loop. Claude reads the prompt and history and decides whether to call tools. The SDK runs those tools, and the results go back to Claude: "Each set of tool results feeds back to Claude for the next decision." The docs put hooks inside the tool-execution step. That makes them the one place where your code sits between Claude's request and the real side effect, and again between the tool's raw output and Claude's next decision.
This lesson covers two events. PreToolUse fires when Claude requests a tool call; the SDK describes it as "Tool call request (can block or modify)". PostToolUse fires on the "Tool execution result". Neither one is optional or chosen by Claude. The hooks reference says they fire "on every tool call inside the agentic loop: PreToolUse and PostToolUse, except EndConversation calls, which skip both". Each callback receives a typed input. For tool events this includes tool_name and tool_input, plus the shared session_id, cwd, and hook_event_name. The SDK docs list what hooks are for, including blocking dangerous operations and the use that matters most here: "Transform inputs and outputs to sanitize data, inject credentials, or redirect file paths".
Each time an event fires, the SDK handles it in the same order. It collects the hooks registered for that event, uses matchers to decide which ones apply, runs those callbacks, and then acts on the decision your callback returns.
Checkpoint 1 of 9· Put it in order
Put the SDK's hook resolution steps in order.
- 1.Callback functions execute
- 2.An event fires
- 3.Your callback returns a decision
- 4.Matchers filter which hooks run
- 5.The SDK collects registered hooks
First the event fires. Registered hooks are then collected and filtered by matcher. Only the callbacks that match run, and the decision they return controls what happens next.
“The SDK collects registered hooks”Source: code.claude.com
2.Deterministic hooks versus prompt instructions
You can state a business rule in two ways. One is to write it in the system prompt: "never issue a refund above $500." The other is to enforce it in a hook. They work differently. A prompt instruction is input to the model's decision. In the agent loop, "Claude evaluates the current state and determines how to proceed", and an instruction is one thing Claude weighs while doing that. Most of the time it will follow the instruction. Nothing in the loop guarantees it will.
A hook works differently on three counts. It fires on every matching tool call, so it is never skipped. It is ordinary code: the SDK docs note that "your callback runs in your application’s own process". And its decision is enforced, not suggested: "permissionDecision: 'deny' stops the tool call." A $500 threshold in a PreToolUse callback is a comparison your code runs. Model judgment plays no part in it.
So the rule for choosing is this. If a business rule needs guaranteed compliance (refund caps, protected files, regulated actions), enforce it in a hook. Use the prompt for guidance where an occasional miss is acceptable. You can also use both: the prompt helps Claude make the right call the first time, and the hook stops the call if Claude doesn't. Note that the provided sources never use the words "deterministic" or "probabilistic". That framing is the exam guide's. The sources do supply the mechanics behind it: hooks fire on every call, run as your code, and enforce their decisions.
Checkpoint 2 of 9· Check yourself
A support agent must never execute a refund above $500. The team has added "Never refund more than $500" to the system prompt, and audits still find rare violations. What change gives a guarantee?
Rewording the prompt or switching models still leaves the decision to the model. A PostToolUse hook runs after the refund has already happened. Only a PreToolUse deny stops the call before it executes, on every call.
“permissionDecision: 'deny' stops the tool call.”Source: code.claude.com
3.PreToolUse: blocking policy-violating calls
The SDK's reference example shows the pattern. The callback reads tool_input, checks it against a rule, and returns a hookSpecificOutput with a permissionDecision. If the call is fine, it returns an empty object. A refund gate has the same structure: read the amount from tool_input, compare it to 500, and deny if it is higher.
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 empty object needs care. It means "no objection". It does not mean "approved". The hooks guide says the same about a command hook that exits 0: "For a PreToolUse hook this doesn’t approve the tool call: the normal permission flow still applies." Each value of permissionDecision has a different effect, so choose by what the rule needs. If the requirement is that the call must never run and no one should be prompted, the answer is deny, not ask.
| Return value | Effect on the call |
|---|---|
| permissionDecision: "allow" | Skips the interactive permission prompt; deny and ask rules still apply |
| permissionDecision: "deny" | Cancels the tool call and sends permissionDecisionReason to Claude |
| permissionDecision: "ask" | Shows the permission prompt to the user as normal |
| permissionDecision: "defer" | Ends the query so you can resume it later |
| {} (empty object) | No decision; the normal permission flow applies |
Checkpoint 3 of 9· Match them up
Match each permissionDecision value to its runtime effect.
Tap a term, then the definition that fits it.
Only deny cancels the call on its own authority. Ask hands the decision to a person, allow only skips the prompt, and defer pauses the whole query.
“cancel the tool call and send the reason to Claude”Source: code.claude.com
Checkpoint 4 of 9· Exam question
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.
4.Redirecting a blocked call to an alternative workflow
A deny stops the call, but the agent still has a goal it is trying to reach. The denial tells the model what happened: "permissionDecisionReason tells the model why, so it avoids retrying." That makes the reason string more than an error message. It is your way of steering the agent toward the right path. A generic reason such as "Refund denied" gives Claude nothing to act on, and Claude may simply retry the same call. A useful reason says why the call was blocked and what to do instead. For example: "Refunds above $500 require human approval. Use the escalation tool with the order details." The hook remains the gate. The reason only shapes what Claude tries next.
Your callback can also return the top-level systemMessage field. That one goes to the user, not the model: "systemMessage shows the user what happened." If escalation should pause the whole run rather than continue in the loop, use "defer". In the SDK's words, "If you return "defer", the query ends so you can resume it later."
Blocking isn't the only way to intercept a call. PreToolUse can also return updatedInput together with allow, so the call goes ahead with corrected arguments. The SDK example below sends every Write into a sandbox path.
Checkpoint 5 of 9· Fill the gap
Which field rewrites the tool's arguments before the call executes?
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",
" ? ": {
**input_data["tool_input"],
"file_path": f"/sandbox{original_path}",
},
}
}
return {}For PreToolUse, updatedInput replaces the arguments the tool runs with. updatedToolOutput and additionalContext are PostToolUse fields that act on the result, and permissionDecisionReason is only an explanation for the model.
Checkpoint 6 of 9· Exam question
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.
Sources3
5.PostToolUse: normalizing tool results before Claude reads them
The other direction matters too. An agent connected to several MCP servers often receives the same kind of data in different formats. One tool returns a Unix timestamp, another returns ISO 8601, and a third reports status as a numeric code where another uses a label. If those results reach Claude unchanged, Claude has to reconcile them on every turn, and it may not do it the same way each time. A PostToolUse hook can convert each result to one canonical format before Claude sees it.
That is the main point of this section. The PostToolUse input includes the raw tool_response alongside tool_name and tool_input, so your callback can parse whatever the tool returned. To change what Claude reads, return updatedToolOutput: "To replace the tool’s output before Claude sees it, set updatedToolOutput, which works for any tool in both SDKs." A normalizer therefore parses tool_response, converts every timestamp to ISO 8601 and every numeric status code to its canonical label, and returns the result as updatedToolOutput. Claude only ever sees one format. The sources don't include a ready-made normalization callback. The steps above apply the documented fields to the exam guide's scenario.
| Field | What it does |
|---|---|
| updatedToolOutput | Replaces the tool's output before Claude sees it; works for any tool in both SDKs |
| additionalContext | Appends information to the tool result; the original output stays |
| updatedMCPToolOutput | Older field that replaces MCP tool output only; deprecated |
If the normalizer needs to know what the original request was, use the tool use ID passed to every callback. It "correlates PreToolUse and PostToolUse events for the same tool call".
Checkpoint 7 of 9· Check yourself
Three MCP tools return dates as Unix seconds, ISO 8601 strings, and a custom format. You want Claude to only ever see ISO 8601. What should the PostToolUse hook return?
Only updatedToolOutput replaces what Claude sees. additionalContext and decision "block" leave the original output in place, and updatedMCPToolOutput is the deprecated MCP-only field.
“The older updatedMCPToolOutput field replaces MCP tool output only and is deprecated.”Source: code.claude.com
6.Scoping hooks to the right tools with matchers
Both kinds of hook need to fire on the right tools: the refund gate on the refund tool, the normalizer on the tools that return mixed formats. You choose those tools with the matcher in each HookMatcher. For tool events, the matcher is checked against the tool name. MCP tool names follow a fixed pattern: "MCP tools use the pattern mcp__<server>__<action>, where <server> is the key you use in the mcpServers configuration."
options = ClaudeAgentOptions(
hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[my_callback])]}
)How a matcher string is interpreted depends on which characters it contains. Letters, digits, _, -, spaces, commas, and | give exact matching. Any other character makes the matcher an unanchored JavaScript regular expression. A pattern like mcp__memory__.* therefore covers every tool on one server and nothing else.
| Matcher value | Evaluated as | Matches |
|---|---|---|
| "*", "", or omitted | Match all | Every occurrence of the event |
| Bash | Exact string | Only the Bash tool |
| Edit|Write | List of exact strings | Either Edit or Write |
| mcp__memory__.* | JavaScript regular expression, unanchored | Every tool from the memory server |
Checkpoint 8 of 9· Check yourself
Which matcher value is evaluated as a regular expression rather than as an exact tool name?
^ falls outside the exact-match character set, so the value is treated as a regex. |, commas, and hyphens are all allowed in exact-match lists.
“^Notebook matches any tool whose name starts with Notebook”Source: code.claude.com
Checkpoint 9 of 9· Exam question
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.
Exam traps
Each one states something that sounds right. Open it to see what is actually true.
1.Returning an empty object (or exiting 0) from a PreToolUse hook approves the tool call.Why is that wrong?
An empty result means no objection. The normal permission flow still decides, so only an explicit decision changes the outcome.
Covered in PreToolUse: blocking policy-violating calls
2.The deny decision is all that matters, so the permissionDecisionReason can be generic.Why is that wrong?
The reason is what the model reads after a deny. A vague reason invites Claude to retry, while a specific one points it to the alternative workflow.
Covered in Redirecting a blocked call to an alternative workflow
3.A PostToolUse hook that returns decision "block" stops Claude from seeing a malformed tool result.Why is that wrong?
After execution, "block" only adds a reason next to the result. Replacing the output takes updatedToolOutput.
Covered in PostToolUse: normalizing tool results before Claude reads them
4.A firmly worded system-prompt rule enforces a hard business limit as reliably as a hook does.Why is that wrong?
A prompt rule is something Claude weighs when it decides what to do. A PreToolUse deny is enforced by your code and stops the call regardless.
Sources
Every claim above is drawn from one of these pages, quoted as it was written on the date shown.
- 1.https://code.claude.com/docs/en/hooksOfficial docs
“on every tool call inside the agentic loop: PreToolUse and PostToolUse, except EndConversation calls, which skip both”
↩︎ Where hooks sit in the agent loop“adds the reason next to the tool result. Claude still sees the original output; to replace it, use updatedToolOutput”
↩︎ PostToolUse: normalizing tool results before Claude reads them“mcp__memory__.* matches every tool from the memory server”
↩︎ Scoping hooks to the right tools with matchers“JavaScript regular expression, unanchored”
↩︎ Scoping hooks to the right tools with matchers“adds the reason next to the tool result. Claude still sees the original output; to replace it, use updatedToolOutput”
↩︎ Exam trap 3“^Notebook matches any tool whose name starts with Notebook”
↩︎ Checkpoint - 2.https://code.claude.com/docs/en/agent-sdk/agent-loopOfficial docs
“Each set of tool results feeds back to Claude for the next decision.”
↩︎ Where hooks sit in the agent loop“Claude evaluates the current state and determines how to proceed.”
↩︎ Deterministic hooks versus prompt instructions“You can use hooks to intercept, modify, or block tool calls before they run.”
↩︎ Key concept“Claude evaluates the current state and determines how to proceed.”
↩︎ Exam trap 4 - 3.https://code.claude.com/docs/en/agent-sdk/hooksOfficial docs
“Transform inputs and outputs to sanitize data, inject credentials, or redirect file paths”
↩︎ Where hooks sit in the agent loop“Tool call request (can block or modify)”
↩︎ Where hooks sit in the agent loop“Because your callback runs in your application’s own process”
↩︎ Deterministic hooks versus prompt instructions“permissionDecision: 'deny' stops the tool call.”
↩︎ Deterministic hooks versus prompt instructions“Return empty object to allow the operation”
↩︎ PreToolUse: blocking policy-violating calls“permissionDecisionReason tells the model why, so it avoids retrying.”
↩︎ Redirecting a blocked call to an alternative workflow“systemMessage shows the user what happened.”
↩︎ Redirecting a blocked call to an alternative workflow“If you return "defer", the query ends so you can resume it later.”
↩︎ Redirecting a blocked call to an alternative workflow“To replace the tool’s output before Claude sees it, set updatedToolOutput, which works for any tool in both SDKs.”
↩︎ PostToolUse: normalizing tool results before Claude reads them“For PostToolUse hooks, you can set additionalContext to append information to the tool result.”
↩︎ PostToolUse: normalizing tool results before Claude reads them“correlates PreToolUse and PostToolUse events for the same tool call.”
↩︎ PostToolUse: normalizing tool results before Claude reads them“MCP tools use the pattern mcp__<server>__<action>, where <server> is the key you use in the mcpServers configuration.”
↩︎ Scoping hooks to the right tools with matchers“permissionDecisionReason tells the model why, so it avoids retrying.”
↩︎ Exam trap 2“The SDK collects registered hooks”
↩︎ Checkpoint“The older updatedMCPToolOutput field replaces MCP tool output only and is deprecated.”
↩︎ Checkpoint - 4.https://code.claude.com/docs/en/hooks-guideOfficial docs
“show the permission prompt to the user as normal”
↩︎ PreToolUse: blocking policy-violating calls“For a PreToolUse hook this doesn’t approve the tool call: the normal permission flow still applies.”
↩︎ PreToolUse: blocking policy-violating calls“For a PreToolUse hook this doesn’t approve the tool call: the normal permission flow still applies.”
↩︎ Exam trap 1“cancel the tool call and send the reason to Claude”
↩︎ Checkpoint