← Tidelines/Guides

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.

by TypeGlish team7 min read#guides
A refund is a decision. Write it down.

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.

refunds.tg (first draft)flagged
# 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.
Two lines, two soft spots. "appropriately" is a judgment call with no stated criterion, and "Try to ... when it seems necessary" is a rule the model is free to skip. Both read fine to a human and mean nothing to a runtime.
tg check - output
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
Nothing here blocks the build yet, but both notes point at the same disease: the draft describes a vibe, not a decision. The rest of this build is turning each vibe into a rule with an edge.

§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.

refunds.tg (routing, one reason short)✗ blocked
$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.
We wrote three arms and moved on, the way anyone does. The domain has four reasons.
tg check - output
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
The forgotten 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.

refunds.tg✓ compiles
$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.
Every reason has an arm, every action names a real tool, and the one number in the prompt is the one decision that actually needs a number. The 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.

refunds.tg (test block, appended)✓ compiles
$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"
The test does double duty: a judged expectation for the behavior ("hands off to a manager") and a deterministic assert (contains "manager") that runs offline. The escalation is now a fact the build defends, not a habit you hope survives.
tg score & test --dry - output
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 B, and the lever tells the truth: the switch arms are prose the model reads, so enforceability is the facet with the most headroom. That is the honest ceiling for a routing prompt. Tighten the arm wording (bounded sentence counts, explicit tool-only actions) and it climbs; the point is the grade is a measurement, not a vibe.
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.
Field note

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.

∿ washed up Jul 22, 2026 ∿