Claude Certification Program · v1.0 · Effective July 2026 · All four tracks open

Home › Study guides › CCAR-F › Domain 1 › Lesson 1.5

CCAR-F · Domain 1 · 27% of the exam · Lesson 1.5 · 22 min read

Agent SDK hooks: intercepting tool calls and normalising data

How Agent SDK hooks enforce rules: a PostToolUse hook normalises mixed MCP results, a PreToolUse hook blocks a $500 refund, and when a hook beats a prompt.

Written against task statement 1.5 of the official CCAR-F exam guide (Version 1.0, effective July 2026). An independent resource, not affiliated with Anthropic; the practice questions are written from scratch.

1.5.1 Why "please don't" is not a rule

Picture the support agent in its first week in production. It handles returns, billing disputes and account problems. It reaches your backend through four custom tools served over the Model Context Protocol (MCP), the standard way to plug outside systems into an agent: get_customer, lookup_order, process_refund and escalate_to_human. Finance has one hard rule: no refund above $500 without a person signing it off. So you write it into the system prompt (the standing instructions the model reads first in every conversation), in bold, near the top.

For a fortnight it works. Then a customer with a $780 order writes a long, reasonable, slightly heartbreaking message, and the model decides that this case is clearly an exception. The money is gone before anyone reads the transcript. Nothing malfunctioned. A prompt instruction is a request to a reader who weighs it against everything else in the conversation, and a good reader occasionally decides the exception is justified.

The same week you meet a quieter problem. The orders system returns dates as Unix timestamps, a raw count of seconds since 1 January 1970. The customer system returns ISO 8601 strings, readable dates such as 2023-05-02T10:15:00Z. The payments system reports status as a bare number. You told the model how to read each one, and most of the time it does. Sometimes it decides a parcel delivered three days ago arrived three weeks ago, and offers a return the policy does not allow.

Both failures have the same shape. You needed something to happen EVERY time, and you asked for it in a medium that delivers "nearly every time". The fix is the same mechanism in both cases. A hook is a piece of ordinary code, written by you, that the Claude Agent SDK runs at a fixed moment in the agent loop. The two moments that matter here are just before a tool runs and just after its result comes back. Because a hook is code, it does not weigh anything. It runs, it checks, it changes or blocks, and the model has no vote.

1.5.2 Where a hook sits in the loop

Here is the question to settle first: at which moments can your code get between the model and a tool? Walk through one turn. The model reads the conversation and asks for a tool. The SDK runs the tool. The result goes back into the conversation and the model reads again. That cycle has two seams where your code can still change the outcome, and the SDK exposes both as events your code can register for.

PreToolUse fires after the model has asked for a tool and before the SDK runs it. Your code sees the tool's name and its inputs. It can allow the call, deny it, change the inputs, or add context for the model. PostToolUse fires after the tool has returned and before the model reads the result. Your code sees the name, the inputs and the raw result, and it can replace what the model will read. Think of two checkpoints on a delivery route: the loading bay, where a parcel can be refused before it leaves, and the door, where the contents can be repacked before they are handed over.

One loop turn with its two hook points

Model asksprocess_refund, amount 780
PreToolUseyour code: allow, deny or change the input
Tool runsthe MCP server does the work
PostToolUseyour code: reshape the result
Model readsonly what the hook let through
Model reads → Model asks · the model reads the result and decides again
The two hook points sit where your code can still change the outcome: before the tool runs, and before the model reads the result.

You register hooks in the hooks option of your agent options (ClaudeAgentOptions in Python, the options object in TypeScript). The keys are event names, spelled exactly as shown, capitals included. Each value is a list of matchers. A matcher pairs an optional pattern with the callbacks (functions the SDK calls for you) to run when the pattern fits. For tool events the pattern is tested against the tool name, and an MCP tool is named mcp__<server>__<tool>, where the server part is the name you registered the server under (support here). Look at the two matcher lines: one catches every tool from the support server (.* means "anything after this"), the other only process_refund.

from claude_agent_sdk import ClaudeAgentOptions, HookMatcher

