A\
Lessons/Domain 1
1.512 min

Apply Agent SDK hooks for tool call interception and data normalization

Agent SDK hooks are deterministic callbacks that run inside the agentic loop, giving you code-level control that prompts cannot provide. A PreToolUse hook intercepts an outgoing tool call before it executes, so it can block policy-violating actions such as an over-limit refund and steer the model to escalate. A PostToolUse hook intercepts the result after a tool returns, so it can normalize heterogeneous formats (Unix timestamps, ISO 8601, numeric status codes) into one consistent shape before the model reasons over it. Reach for hooks whenever a business rule must be guaranteed rather than merely encouraged.

Hooks intercept the agentic loop PreToolUse gates outgoing calls; PostToolUse normalizes returning results Claude agentic loop PreToolUse allow / deny / modify deny if amount > $500 MCP tool runs on backend PostToolUse updatedToolOutput Unix to ISO, 2 to delivered tool_use allowed raw result reason steers model to escalate_to_human normalized, policy-checked result returned to model
The agentic loop with two hook interception points: PreToolUse gates the outgoing call (deny over-limit refunds) and PostToolUse normalizes the returning result (Unix to ISO, status code to label) before it reaches the model.

Hooks: deterministic code in the agentic loop

Hooks are callbacks the Agent SDK runs at fixed points in the agentic loop. Unlike the system prompt, which is advice the model may or may not follow, a hook is ordinary code that runs every time its event fires. That makes hooks the right tool whenever an outcome must be guaranteed rather than merely encouraged.

You register hooks in the SDK options, keyed by event name. Each entry is a HookMatcher whose matcher field filters which tool names trigger the hook (it accepts a regex such as 'Write|Edit') plus a list of callback functions.

from claude_agent_sdk import ClaudeAgentOptions, HookMatcher

options = ClaudeAgentOptions(
    hooks={
        'PreToolUse':  [HookMatcher(matcher='process_refund', hooks=[block_large_refund])],
        'PostToolUse': [HookMatcher(matcher='lookup_order',   hooks=[normalize_order])],
    }
)

Each callback receives (input_data, tool_use_id, context). input_data carries hook_event_name, tool_name, and tool_input; a PostToolUse callback also gets tool_response. The callback returns a dict, and an empty dict means 'proceed unchanged'.

The two events that matter for this task statement are PreToolUse, which fires before a tool runs, and PostToolUse, which fires after it returns. Everything below builds on that timing distinction.

PreToolUse: intercept and gate outgoing tool calls

PreToolUse fires after the model has decided to call a tool but before the tool actually executes. It is the interception point for enforcing compliance, because it is the only place you can stop a side effect from happening at all.

The callback returns a hookSpecificOutput object. Set permissionDecision to 'deny' to block the call, 'allow' to force-approve it, or 'ask' to request confirmation. Always include permissionDecisionReason: that text is fed back to the model, so a good reason lets the model recover by choosing another path (for example, escalating to a human) instead of stalling.

async def block_large_refund(input_data, tool_use_id, context):
    if input_data['tool_name'] == 'process_refund':
        amount = input_data['tool_input'].get('amount', 0)
        if amount > 500:
            return {
                'hookSpecificOutput': {
                    'hookEventName': 'PreToolUse',
                    'permissionDecision': 'deny',
                    'permissionDecisionReason': 'Refunds over $500 need human approval. Call escalate_to_human instead.'
                }
            }
    return {}

PreToolUse can also rewrite arguments before execution by returning updatedInput, which is handy for injecting safety flags or clamping parameters. But its headline use is the hard gate: a policy-violating action never reaches your backend.

PostToolUse: normalize results before the model sees them

PostToolUse fires after a tool returns but before the result is handed back to the model. Its input includes tool_response, the raw output of the tool. This is the correct place to normalize heterogeneous data so the model always reasons over one consistent shape.

Production systems wire many MCP tools to different backends, each with its own conventions: one returns a Unix epoch timestamp, another ISO 8601, a third a numeric status code like 2. If you leave this to the model, it must infer formats on every turn, burns tokens, and eventually errs. A PostToolUse hook converts everything deterministically. Return the rewritten payload in updatedToolOutput, or attach clarifying text with additionalContext.

STATUS = {0: 'pending', 1: 'shipped', 2: 'delivered', 3: 'returned'}

