Production Agent Architecture: Agent SDK Hooks (Part 5)


04 Aug 2026  Istvan Dobrentei  11 mins read.

Lessons Learned While Preparing for the Claude Architect Certification

Picking up where Part 4 left off

Part 4 built a prerequisite gate straight into process_refund’s own code, and closed with a promise: the Agent SDK has a dedicated mechanism for this same kind of check, called hooks, and they deserved their own part. This is that part.

The objective for Task 1.5 asks about two things that sound different but solve the same underlying problem — a tool result or a tool call passing through untouched when it shouldn’t. The first is interception: stopping a tool call before it runs, the way Part 4’s gate stopped an unverified refund. The second is normalization: cleaning up a tool’s result after it runs, before the model ever reads it. Both are described as hooks — PreToolUse for the first, PostToolUse for the second — and both exist so that a rule doesn’t have to live inside the tool it applies to, or inside the model’s head.

The example keeps the same three tools this agent actually uses on a given case — lookup_order, process_refund, escalate_to_human — but drops Part 4’s customer-verification step. That step is real and worth keeping in a production agent, but it’s a different lesson (a prerequisite gate, not a hook), and dragging it along here would only add a customer_id to thread through every function without teaching anything new about hooks. What’s left is small enough to hold in your head in one read: two tools that do real work, one that hands a case to a person, and two hooks watching the traffic between the model and all three.

Two hook points in one loop

Every part so far reused the same agentic loop, with small additions each time. This part’s addition is two lists of functions the loop consults on every tool call: pre_tool_hooks, checked before the real tool runs, and post_tool_hooks, checked after:

for block in calls:
    blocked = None
    for hook in pre_tool_hooks:
        blocked = hook(block.name, block.input)
        if blocked is not None:
            break

    if blocked is not None:
        result = blocked
    else:
        result = tool_impls[block.name](**block.input)
        for hook in post_tool_hooks:
            result = hook(block.name, block.input, result)

A pre-tool hook takes the tool’s name and its input, and returns either None — “no objection, proceed” — or a string that replaces the entire call. When that happens, tool_impls[block.name] never runs at all. A post-tool hook takes the name, the input, and whatever the tool actually returned, and hands back a (possibly rewritten) result. This is a hand-rolled stand-in for what the real Agent SDK calls PreToolUse and PostToolUse, small enough to read in one pass, but it draws the exact same line: interception happens before the call, normalization happens after it.

You can find the full file on GitHub. A PHP port is available too, built the same way as the earlier parts.

Blocking a call the tool never sees

In this example, process_refund itself contains no policy logic at all — no threshold, no check of any kind. It trusts its inputs and does the one thing it’s for:

def process_refund(order_id: str, amount: float) -> str:
    if order_id not in ORDERS:
        return f"ERROR: order {order_id} not found. retryable=false"
    return f"Refunded ${amount:.2f} for order {order_id}."

Every bit of the $500 rule lives somewhere else entirely, in a hook that process_refund never calls and doesn’t know exists:

REFUND_THRESHOLD = 500.00

def block_large_refunds(tool_name: str, tool_input: dict[str, Any]) -> str | None:
    if tool_name != "process_refund":
        return None
    amount = tool_input.get("amount", 0)
    if amount <= REFUND_THRESHOLD:
        return None
    return (
        f"BLOCKED by policy hook: a refund of ${amount:.2f} exceeds the "
        f"${REFUND_THRESHOLD:.2f} threshold this agent may approve on its "
        "own. Call escalate_to_human instead."
    )

block_large_refunds reads tool_name and tool_input the same way every hook does — it has no special access to process_refund’s internals, because there’s nothing there to access. Compare that to Part 4’s gate, which lived inside process_refund itself: a check written that way only ever protects the one tool it’s written into, and changing the rule means editing that tool’s code. A hook sits one layer above every tool call instead, so raising the threshold to $750, or adding a second rule for a completely different tool, never touches process_refund at all.

demo_hook_without_model() proves this the same way Part 4’s gate demo did — by calling the hook directly, with no model and no loop involved:

def demo_hook_without_model() -> None:
    big = {"order_id": "A123", "amount": 900.00}
    small = {"order_id": "A123", "amount": 40.00}
    print("large refund:", block_large_refunds("process_refund", big))
    print("small refund:", block_large_refunds("process_refund", small))
    print("different tool:", block_large_refunds("lookup_order", big))