options = ClaudeAgentOptions(
    mcp_servers={"support": support_server},   # get_customer, lookup_order, ...
    allowed_tools=["mcp__support__*"],         # run all four without a permission prompt
    hooks={
        # AFTER any support tool returns: reshape the result
        "PostToolUse": [HookMatcher(matcher="mcp__support__.*", hooks=[normalise_result])],
        # BEFORE process_refund runs: enforce the $500 rule
        "PreToolUse": [HookMatcher(matcher="mcp__support__process_refund", hooks=[refund_gate])],
    },
)

Every callback receives three arguments: the event's input data, the id of the tool call, and a context object. It returns a small dictionary of named fields that tells the SDK what to do, and an empty dictionary means "no opinion, carry on". The table below is the part to memorise; everything else in this lesson is built from these two rows.

Event When it fires What your code sees What it can return
PreToolUse The model asked for a tool; it has not run yet tool_name, tool_input permissionDecision (allow, deny, ask or defer) with a permissionDecisionReason; updatedInput to change the inputs; additionalContext
PostToolUse The tool has run; the model has not seen the result tool_name, tool_input, tool_response updatedToolOutput to replace the result; additionalContext to add to it

Learn the two names and their direction cold: Pre is before, and can prevent; Post is after, and can transform. From the last column, this lesson uses deny, permissionDecisionReason and updatedToolOutput; recognise the rest. The SDK offers other events too (Stop, SubagentStop, PreCompact and more), but this task statement is about these two.

1.5.3 PostToolUse: one shape before the model reads it

Start with the quieter problem, the one the task statement names by its event. The support agent's tools sit on backends that were never designed to agree with each other. From lookup_order, delivered_at arrives as a Unix timestamp such as 1758960000. From get_customer, member_since arrives as an ISO 8601 string such as "2023-05-02T10:15:00Z". And process_refund reports its outcome as status: 2, which in the payments system means "pending review" and in nobody's head means anything at all.

It is tempting to leave the translation to the model. It is good at reading messy data, and you can explain each format in the system prompt. Resist it. The model is being asked to do arithmetic on a ten-digit number, in its head, on every turn, while also reasoning about the customer's problem. It will usually get it right, and "usually" is exactly the word we are trying to remove. Worse, the formats sit side by side in the conversation, so a question such as "was this delivered inside the 14-day window?" has to cross formats to be answered at all.

The right place for the translation is a PostToolUse hook. It runs in your application, in ordinary code, after the tool has returned and before the model sees anything. It reads the raw tool_response, converts every timestamp to one ISO 8601 form, maps every numeric status to a word, and hands the SDK an updatedToolOutput. The SDK sends that to the model in place of the original result. The model never learns that three formats existed.

Three raw tool outputs, one normalised record

lookup_order

delivered_at: 1758960000Unix seconds
Hook converts
delivered_at: "2025-09-27T08:00:00Z"

get_customer

member_since: "2023-05-02T10:15:00Z"ISO 8601 already
Hook leaves it alone
member_since: "2023-05-02T10:15:00Z"

process_refund

status: 2a payments code
Hook maps it
status: "pending_review"
Each backend answers in its own dialect. The hook translates all three into the one shape the model reasons over, before any of them reach the conversation.

Here is the hook. parse and repack stand for a few lines of your own that unwrap the tool's result and wrap it back up, because a replacement must keep the shape of the tool's own output. The lines that matter are the timestamp conversion, the status lookup, and the updatedToolOutput line, which is what actually changes what the model reads.

from datetime import datetime, timezone

STATUS = {1: "processing", 2: "pending_review", 3: "completed"}

async def normalise_result(input_data, tool_use_id, context):
    record = parse(input_data["tool_response"])      # your own unwrapping of the raw result
    for key in ("delivered_at", "member_since"):
        if isinstance(record.get(key), int):         # Unix seconds -> ISO 8601, UTC
            when = datetime.fromtimestamp(record[key], timezone.utc)
            record[key] = when.strftime("%Y-%m-%dT%H:%M:%SZ")
    if isinstance(record.get("status"), int):        # 2 -> "pending_review"
        record["status"] = STATUS.get(record["status"], "unknown")
    return {"hookSpecificOutput": {
        "hookEventName": "PostToolUse",
        "updatedToolOutput": repack(record),         # REPLACES what the model sees
    }}

Two things to notice. First, the hook runs on every result from every support tool, because the matcher is mcp__support__.*, so the guarantee covers tools you add next year as well as today's four. Second, the hook runs in your application's process, not in the model's context window, so the translation uses none of the model's context. A paragraph of format instructions in the prompt does, on every request.

