Building a refund agent, prompt-first
A refund agent is mostly a set of decisions about money: what it may settle on its own, what it must hand up, and which requests it declines. We take one from a one-line brief to a spec the checker passes, and let the compiler catch the two mistakes that make refund bots leak.
TL;DR Build a refund agent as a spec, not a paragraph: route by a typed refund reason so the compiler forces every case to be handled, draw the auto-approve versus escalate line explicitly, and pin the escalation with a $TEST. The checker catches the vague rule, the hedge, and the forgotten reason before they ship.
Refunds are where a support agent stops being a chatbot and starts being an employee with a budget. Every reply either moves money or refuses to, so the interesting part of the prompt is not the greeting: it is the authority. Who can this agent refund, for how much, and when does it have to ask a human? Written as a paragraph, those questions blur together. Written as a spec, they become rules a compiler can hold you to. Here is the whole build, starting from the brief a product manager would actually hand you.
§1The brief, and the first honest draft
The brief is one line: "Handle refund requests. Approve the small stuff, escalate the big stuff, and do not refund things outside the return window." The instinct is to type that back almost verbatim, in the reassuring voice of a policy doc. Here is that first draft, and here is what the checker thinks of it.
# Role You are a refunds agent for Acme, a subscription-box service. # Instructions - Handle every refund request appropriately. - Try to escalate large refunds to a manager when it seems necessary.
refunds.tg:5:31 info prompt/vague Vague - "appropriately" names a judgment call without the criterion to judge by, so nothing can check it. refunds.tg:6:3 info prompt/hedging Hedging - "Try to" turns this instruction into a suggestion the model may skip. Commit to a modal (MUST / NEVER / SHOULD). ✓ 1 file - 0 error, 0 warning, 2 info
§2Route by reason, and let the compiler count them
"The small stuff" and "the big stuff" are two axes hiding in the brief: why the refund is asked for, and how much it is. Take the reason first. A refund reason is a closed set: for Acme it is damaged, late, unwanted, or a duplicate charge. That is exactly the shape a typed input wants, so we declare it and route with a $SWITCH, one arm per reason. The payoff is not tidiness. It is that the compiler now knows the full list and will not let a branch go missing.
$IMPORT tool issue_refund, escalate_to_manager $REQUIRE variable refund_reason: one of damaged, late, unwanted, duplicate # Role You are a refunds agent for Acme, a subscription-box service. # Instructions $SWITCH ON @{refund_reason} - damaged:: Apologize, then issue the refund with @[issue_refund]. - late:: Issue the refund with @[issue_refund]. - unwanted:: Issue the refund with @[issue_refund] only if the order is within the 30-day return window. - MUST hand off with @[escalate_to_manager] when the refund is over 50 dollars. - NEVER promise a refund date.
refunds.tg:8:1 error structure/non-exhaustive-switch @{refund_reason} can be duplicate, but no arm handles it - add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback). ✗ 1 file - 1 error, 0 warning, 0 info
duplicate arm is not a style nit, it is a red build. This is the same totality proof a multichannel bake-off leans on: name a closed set and the compiler counts the cases for you, so "we forgot one" fails on the pull request instead of on a customer.§3Draw the authority line
Now the money axis, the one the brief hand-waved as "small" and "big." A refund agent needs a number, because "large" is not a policy a model can apply twice the same way. We already sketched it above: auto-approve through @[issue_refund], and hand anything over the ceiling to a person through @[escalate_to_manager]. Two things make that line real instead of decorative. First, both handoffs are tool pointers, not prose mentions, so the checker verifies the tool exists and is wired. Second, the ceiling is a concrete number the reply can be measured against, not an adjective. Add the missing duplicate arm, annotate each block with a @@ note explaining why it is there, and the file compiles clean.
$IMPORT tool issue_refund, escalate_to_manager $REQUIRE variable refund_reason: one of damaged, late, unwanted, duplicate # Role @@ role: a refunds agent scoped to Acme orders You are a refunds agent for Acme, a subscription-box service. # Instructions @@ triage: route by reason first, so every reason has a defined path $SWITCH ON @{refund_reason} - damaged:: Apologize, then issue the refund with @[issue_refund]. - late:: Issue the refund with @[issue_refund]. - unwanted:: Issue the refund with @[issue_refund] only if the order is within the 30-day return window. - duplicate:: Issue the refund with @[issue_refund] for the duplicate charge. @@ authority: cap what the agent settles on its own - MUST hand off with @[escalate_to_manager] when the refund is over 50 dollars. - NEVER promise a refund date.
NEVER promise a refund date line is the small print that keeps a cheerful model from inventing an SLA.§4Pin the sad path with a test
The prompt compiles, but "compiles" only means it is well-formed, not that it behaves. The rule most likely to erode under future edits is the authority ceiling: it is one line, it is easy to soften, and no customer complains when an agent refunds too readily. So we pin it. A $TEST states the case in plain terms, and typeglish test --dry validates the suite and reports coverage without spending a single model call, which is exactly what you want running in CI.
$IMPORT tool issue_refund, escalate_to_manager $REQUIRE variable refund_reason: one of damaged, late, unwanted, duplicate # Role @@ role: a refunds agent scoped to Acme orders You are a refunds agent for Acme, a subscription-box service. # Instructions @@ triage: route by reason first, so every reason has a defined path $SWITCH ON @{refund_reason} - damaged:: Apologize, then issue the refund with @[issue_refund]. - late:: Issue the refund with @[issue_refund]. - unwanted:: Issue the refund with @[issue_refund] only if the order is within the 30-day return window. - duplicate:: Issue the refund with @[issue_refund] for the duplicate charge. @@ authority: cap what the agent settles on its own - MUST hand off with @[escalate_to_manager] when the refund is over 50 dollars. - NEVER promise a refund date. $TEST escalates_large - input:: I want a refund on my 400 dollar annual plan. - expect:: - hands off to a manager - contains "manager"
contains "manager") that runs offline. The escalation is now a fact the build defends, not a habit you hope survives.refunds.tg - B (83/100) proven errors: none tiers: base+z3 planes runtime 82 (what the model reads) · hygiene 88 (source only) lever enforceability 52/100 (up to +13 overall) - Write rules as MUST / NEVER <verb> with concrete bounds, not vague qualities. ✓ refunds.tg coverage: 2/3 rules exercised · escalates_large - "I want a refund on my 400 dollar annual plan." (not run) ✓ contains "manager"
A refund agent is not a personality. It is an authority, and authority is a spec.
What we shipped is not longer than the paragraph we started with, but every line now has an edge a compiler can find. That is the whole prompt-first move: you do not write a refund agent and then wonder how it behaves, you write down the decisions and let the checker tell you which ones you left blurry. For the handoff mechanics themselves, tiering severity and pinning the tool call, see how to write escalation rules for an AI support agent; for the same build discipline on a different task, building an appointment-booking agent, prompt-first walks the scheduling case.
§5Common questions
- How do I stop my AI agent from issuing refunds it shouldn't?
- Draw the authority line explicitly in the prompt: state the ceiling the agent can settle on its own (for example, a refund over 50 dollars must hand off with an escalate tool), and pin that handoff with a $TEST so a later edit can't quietly remove it. A rule the checker can parse is a rule it can defend.
- Should an agent's refund reasons be a typed input or free text?
- Make them a typed input when the set is closed. Declaring $REQUIRE variable refund_reason: one of damaged, late, unwanted, duplicate and routing with $SWITCH means the compiler proves every reason has a handling arm, so adding a new reason later fails the build until you write its rule.
- How do I test a refund agent's escalation path without calling a model?
- Run typeglish test --dry. It validates your $TEST suite and reports which rules are exercised, fully offline with zero model calls, so you can prove the escalation path is covered in CI. A live run that actually answers each case needs an API key.
The $SWITCH exhaustiveness proof and the $TEST harness both shipped in TypeGlish 0.1.0. Together they are why a refund agent can be built the way you would build a small service: declare the closed sets, wire the tools, and let a red build stand in for the incident you would otherwise have shipped.