The $900 request comes back blocked. The $40 request and the unrelated lookup_order call both come back None — the hook only ever has an opinion about process_refund calls above its threshold, and stays out of the way for everything else.

For comparison, the example project also keeps the prompt-only version of this exact rule around, the same way Part 4 did:

PROMPT_ONLY_REFUND_POLICY = (
    "Never approve a refund over $500 on your own — always escalate "
    "those to a human agent instead of calling process_refund."
)

This sentence would genuinely help, most of the time — the same as Part 4’s prompt-only version helped, most of the time. It has the same ceiling, too: nothing forces the model to reread it before every single refund decision, and a threshold that protects money deserves better than “most of the time.”

One shape out of two backends

The second hook solves a different problem. lookup_order in this example pulls from two backends that describe an order differently — a legacy system using a numeric status code and a Unix timestamp, and a modern one using a status word and an ISO 8601 date:

ORDERS = {
    "A123": {"status_code": 2, "shipped_at_unix": 1750000000, ...},   # legacy backend
    "B456": {"status": "delivered", "shipped_at_iso": "2025-06-20T10:00:00Z", ...},  # modern backend
}

Nothing about which backend answers is under the model’s control, and nothing about it should have to be. Without a hook, the model would have to learn both shapes, recognize which one it’s looking at on every single order, and hope it never confuses 2 for “delivered” with 2 meaning something else in a different response. A PostToolUse hook removes that entirely by converting both shapes into one, before either ever reaches the model:

def normalize_order_result(tool_name: str, tool_input: dict[str, Any], raw_result: str) -> str:
    if tool_name != "lookup_order" or not raw_result.startswith("{"):
        return raw_result

    order = json.loads(raw_result)
    if "status_code" in order:
        status = STATUS_CODE_MAP[order["status_code"]]
        date = datetime.fromtimestamp(order["shipped_at_unix"], tz=timezone.utc).date().isoformat()
    else:
        status = order["status"]
        date = order["shipped_at_iso"][:10]
    return f"status={status} date={date} amount=${order['amount']:.2f}"

demo_normalization() runs both raw results through this hook and prints them side by side. Both come out as status=delivered date=... amount=$... — same three fields, same format, regardless of which backend produced the original response. The error strings lookup_order returns for a missing order pass straight through untouched, since they don’t start with {; a hook that tries to normalize everything indiscriminately usually ends up breaking the one shape it wasn’t expecting.

Critical design decisions

  • Interception happens before the tool runs, normalization after: a PreToolUse hook can stop a call outright; a PostToolUse hook only ever gets to reshape a result the tool already produced.
  • A hook doesn’t require touching the tool it governs: block_large_refunds never edits process_refund, and process_refund never mentions the threshold. That decoupling is the whole reason to reach for a hook instead of another in-function check.
  • None means “no objection,” not “nothing happened”: a pre-tool hook has to actively return a replacement to block anything. Anything it doesn’t recognize passes straight through.
  • Test a hook the same way you test a gate: demo_hook_without_model() calls block_large_refunds directly, no model or loop required, for the same reason Part 4 tested its gate that way — the guarantee shouldn’t depend on the model cooperating.
  • Normalize only what you recognize: normalize_order_result checks the shape of what it received before touching it, so error strings and anything unexpected pass through unchanged instead of crashing on a shape the hook wasn’t written for.

Consequences

  • A business rule written inside one tool -> protecting a second tool with the same rule means copying the check, and every copy can drift out of sync with the others. A hook that inspects tool_name applies the same rule everywhere it’s registered, from one place.
  • A prompt-only threshold on a financial operation -> the same non-zero failure rate Part 4 described, just applied to a dollar amount instead of an identity check.
  • No normalization hook, two backends, one model -> the model has to hold both formats in its head and guess correctly every time, and a status code that means one thing in one response can silently mean something else if a third backend joins later with its own numbering.
  • A hook that transforms without checking the shape first -> the moment an error string or an unexpected format reaches it, it either crashes or silently mangles something it was never meant to touch.

Interception and normalization are really the same idea pointed in two directions: a layer between the model and a tool that gets to see every call and every result before anyone else does, and can act on what it sees without either side needing to know it’s there.