Claude Code Hooks vs Prompts: When You Need Guaranteed Rules
Why prompt rules fail sometimes and hooks don't: PreToolUse and PostToolUse examples, a prompt-vs-hook decision table, and the CCAR-F exam trap.
A system prompt that says "never refund more than $500 without a human" works most of the time. For a compliance rule, most of the time is a failure rate. Claude Code hooks and Agent SDK hooks run your own code at fixed points in the agent loop, so the rule holds on every call. This post shows when the CCAR-F exam expects a hook instead of a better prompt, with a PreToolUse hook example and a decision table.
Prompts are probabilistic, hooks are deterministic
Task statements 1.4 and 1.5 say it directly: when compliance must be deterministic, such as identity verification before a financial operation, "prompt instructions alone have a non-zero failure rate."
A prompt instruction is one input the model weighs against everything else in context, and a long conversation or an insistent customer can tip it the other way. A hook is a function your application runs whenever a matching event fires. If the condition is met, it blocks the call.
So the split looks like this:
- Prompt guidance for preferences and judgment calls: tone, when to ask a clarifying question, which tool to try first.
- Hooks and programmatic gates for rules the business must guarantee: refund limits, required verification, data-format contracts.
PreToolUse hook example: block, redirect, enforce order
A PreToolUse hook fires after Claude asks for a tool and before it runs. It can allow the call, deny it with a reason Claude reads, or rewrite the input.
The guide's two standard cases:
process_refundfor more than $500 must not run. The agent should callescalate_to_humaninstead.process_refundmust not run untilget_customerhas returned a verified customer ID.
Here is a minimal Agent SDK version in Python. It assumes the support tools come from an MCP server registered as support, so their names are mcp__support__<tool>.
from claude_agent_sdk import ClaudeAgentOptions, HookMatcher
verified = set() # session_ids that passed get_customer
async def record_verification(input_data, tool_use_id, context):
# PostToolUse on get_customer: remember that this session verified someone
if "verified" in str(input_data.get("tool_response", "")):
verified.add(input_data["session_id"])
return {}
def deny(reason):
return {"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}}
async def refund_policy(input_data, tool_use_id, context):
if input_data["session_id"] not in verified:
return deny("Call get_customer and verify the customer before any refund.")
if input_data["tool_input"].get("amount", 0) > 500:
return deny("Refunds over $500 need a human. Call escalate_to_human "
"with customer ID, root cause, amount and recommended action.")
return {} # no objection, normal flow continues
options = ClaudeAgentOptions(hooks={
"PreToolUse": [HookMatcher(matcher="mcp__support__process_refund",
hooks=[refund_policy])],
"PostToolUse": [HookMatcher(matcher="mcp__support__get_customer",
hooks=[record_verification])],
})
The "verified" in str(...) check stands in for parsing your real get_customer response. What matters is the structure: the denial reason goes back to Claude, which then verifies first or escalates instead of failing silently. That is the guide's "block and redirect" pattern.
In Claude Code itself, the same hook is a command in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__support__process_refund",
"hooks": [{ "type": "command", "command": "./.claude/hooks/refund-policy.sh" }]
}
]
}
}
The script reads the event JSON (with tool_name and tool_input) on stdin. Exit code 2 blocks the call and sends stderr to Claude as the reason. Exit 0 with JSON on stdout lets you return permissionDecision (allow, deny, ask, and on current versions defer). Any other exit code is a non-blocking error and the call goes ahead, so a policy script that crashes blocks nothing.
PostToolUse hooks to normalize tool output
The second pattern in task statement 1.5 runs the other way. A PostToolUse hook fires after a tool succeeds and receives tool_response. It can change what Claude sees before Claude reasons over it.
The exam scenario: three MCP tools return dates as Unix seconds, ISO 8601, and "10/03/2026", and order status as numeric codes in one system and words in another. A prompt saying "be careful with date formats" is again probabilistic. A PostToolUse hook that converts every timestamp to ISO 8601 and maps status codes to names makes the data consistent before the model reads it.
On current Claude Code and Agent SDK versions, the hook returns the cleaned result in hookSpecificOutput.updatedToolOutput, which must match the tool's output shape (the older updatedMCPToolOutput covers MCP tools only). The tool has already run by this point. To stop or change a call, use PreToolUse.
flowchart LR
A[Claude requests tool] --> B{PreToolUse hook}
B -- deny + reason --> A
B -- allow --> C[Tool runs]
C --> D[PostToolUse hook normalizes output]
D --> E[Claude reasons over clean data]
Prompt vs hook: decision table
| Requirement | Prompt instruction | Hook / programmatic gate |
|---|---|---|
| Refund cap, spending limit, irreversible action | Not enough | Yes, PreToolUse deny + redirect |
| "Verify identity before financial operations" | Not enough | Yes, prerequisite gate |
| Consistent data formats across MCP tools | Unreliable | Yes, PostToolUse normalization |
| Tone, empathy, phrasing | Yes | Overkill |
| Ask a clarifying question when a request is ambiguous | Yes | Can't judge ambiguity |
The test: is a single miss acceptable? If not, put the rule in code.
Try it
A support agent built with the Agent SDK sometimes issues refunds above the $500 policy limit, even though the system prompt states the limit in bold and repeats it at the end. What is the most effective fix?
A. Move the refund limit to the top of the system prompt and add three few-shot examples of correct escalations.
B. Add a PreToolUse hook on process_refund that denies amounts over $500 and tells the agent to call escalate_to_human.
C. Lower the temperature so the agent follows instructions more consistently.
D. Add a PostToolUse hook that logs every refund over $500 for a weekly audit.
Answer: B. The rule needs a guarantee, and a PreToolUse hook enforces it before the money moves, while redirecting the agent to the escalation path.
- A strengthens the prompt. It may lower the miss rate, but it stays probabilistic, which is the trap this question tests.
- C changes sampling randomness, not compliance. Nothing stops a bad refund.
- D uses the right mechanism at the wrong point. PostToolUse runs after the refund has gone through, so it only detects the violation.
Key takeaways
- Prompt instructions have a non-zero failure rate. Rules that need guaranteed compliance belong in hooks or programmatic gates.
- PreToolUse intercepts outgoing calls: deny with a reason, redirect to
escalate_to_human, or enforce thatget_customerran first. - PostToolUse transforms tool results, such as mixed timestamp formats, before the model reasons over them.
- In command hooks, exit
2blocks. Other non-zero codes don't. - When an answer choice says "strengthen the system prompt" for a must-never-happen rule, look for the hook option.
Frequently asked questions
Same events, same output format. Claude Code hooks are usually shell commands in settings.json. Agent SDK hooks are callbacks you pass in options.hooks, and the SDK can also load command hooks from settings files.
Yes. Return updatedInput inside hookSpecificOutput to replace the arguments before the tool runs, for example to cap a value or rewrite a path.
No. Keep the policy in the prompt so Claude plans around it and can explain it. The hook is the backstop for when the model gets it wrong.
Related reading: Claude agentic loop: workflow vs agent, escalation and human handoff patterns, and MCP tools vs resources vs prompts. Practice Domain 1 scenarios to drill hook and enforcement questions.