Note what PostToolUse cannot do. It fires after the tool has run, so it cannot undo a refund that has already been issued. A PostToolUse hook can return a block decision, but that only puts a note next to the result the model reads; the tool's work is already done. That limitation is a definition, not a flaw. Post is for transforming what came back; the next section is for preventing what should never go out.

1.5.4 PreToolUse: blocking the $500 refund and redirecting the flow

Back to the $780 refund. The rule is "no refund above $500 without a human". The only place to enforce it with certainty is the seam where the model has asked for process_refund and nothing has happened yet. That is a PreToolUse hook. Its matcher is the full tool name, mcp__support__process_refund, so the callback runs for that tool and no other. It reads the amount out of tool_input, and if the amount is over the limit it returns permissionDecision: "deny".

A denial does two things. First, the SDK does not run the tool, so the refund cannot go through. Second, the model receives your permissionDecisionReason in place of a tool result, so it knows the call was refused and why, instead of trying again. That reason is where the redirect lives. Tell the model plainly what the alternative workflow is: "Refunds above $500 need a human. Call escalate_to_human with the customer id, the order and the amount." The model reads that, and on its next turn asks for escalate_to_human instead.

REFUND_LIMIT = 500

async def refund_gate(input_data, tool_use_id, context):
    amount = float(input_data["tool_input"].get("amount", 0))
    if amount <= REFUND_LIMIT:
        return {}                                   # no opinion: the call proceeds
    return {"hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "deny",               # the refund NEVER runs
        "permissionDecisionReason": (
            f"Refunds above ${REFUND_LIMIT} need a human. "
            "Call escalate_to_human with the customer id, order and amount."
        ),
    }}

Be precise about which part is guaranteed. The block is a guarantee: the code runs on every process_refund request and the tool cannot run past a deny. The redirect, as written, is a strong steer: the model is told exactly what to do next and has nothing else sensible to do. If the escalation itself must never be missed, the hook can open the ticket in its own code before returning the denial, because a callback is ordinary code that can call your systems. Either way the model never chooses whether the rule applies.

Picture a bank card with a spending limit. The cardholder can want the purchase badly and explain it beautifully to the shop assistant. The terminal still says declined, because the card network checks the limit, not anyone at the till. PreToolUse is the terminal. The prompt is the assistant.

The model asks for The hook sees The hook returns What happens next
process_refund, amount 120 120 is under the limit {} The refund runs; PostToolUse normalises its status
process_refund, amount 780 780 is over the limit deny plus the reason Nothing runs; the model reads the reason and calls escalate_to_human
lookup_order Nothing; this matcher does not fire Not called The lookup runs; PostToolUse still normalises the result

1.5.5 Guarantee versus good chance: choosing a hook over a prompt

Here is the distinction underneath everything so far. A prompt instruction produces probabilistic compliance: the model reads the instruction, weighs it with everything else, and complies most of the time. A hook produces a deterministic guarantee: the code runs on every matching call and the outcome does not depend on the model's judgement. Both are useful. The skill is knowing which one a given rule needs.

The test is the cost of the failure, not the strength of your wording. If the rule can fail occasionally and the business absorbs it (a slightly cold tone, a reply longer than ideal), a prompt is the right tool, and far more flexible than code. If a single failure is money out the door, a regulatory breach, or a step the auditors will ask about, then "occasionally" is not an acceptable rate. That rule belongs in a hook. Read the scenario for the words that signal this: must, always, never, policy, compliance, threshold, financial.

Approach Runs every time? Can the conversation talk it out of the rule? Good for
Instruction in the system prompt No; the model weighs it against everything else Yes Tone, judgement, how to phrase an escalation
Few-shot examples (worked examples in the prompt) No; they shift the odds Yes Showing the shape of a good answer
A larger or newer model No; fewer mistakes, never zero Yes Harder reasoning, not compliance
A PreToolUse or PostToolUse hook Yes; it is code No Any rule with a cost of failure the business cannot accept

The column to remember is the second one. The prompt-side options are real techniques with their own uses; they become wrong answers only when a rule must hold every time.

Four ways to ask, one way to enforce

