Skip to main content
Agentic architecture

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.

The CertGrove team · 6 min read

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:

  1. process_refund for more than $500 must not run. The agent should call escalate_to_human instead.
  2. process_refund must not run until get_customer has 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

RequirementPrompt instructionHook / programmatic gate
Refund cap, spending limit, irreversible actionNot enoughYes, PreToolUse deny + redirect
"Verify identity before financial operations"Not enoughYes, prerequisite gate
Consistent data formats across MCP toolsUnreliableYes, PostToolUse normalization
Tone, empathy, phrasingYesOverkill
Ask a clarifying question when a request is ambiguousYesCan'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 that get_customer ran first.
  • PostToolUse transforms tool results, such as mixed timestamp formats, before the model reasons over them.
  • In command hooks, exit 2 blocks. 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

The CertGrove teamBilingual study notes for developers preparing for Claude Certified Architect — Foundations.