async def normalize_order(input_data, tool_use_id, context):
    order = dict(input_data['tool_response'])
    ts = order.get('created_at')
    if isinstance(ts, (int, float)):            # Unix epoch to ISO 8601
        order['created_at'] = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
    if isinstance(order.get('status'), int):    # numeric code to label
        order['status'] = STATUS.get(order['status'], 'unknown')
    return {
        'hookSpecificOutput': {
            'hookEventName': 'PostToolUse',
            'updatedToolOutput': order
        }
    }

Because the hook is centralized, you fix format drift in one place instead of teaching every prompt and every tool the same rules.

Deterministic guarantees vs probabilistic prompt compliance

The core judgement this task tests is knowing when a hook is the answer versus a prompt instruction. A system prompt such as 'never process a refund above $500' is guidance: the model obeys most of the time, but its compliance is probabilistic and a non-zero failure rate is expected, especially under ambiguous or adversarial input. When money moves, identity is verified, or PII is redacted, 'most of the time' is not acceptable.

A hook is code on the execution path. If the callback denies process_refund for amounts over 500, there is no phrasing, ordering, or jailbreak that lets a $750 refund through. The guarantee is structural, not persuasive.

The rule to memorize: choose hooks over prompt-based enforcement whenever a business rule must be guaranteed. Reserve prompts and few-shot examples for shaping judgement and tone, where some variance is tolerable. This mirrors task statement 1.4's split between programmatic enforcement and prompt-based guidance.

Timing: block before, transform after

The single most tested nuance is timing. PreToolUse runs before execution; PostToolUse runs after. That ordering decides which hook fits which job.

To block or alter a side-effecting action, you must use PreToolUse. If you tried to enforce the refund cap in a PostToolUse hook on process_refund, the refund would already be issued by the time your code ran, so blocking it then is too late. Conversely, you cannot normalize data in PreToolUse, because the result does not exist yet; normalization belongs in PostToolUse, where tool_response is available.

A compact way to remember it: PreToolUse guards the door, PostToolUse cleans the delivery. Prevention and compliance go in front, transformation and sanitization go behind.

Steering the loop, not killing it

A denied tool call does not end the conversation. When PreToolUse returns a deny decision, the model receives the permissionDecisionReason as feedback and continues the agentic loop, now free to pick a different action. This is how a hook redirects a blocked path onto an approved workflow.

In the support scenario, blocking a large refund with the reason 'call escalate_to_human instead' nudges the model to invoke escalate_to_human on its next turn. The hook enforced the policy; the model performed the graceful recovery. A deny with no reason wastes this: the model sees a blocked action, has no guidance, and may retry the same call or give up. Always pair a block with a reason that names the correct alternative.

Anti-patterns to avoid

avoid
Enforcing a refund cap by writing 'never process refunds above $500' in the system prompt.

Why it fails: Prompt instructions are probabilistic. The model complies most of the time but has a non-zero failure rate, and under ambiguous or adversarial input it can still call process_refund with $750, moving real money.

instead Add a PreToolUse hook on process_refund that denies amounts over 500. Code on the execution path delivers a guarantee that no prompt wording can.

avoid
Using a PostToolUse hook to catch and block an over-limit refund.

Why it fails: PostToolUse runs after the tool executes, so the refund has already been issued. Blocking it afterward cannot undo the side effect.

instead Gate side-effecting actions in PreToolUse, which runs before execution. Reserve PostToolUse for transforming results that already exist.

avoid
Telling the model in the prompt to convert all timestamps to ISO 8601 and map status codes to labels.

Why it fails: Normalization becomes the model's job on every turn: inconsistent, token-hungry, and occasionally wrong. Every new MCP tool with a different format needs yet another prompt rule.

instead Normalize deterministically in a PostToolUse hook using updatedToolOutput. One centralized place fixes format drift for every tool and every turn.

avoid
Denying a tool call without setting permissionDecisionReason.

Why it fails: The model receives a blocked action but no guidance, so it may retry the same call in a loop or abandon the task.

instead Include a permissionDecisionReason that names the correct alternative (for example, 'call escalate_to_human'), so the model can redirect to the approved workflow.

Worked example: Refund guardrail plus order normalization for the support agent (Scenario 1)

The support resolution agent exposes four MCP tools: get_customer, lookup_order, process_refund, and escalate_to_human. Two production requirements are non-negotiable: refunds above $500 must never be auto-approved, and order records arrive in inconsistent formats that confuse the model. Both are solved with hooks, not prompt edits.

Register a PreToolUse gate on process_refund (using block_large_refund from above) and a PostToolUse normalizer on lookup_order (using normalize_order from above):

from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, HookMatcher