Stronger system prompt"never refund above $500"
Few-shot examplesof correct escalations
A bigger modelfewer mistakes, not zero
The rule in the tool descriptionstill just words the model reads
PreToolUse hook denies the callruns every time, whatever the model decided
Every option on the left improves the chance the model complies. Only the hook makes the model's decision irrelevant to whether the refund runs.

None of this means the prompt is useless once the hook exists. The best design uses both. The hook guarantees that a refund over $500 never runs. The prompt tells the model about the $500 policy so that it explains the escalation to the customer gracefully instead of being surprised by a denial. The hook is the seatbelt; the prompt is the driving lesson. You never remove the seatbelt because the lesson went well.

1.5.6 The exam traps

Each trap below puts a guaranteed rule in a probabilistic place, or uses the right mechanism at the wrong seam. The questions are usually framed as a production incident, and the fix is essentially always the hook at the right moment.

  • ✗ Strengthening the system prompt to stop refunds above $500. ✓ Register a PreToolUse hook on process_refund that denies the call. The prompt improves the odds; the hook removes them.
  • ✗ Adding few-shot examples of correct escalations, or upgrading the model. ✓ Same answer. Both are legitimate techniques that shift a probability, and the question is about a rule that needs a guarantee.
  • ✗ Putting the threshold in the tool's description. ✓ A description is text the model reads, so it is still a request. The hook is code the SDK runs.
  • ✗ Using PostToolUse to block the refund. ✓ PostToolUse runs after the tool has run, so the money has already moved. Prevention belongs in PreToolUse; transformation belongs in PostToolUse.
  • ✗ Telling the model in the prompt how to convert Unix timestamps and status codes. ✓ Normalise in a PostToolUse hook that returns updatedToolOutput. The conversion happens in code, on every result, and uses none of the model's context.
  • ✗ Treating the hook as a reason to drop the policy from the prompt. ✓ Keep both. The hook enforces; the prompt lets the model explain the escalation to the customer instead of being surprised by it.

1.5.7 Put it together: build the two hooks and break them

You now have every piece: the two seams, the registration, the reshaped result, the refused and redirected call, and the rule for choosing a hook. Build it small, then break each half on purpose; watching the refund go through is what makes the distinction stick.

The same seam enforces order as well as amounts. A PreToolUse hook that refuses lookup_order and process_refund until get_customer has returned a verified customer is a programmatic prerequisite, the pattern behind enforcing a multi-step workflow. Beyond this domain, tool design (Domain 2) decides what get_customer and lookup_order return in the first place. A well-designed tool returns clean data; a hook covers the tools you cannot change.

Key takeaways

  • ✓ A hook is your own code that the Agent SDK runs at a fixed point in the loop. You register it under an event name in the hooks option, with a matcher on the tool name (mcp__<server>__<tool> for MCP tools).
  • ✓ PreToolUse fires before a tool runs and can allow, deny or change the call; PostToolUse fires after it returns and can replace the result. Pre prevents, Post transforms.
  • ✓ A PostToolUse hook that returns updatedToolOutput normalises heterogeneous data (Unix timestamps, ISO 8601 dates, numeric status codes) into one shape in code, so the model never translates formats probabilistically.
  • ✓ A PreToolUse hook that returns permissionDecision: "deny" stops a policy-violating call such as a refund above $500, and its permissionDecisionReason tells the model the alternative workflow, escalate_to_human.
  • ✓ Prompt instructions, few-shot examples and bigger models give probabilistic compliance; a hook gives a deterministic guarantee. Choose the hook when a rule must hold every time.
  • ✓ Keep the policy in the prompt as well, so the model behaves gracefully around the rule the hook enforces; the hook is the seatbelt, not a replacement for the lesson.

Check your understanding

4 questions written for this lesson, then one from the CCAR-F question bank on the same topic. Every answer option is explained, including the ones you did not pick. Nothing is stored.

96 CCAR-F questions on Domain 1, free

Every question in the bank is tagged to a domain, so you can drill 96 questions on Agentic Architecture & Orchestration alone, or sit the full 60-question timed simulator.

Open the CCAR-F question bank → Back to Domain 1 →

The question bank is free. It asks for an account only because the quiz engine has to store answers to score them and show which domains are weak. The questions on this page need nothing.

Sources