Rubric
Contents — domains, guide and mocks

Agent SDK hooks

CCAR-F 1.510 min read · checked 21 September 2026

Task statementApply Agent SDK hooks for tool call interception and data normalization

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

  1. Claude asks for toola tool_use with name and input
  2. PreToolUseallow, deny, or rewrite the input
  3. Tool runsyour function, MCP server or built-in
  4. PostToolUsereplace or annotate the output
  5. 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.
HookFiresExam use
PreToolUseBefore a tool executesBlock or redirect a call that breaks policy
PostToolUseAfter a tool returnsNormalise or trim the result before the model reads it
PostToolUseFailureWhen a tool call failsLog or handle errors consistently (less central to the exam)
SubagentStart / SubagentStopWhen a subagent spawns or finishesTrack 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.

Blocking refunds above a thresholdpython
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

Claude
Agent SDK
Your hook
Refund tool
Step 1: Claude to Agent SDK: Refund 900 for order 88412
Step 2: Agent SDK to Your hook: PreToolUse: name + input
Step 3: Your hook to Agent SDK: deny · “over 500, escalate”
Step 4: Agent SDK to Claude: Rejection as tool result
Step 5: Claude to Agent SDK: escalate_to_human(…)
The hook never negotiates, and the refund tool is never called. Claude reads the reason as the tool result and changes course.

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" }
Same order, two backends. After the hook, Claude only ever sees one date format and one status vocabulary.

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.

Normalising order-service outputpython
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 wrongDo this instead
Enforcing a financial limit with a prompt instructionDeny the call in a PreToolUse hook.
A bare denial with no reasonExplain the alternative so the model can take it.
Asking the model to reconcile mixed timestamp and status formatsNormalise tool results before the model sees them.
Using PostToolUse to enforce a limitThe tool has already run; block it in PreToolUse.
Matching a custom SDK tool by its bare nameMatch the full mcp__<server>__<tool> name.

You should now be able to

  • Write a PreToolUse hook that blocks policy-violating calls and redirects to an alternative.
  • Use PostToolUse to 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.

Practice questions

Original questions written for this lesson, in the exam’s style. Answer first, then open the reasoning — every option is explained, including why the wrong ones are tempting.

  1. Question 1

    Refunds above $500 must never be issued automatically. Which implementation guarantees this?

    1. AA system prompt rule backed by three few-shot examples.
    2. BA PostToolUse hook that reverses any large refund.
    3. CA PreToolUse hook denying refunds over $500.
    4. DLowering the model's temperature for refund turns.
    Show answer and reasoning
    1. AIncorrect. Prompts give probabilistic compliance, not a guarantee.
    2. BIncorrect. By then the refund has already been issued.
    3. CCorrect. The hook runs in code before the tool, every time, and can point Claude to escalation.
    4. DIncorrect. Sampling settings do not enforce business rules.
  2. Question 2

    Three MCP tools return dates as a Unix timestamp, an ISO 8601 string and a locale string. The agent occasionally misorders events. What is the best fix?

    1. AA PostToolUse hook that converts every date to ISO 8601.
    2. BExplain in the system prompt how to parse each date format.
    3. CA PreToolUse hook that blocks the tools returning timestamps.
    4. DAdd a fourth tool that compares dates on the model's behalf.
    Show answer and reasoning
    1. ACorrect. Normalising at the boundary removes the ambiguity before the model reasons about it.
    2. BIncorrect. Leaves conversion to the model, which is where the errors come from.
    3. CIncorrect. That removes data you need rather than fixing its format.
    4. DIncorrect. More tools add decisions; the problem is inconsistent input.
  3. Question 3

    A PreToolUse hook blocks deletions outside a project folder. It returns deny with no reason. Logs show the agent retrying the same deletion several times, then telling the user it “encountered an error”.

    What change best improves the behaviour while keeping the block?

    1. ASwitch the hook to allow and log the deletions for later review.
    2. BAdd a reason that says why and what to do instead.
    3. CAdd a system prompt line telling Claude not to retry denied calls.
    4. DRemove the deletion tool from the agent's tool set completely.
    Show answer and reasoning
    1. AIncorrect. That removes the protection the hook exists to provide.
    2. BCorrect. The reason reaches Claude as the tool result; with it, the model can stop retrying and take the permitted route.
    3. CIncorrect. It may reduce retries, but Claude still does not know why or what the alternative is.
    4. DIncorrect. Blocks legitimate deletions inside the project too; the hook was right, only its message was poor.
  4. Question 4

    A CRM MCP tool returns full customer records, including card numbers. Policy says card numbers must never enter the model's context, but the agent needs the rest of the record.

    Where should the masking happen?

    1. AIn the system prompt, telling Claude never to repeat card numbers.
    2. BIn a PreToolUse hook that denies every call to the CRM tool.
    3. CIn a PostToolUse hook returning updatedToolOutput with masking.
    4. DIn a separate subagent that reads the record and then summarises it.
    Show answer and reasoning
    1. AIncorrect. The numbers would still enter the context; the prompt only discourages repeating them.
    2. BIncorrect. The agent needs the rest of the record, so blocking the tool breaks the workflow.
    3. CCorrect. It replaces the output before Claude reads it, so the numbers never reach the context while the rest of the record does.
    4. DIncorrect. The subagent's own context would still contain the card numbers.

Sources

Drafted with AI assistance and checked against the sources above; expert review is in progress. Spotted an error? Tell us and it gets fixed, dated and listed on how this is written.