options = ClaudeAgentOptions(
    hooks={
        'PreToolUse':  [HookMatcher(matcher='process_refund', hooks=[block_large_refund])],
        'PostToolUse': [HookMatcher(matcher='lookup_order',   hooks=[normalize_order])],
    }
)

async with ClaudeSDKClient(options=options) as client:
    await client.query('Customer wants a $750 refund for damaged order #A-1042.')

Trace of one run:

  1. The model calls lookup_order(order_id='A-1042'). The backend returns created_at=1719792000 and status=2.
  2. PostToolUse fires: normalize_order rewrites the payload to created_at='2024-07-01T00:00:00+00:00' and status='delivered'. The model now reasons over clean, consistent data.
  3. Following its resolution logic, the model calls process_refund(amount=750).
  4. PreToolUse fires before the backend is touched: 750 > 500, so the hook returns permissionDecision 'deny' with a reason to escalate. No money moves.
  5. The model reads the reason and calls escalate_to_human with the case details, producing a compliant hand-off.

Why this is the exam-correct design: the refund cap is a business rule with financial consequences, so it needs the deterministic guarantee of a PreToolUse gate, not the probabilistic compliance of a prompt. The normalization needs data that already exists, so it lives in PostToolUse. Swapping the two events breaks both, because a PostToolUse refund check runs too late and a PreToolUse normalizer has nothing to normalize.

Exam tips

  • PreToolUse intercepts OUTGOING tool calls (before execution) to allow, deny, ask, or rewrite input; PostToolUse intercepts RESULTS (after execution) to normalize or annotate them.
  • To block a side-effecting action such as a refund you MUST use PreToolUse. A PostToolUse check runs after the action already happened and cannot prevent it.
  • PreToolUse blocks with hookSpecificOutput.permissionDecision='deny' plus permissionDecisionReason; the reason is returned to the model so it can redirect to an alternative workflow like escalate_to_human.
  • PostToolUse normalizes heterogeneous formats (Unix epoch, ISO 8601, numeric status codes) by returning updatedToolOutput, or adds clarifying text with additionalContext.
  • Choose hooks when a business rule must be guaranteed. Prompt instructions are probabilistic with a non-zero failure rate; hooks are deterministic code on the execution path.
  • Hooks are registered in ClaudeAgentOptions(hooks=...) via HookMatcher(matcher=...), where matcher filters by tool name and accepts a regex like 'Write|Edit'. A hook returning an empty dict proceeds unchanged.
Official exam objectives for 1.5
Knowledge of
  • Hook patterns (e.g., PostToolUse) that intercept tool results for transformation before the model processes them
  • Hook patterns that intercept outgoing tool calls to enforce compliance rules (e.g., blocking refunds above a threshold)
  • The distinction between using hooks for deterministic guarantees versus relying on prompt instructions for probabilistic compliance
Skills in
  • Implementing PostToolUse hooks to normalize heterogeneous data formats (Unix timestamps, ISO 8601, numeric status codes) from different MCP tools before the agent processes them
  • Implementing tool call interception hooks that block policy-violating actions (e.g., refunds exceeding $500) and redirect to alternative workflows (e.g., human escalation)
  • Choosing hooks over prompt-based enforcement when business rules require guaranteed compliance

Flashcards from this lesson

Why can a PostToolUse hook not enforce a $500 refund limit?

It runs after the tool executes, so the refund is already issued. Use PreToolUse, which runs before execution and can deny the call.

Hooks vs system-prompt instructions for compliance?

Hooks are deterministic code with 100% enforcement. Prompt instructions are probabilistic with a non-zero failure rate. Use hooks when the rule must be guaranteed.

What field carries the block explanation back to the model, and why does it matter?

permissionDecisionReason. The model reads it and can redirect to an alternative workflow (for example escalate_to_human) instead of stalling.

How do you scope a hook to specific tools?

HookMatcher(matcher='...'), where matcher is a regex matched against the tool name, such as 'process_refund' or 'Write|Edit'.

What does a PostToolUse hook receive that a PreToolUse hook does not?

tool_response, the tool's actual output, which is what makes result normalization possible.

Which hook normalizes heterogeneous tool results, and how does it return the cleaned data?

PostToolUse. It returns the rewritten payload in hookSpecificOutput.updatedToolOutput, or annotates with additionalContext.

Which hook fires before a tool executes, and what can it do?

PreToolUse. It can allow, deny, or ask on the call, and can rewrite arguments via updatedInput, all before any side effect occurs.

Study all flashcards with spaced repetition