- Back to customer support, on purpose
- Programmatic enforcement vs. asking nicely
- One message, more than one problem
- A handoff a human can actually use
- Critical design decisions
- Consequences
Lessons Learned While Preparing for the Claude Architect Certification
Back to customer support, on purpose
Part 3 moved to a research pipeline because that objective’s own examples were about research subagents. This part moves back to customer support, for the same reason: the official objective for Task 1.4 is written around a refund flow. It talks about verifying a customer before a refund, about escalating a case a bot can’t finish alone, and about giving a human agent a full picture instead of a raw chat log to dig through. The exam guide’s own sample question describes the exact bug this part fixes: an agent that skips customer verification in 12% of cases and occasionally refunds the wrong account.
So the example this time is a single support agent again, close to the one Parts 1 and 2 started with — but with a question none of the earlier parts asked: what happens between two tool calls that absolutely must happen in the right order, and what happens when the agent can’t finish the job by itself?
Programmatic enforcement vs. asking nicely
Every part so far had a system prompt telling the model what to do. That’s prompt-based guidance, and it works well for most decisions — which subagent fits a question, how many sources to check. It stops being good enough the moment a step protects something with a financial consequence, like refunding money without knowing who the customer actually is.
The reason isn’t that the model is careless. A prompt instruction is probabilistic by nature. “Always verify the customer first” competes with everything else in the prompt and the conversation for the model’s attention on any given turn, and on some fraction of turns, it loses. A 12% skip rate isn’t a broken model — it’s what a probabilistic instruction protecting a critical step looks like at scale.
The fix is to stop asking and start checking. In the example project, process_refund doesn’t trust that get_customer ran earlier in the conversation. It checks a verified_customers set directly, at the top of the function, before doing anything else:
def process_refund(customer_id: str, order_id: str, amount: float) -> str:
if customer_id not in verified_customers:
return (
"ERROR: prerequisite not met. process_refund requires a "
"verified customer_id from get_customer first. retryable=false"
)
...
This is what “programmatic prerequisite” means: a gate in the code path, not a line in a prompt. It doesn’t matter whether the model forgot to call get_customer, whether a bug somewhere skipped it, or whether nobody went through the model at all. The example project shows that last case directly — demo_prerequisite_gate() calls process_refund straight from Python, with no agentic loop in between, and the gate still blocks it:
def demo_prerequisite_gate() -> None:
print(process_refund(customer_id="cust_001", order_id="A123", amount=40.00))
get_customer("alice@example.com")
print(process_refund(customer_id="cust_001", order_id="A123", amount=40.00))
The first call fails. The second one succeeds, once get_customer has actually run in between. No system prompt was involved in either outcome — the check gives the same result for any code calling these two functions in the wrong order, model or no model.
For comparison, the example project also keeps a prompt-only version of the same rule around:
PROMPT_ONLY_SYSTEM = (
"You are a customer support agent. Always call get_customer before "
"calling process_refund. Never skip this step."
)
That sentence isn’t wrong, and it isn’t useless — a clear system prompt still helps the model reach for the right tool in the right order most of the time. It’s just not the thing actually doing the protecting. If this prompt were the only defense, demo_prerequisite_gate()’s direct call would have gone straight through to process_refund with nothing standing in front of it, because a prompt only has influence over a model — and that direct call never involved a model at all.
A plain if statement inside process_refund is one way to build a gate like this. It’s not the only one. The Agent SDK has a dedicated mechanism for the same job — hooks that sit between the model and a tool call and can block it before it ever runs, without the tool’s own code having to know anything about verification. A hand-written check and an SDK hook are both “programmatic enforcement” in the sense that matters here: code decides, not a sentence in a prompt. The difference is where that code lives and how it’s wired in, and that’s specific enough to deserve its own part — Part 5 covers Agent SDK hooks directly.
You can find the full file on GitHub. A PHP port is available too, built the same way as the earlier parts.
One message, more than one problem
Real support messages rarely stick to one topic. “Check on order A123, and also refund order B456” is two separate concerns in one sentence, and a naive agent can end up half-answering one of them, or losing track of the second one while it’s busy explaining the first.
The example project doesn’t need any special machinery for this. The same agentic loop from Part 1 already handles it, because nothing in that loop forces tool calls to happen one at a time. A single response from the model can carry more than one tool_use block, and the loop already collects every one of them before it calls the model again:
calls = [b for b in response.content if b.type == "tool_use"]
if len(calls) > 1:
print(f" [{label}] {len(calls)} tool calls in this turn (parallel)")
Given a two-order message, the agent verifies the customer once, then looks up both orders. They don’t depend on each other, so nothing stops them from landing in the same turn. Only after both results are back does it write one combined reply covering both concerns. The decomposition itself is the model’s job, not something the code has to spell out — a system prompt that expects multiple concerns and expects one final answer is enough, and the existing loop takes care of the rest.
A handoff a human can actually use
Not every case ends with a tool result. Sometimes the agent hits a wall — an order that isn’t in the system, a request outside its authority — and the right move is to stop and hand the case to a person. What matters at that point is what the handoff actually contains.
A human picking up an escalated case usually doesn’t get the conversation transcript. They get whatever fields the escalation tool was called with. If those fields are vague — “customer has a problem, please help” — the human has to start the investigation from zero, which defeats the entire point of escalating a case the bot already partly worked through.
escalate_to_human is written so that a vague handoff isn’t even possible, using the same tool schema every other tool in the project already uses — this time with required fields:
"escalate_to_human": {
...
"input_schema": {
"type": "object",
"properties": {
"customer_id": {"type": "string"},
"root_cause": {"type": "string"},
"recommended_action": {"type": "string"},
"refund_amount": {"type": "number"},
},
"required": ["customer_id", "root_cause", "recommended_action"],
},
},
customer_id, root_cause, and recommended_action are required — the model can’t call this tool without supplying them, because a tool call missing a required field isn’t a valid call in the first place. refund_amount stays optional, since plenty of escalations don’t have a dollar figure attached yet — the “order isn’t even in the system” case in the demo is exactly that. When the tool runs, it prints exactly what a human needs to start working: who the customer is, why this needs a person, and what the agent already thinks should happen next.
Critical design decisions
- Enforcement lives in the tool, not the prompt:
process_refundchecks its own prerequisite before doing anything else. A system prompt can guide; only code sitting in the execution path can guarantee. - Test the gate without the model: calling a protected tool directly, the way
demo_prerequisite_gate()does, is the fastest way to prove a gate holds no matter what any model decides to do. - Decomposition doesn’t need new code: parallel tool calls already exist in the loop from Part 1. A multi-concern message is handled by the same mechanism Part 3 used for parallel subagent spawning — just aimed at ordinary tools this time.
- Required fields make a handoff complete by construction: putting
customer_id,root_cause, andrecommended_actionin the schema’srequiredlist means an incomplete handoff isn’t a call the model can make — not just a call it’s told not to make. - Optional fields for what isn’t always known yet:
refund_amountstays optional because forcing a number into every escalation would push the model to guess one it doesn’t actually have.
Consequences
- A prompt-only rule protecting a financial step -> a non-zero fraction of conversations skip it, close to the 12% figure in the exam guide’s own sample question. The instruction isn’t wrong; it just isn’t a guarantee.
- No gate at all -> the failure only shows up in production logs, after the wrong account has already been refunded, instead of failing loudly the moment it happens.
- Sequential-only concern handling -> a two-part request pays for two round trips it didn’t need, the same latency cost Part 3 covered for subagents, just showing up here in an ordinary support conversation.
- Optional or missing fields on an escalation tool -> a human agent picks up a case with half the picture, and ends up re-doing the investigation the bot already did, which is the entire cost escalation was supposed to save.
Enforcement and handoff aren’t really new ideas by themselves. They’re the same lesson from Part 1 — that a loop has to check something for certain instead of hoping the model got it right — applied to two new places: a gate between two tool calls, and a schema on the last one before a person takes over.