What a hook is
A hook is your code, called at a fixed point in the agent loop. It runs in your application process, not in the model's context, so it costs no tokens and cannot be talked out of its behaviour. The two points the exam cares about are before a tool runs (PreToolUse) and after it returns (PostToolUse).
Where the two hooks sit
- Claude asks for toola
tool_usewith name and input PreToolUseallow, deny, or rewrite the input- Tool runsyour function, MCP server or built-in
PostToolUsereplace or annotate the output- Claude reads resultthe cleaned-up version
PreToolUse can stop or change a call before anything happens. PostToolUse sees the result after the tool ran — it can reshape what Claude reads, but it cannot un-send a refund.| Hook | Fires | Exam use |
|---|---|---|
PreToolUse | Before a tool executes | Block or redirect a call that breaks policy |
PostToolUse | After a tool returns | Normalise or trim the result before the model reads it |
PostToolUseFailure | When a tool call fails | Log or handle errors consistently (less central to the exam) |
SubagentStart / SubagentStop | When a subagent spawns or finishes | Track parallel work (less central to the exam) |
Hooks are registered in the hooks option as a map from event name to a list of matchers. A matcher's pattern is tested against the tool name, so one hook can target Bash, another every file-writing tool with Write|Edit, and a third every MCP tool. A matcher with no pattern runs for every call of that event — useful for audit logging.
Intercepting outgoing calls
A PreToolUse hook sees the tool name and its arguments and can deny the call. When a PreToolUse hook rejects a call, the tool does not run and Claude receives the rejection as its result — so a good denial explains what to do instead, for example escalate to a human.
REFUND_LIMIT = 500
# SDK custom tools are named mcp__<server>__<tool>
REFUND_TOOL = "mcp__billing__process_refund"
async def refund_limit(input_data, tool_use_id, context):
if input_data["tool_input"]["amount"] > REFUND_LIMIT:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
f"Refunds over {REFUND_LIMIT} need a human. "
"Call escalate_to_human instead."),
}
}
return {} # allow unchanged
options = ClaudeAgentOptions(
mcp_servers={"billing": billing_server},
hooks={"PreToolUse": [
HookMatcher(matcher=REFUND_TOOL, hooks=[refund_limit]),
]},
)Two details are easy to get wrong. First, custom tools you build with the SDK are served from an in-process MCP server, so Claude sees them as mcp__<server>__<tool> — a hook that compares the name to a bare process_refund would never fire. Second, the reason matters: the documentation notes that permissionDecisionReason tells the model why, so it stops retrying and can take the route you name.
A denial, message by message
escalate_to_human(…)In Claude Code's hook configuration the same outcome is reached by a hook script exiting with code 2, or returning permissionDecision: "deny". Check the hooks reference for the exact callback signature in the SDK version you use.
A PreToolUse hook can also rewrite a call rather than refuse it, by returning updatedInput — for example redirecting file writes into a sandbox directory. And when several hooks match the same call they run in parallel; for permission decisions the most restrictive answer wins, so a single deny blocks the call whatever the others say. Write each hook to stand on its own rather than relying on another having run first.
Normalising results
Different backends return the same idea in different shapes: one MCP tool gives a Unix timestamp, another an ISO 8601 string, a third a numeric status code where you expected a word. The guide's use for PostToolUse is to normalise these into one format before the agent processes them, so the model is not left reconciling formats — and occasionally getting it wrong.
Two services, one shape
What the tools returnjson
order-service:
{ "placed": 1788861600,
"state": 3 }
billing-service:
{ "placed_at":
"2026-09-08T10:00:00Z",
"status": "PAID" }What Claude readsjson
order-service:
{ "placed_at":
"2026-09-08T10:00:00Z",
"status": "paid" }
billing-service:
{ "placed_at":
"2026-09-08T10:00:00Z",
"status": "paid" }To replace what Claude sees, a PostToolUse hook returns updatedToolOutput inside hookSpecificOutput. Current documentation says this works for any tool in both SDKs and must match the tool's output shape — for an MCP tool, the usual content list. An older field, updatedMCPToolOutput, did the same for MCP tools only and is now deprecated. If you only want to add a note rather than replace the output, additionalContext appends it to the result.
STATE = {1: "pending", 2: "shipped", 3: "paid"}
async def normalise_order(input_data, tool_use_id, context):
# parse_order is your helper that pulls the JSON out of the response
raw = parse_order(input_data["tool_response"])
placed = datetime.fromtimestamp(raw["placed"], tz=timezone.utc)
clean = {
"placed_at": placed.isoformat(),
"status": STATE.get(raw["state"], "unknown"),
}
text = json.dumps(clean)
return {
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
# must keep the tool's output shape: an MCP content list
"updatedToolOutput": {"content": [{"type": "text", "text": text}]},
}
}Traps the wrong answers are built from
| Tempting but wrong | Do this instead |
|---|---|
| Enforcing a financial limit with a prompt instruction | Deny the call in a PreToolUse hook. |
| A bare denial with no reason | Explain the alternative so the model can take it. |
| Asking the model to reconcile mixed timestamp and status formats | Normalise tool results before the model sees them. |
Using PostToolUse to enforce a limit | The tool has already run; block it in PreToolUse. |
| Matching a custom SDK tool by its bare name | Match the full mcp__<server>__<tool> name. |
You should now be able to
- Write a
PreToolUsehook that blocks policy-violating calls and redirects to an alternative. - Use
PostToolUseto normalise heterogeneous tool output. - Choose hooks over prompt-based enforcement when compliance must be guaranteed.
- Register hooks with matchers that target the right tools.
- Explain what happens when several hooks return different decisions.