Building a billing-dispute agent, prompt-first
A dispute desk is an intake job wearing the costume of a judgement job. The brief will ask for the judgement anyway, in verbs no prover can read, and that is the whole build.
TL;DR Build a billing-dispute agent as intake, never adjudication: two prohibitions (NEVER approve a dispute., NEVER reject a dispute.), a lookup before the argument, a case opened before the reply, and a cap written as a bound. The brief's own verbs are the obstacle, because refund and promise are not verbs the checker reads: as sent, the six requirements are C (71/100) with 3 of 6 rule-shaped lines read, and the same six, reworded, finish at A (96/100) with 8 of 8 rules covered by a $TEST.
Calder Utilities, an energy supplier with about forty agents, is putting a bot on the front of its billing queue. Not on refunds: on disputes. Somebody is charged twice, somebody does not recognise a line on the statement, somebody paid on the third and got a late fee on the fifth. The desk behind the bot decides all of that. The bot's job is to find the charge, ask the one question that makes the case reviewable, open it, and say the deadline out loud. Here is the brief that arrives, and the six commands that turn it into a spec.
§1The brief, checked as written
Six lines in a message from the head of billing ops. Type them out and you have a document that reads like a finished prompt.
# Role You are a billing support agent for Calder Utilities, an energy supplier. # Constraints - You MUST be empathetic and professional. - Check the charge on the account before you say anything about it. - IF the customer is right THEN refund the charge. - USUALLY offer a goodwill credit when the customer is upset. - NEVER promise a refund date. - Escalate anything over 100 dollars to a human agent.
The checker disagrees about how much of it is a policy it can hold.
$ npx typeglish score brief.tg
brief.tg — C (71/100) proven errors: none tiers: base+z3
planes runtime 77 (what the model reads) · hygiene 50 (source only)
facets enforceability 50 x.21 · hardness 92 x.12 · directness 88 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 0 x.12 (hygiene) · style 46 x.08 · security 100 x.08
rules 3 of 6 rule-shaped lines read · 2 not provable · 1 unmeasurable
L5 ~ unmeasurable You MUST be empathetic and professional.
no observable action or bound, so nothing can check compliance → name a concrete action or a bound ("at most 3 sentences")
L6 ✗ invisible Check the charge on the account before you say anything about it.
no modal (MUST / NEVER / SHOULD), and the checker knows "check" but cannot prove this shape: it stops reading at "before" — the clause after it is not part of the provable action, so it reads as prose → lead with a modal (MUST …) to make it a rule, and if the clause is a condition, make it one: WHEN <clause> THEN MUST <verb> …; otherwise give it its own rule
L7 ✗ invisible IF the customer is right THEN refund the charge.
the branch after THEN ("refund the charge") has no modal, and "refund" is not a verb the checker knows, so the line reads as prose → put a modal and a catalog verb in the branch: THEN MUST / NEVER / SHOULD <verb> …
L8 ✗ invisible USUALLY offer a goodwill credit when the customer is upset.
USUALLY is a frequency word, not a modal — the logic plane weighs it on the occasion square, but the rule reader does not count it as a rule → lead with a modal (MUST / NEVER / SHOULD); a soft rule is SHOULD
L9 ~ unprovable NEVER promise a refund date.
"promise" is not a verb the checker knows → use a verb from the catalog (redirect, escalate, ask, cite …) or add a numeric bound
Read line 7 again, because it is the one that matters. IF the customer is right THEN refund the charge. That sentence hands the adjudication to the model, and it does it so politely that it survives every review. Nobody at Calder intends the bot to decide who is right about a charge. The brief says it anyway, because that is how people talk about disputes.
§2The verbs in your policy are not the verbs in the catalog
TypeGlish reads directive heads against a verb ontology, so a rule is provable when its verb is one the checker has a frame for. Refund is not. Promise is not. Credit, waive, bill and charge are not. That list is most of a billing department's vocabulary, and it is the single reason a finance prompt scores worse than a support prompt written with the same care.
The move is not to soften the policy. It is to say the same policy with verbs that carry a frame: approve and reject for the decision, state and cite for what comes out of the agent's mouth, call for a declared tool. Note that both halves of the decision need their own line. A prohibition on approving says nothing about refusing, because not approving is not declining: they are different verbs on different rows of the ontology.
$TOOL find_charge - description:: Look up a charge on the account by date and amount. - input:: - charge_date:: string - amount:: number # Role You are a billing support agent for Calder Utilities, an energy supplier. # Constraints - MUST keep every reply to at most 4 sentences. - WHEN the customer questions a charge THEN MUST call @[find_charge]. - NEVER approve a dispute. - NEVER reject a dispute. - NEVER state a refund date. - SHOULD offer a goodwill credit.
0 error, 0 warning, 0 info, B (86/100), and 6 of 6 rule-shaped lines read at enforceability 100. The empathy line is gone, not because empathy does not matter but because it was never a rule; brand voice belongs where it can be demonstrated rather than asserted.The remaining lever is annotation 0, which is twelve points of the grade sitting in a habit: every statement wants a @@ why line above it, stripped before the model reads anything. Those go in as the rules go in, not at the end. Worth noticing what the swap did and did not buy: the file is more checkable and not yet more correct. It still cannot tell a duplicate charge from a late fee, and it still lets an agent hand out any amount of goodwill it likes.
§3Four kinds of dispute, and one typed input
A dispute is reviewable when the case carries the one fact the reviewer will need, and which fact that is depends entirely on the kind of dispute. Ask a duplicate-charge customer for the date of the second charge. Ask an unrecognised-charge customer who else uses the account. That is a closed set with an arm each, which is a $SWITCH ON over a typed input rather than four paragraphs of prose the model has to sort at runtime.
<$CONFIG> $REQUIRE variable dispute_kind: one of duplicate, unrecognized, amount, fee </$CONFIG> # Role You are a billing support agent for Calder Utilities, an energy supplier. # Constraints - MUST keep every reply to at most 4 sentences. $SWITCH ON @{dispute_kind} - duplicate:: MUST ask for the date of the second charge. - unrecognized:: MUST ask who else uses their account. - amount:: MUST ask for the expected amount. - fee:: MUST ask for the payment date.
Three of those four arms draw an info-level finding, and the pattern in which ones is worth two minutes of your life.
$ npx typeglish check kinds.tg kinds.tg:11:3 info prompt/unregistered-doer A bare generic doer never enters the world model - instruction to the agent, or background about users? Use the imperative if the agent acts, or a definite party ("The user should ...") to register the doer. kinds.tg:13:3 info prompt/unregistered-doer A bare generic doer never enters the world model - instruction to the agent, or background about users? Use the imperative if the agent acts, or a definite party ("The user should ...") to register the doer. kinds.tg:14:3 info prompt/unregistered-doer A bare generic doer never enters the world model - instruction to the agent, or background about users? Use the imperative if the agent acts, or a definite party ("The user should ...") to register the doer. ✓ 1 file — 0 error, 0 warning, 3 info $ npx typeglish check kinds-adj.tg ✓ 1 file — 0 error, 0 warning, 0 info
duplicate, amount and fee. Line 12, unrecognized, is silent. A member whose name is a noun reads as the subject of the sentence that follows it, so amount:: MUST ask ... parses as a thing called amount doing the asking. Rename the members as adjectives (duplicated, unrecognized, overcharged, late) and the file is clean.Nothing was wrong with the first version in any way a customer would notice. It is a quarter-point each on the style facet. It is in here because it is exactly the kind of thing you cannot reason your way to from the outside: the members of a domain are read as words, so name them the way you would name a state and not the way you would name a column.
§4The cap has to be a bound, not a ban
The brief's goodwill line was usually offer a goodwill credit when the customer is upset, which is invisible twice over: USUALLY is a frequency word rather than a modal, and there is no amount in it. The desk's actual rule is that an agent can spend up to ten dollars to close a small annoyance without opening a case. That is a number, so write the number. There are two natural spellings and they behave completely differently.
# Role @@ role: one named agent on one desk, so every later "you" is this agent You are Wren, a billing support agent for Calder Utilities, an energy supplier. # Constraints @@ goodwill: the desk lets an agent close a small annoyance without a case - MUST keep a goodwill credit to at most 10 dollars. @@ peak: storm week, ops wants a bigger gesture on the phones - MUST offer a goodwill credit of at least 25 dollars.
$ npx typeglish check cap.tg ban.tg cap.tg:7:1 error logic/numeric Conflicts with line 9. Numeric conflict — "at most 10 dollars" and "at least 25 dollars" can't both hold. ↳ line 9: - MUST offer a goodwill credit of at least 25 dollars. cap.tg:9:1 error logic/numeric Conflicts with line 7. Numeric conflict — "at most 10 dollars" and "at least 25 dollars" can't both hold. ↳ line 7: - MUST keep a goodwill credit to at most 10 dollars. ✗ 2 files — 2 error, 0 warning, 0 info $ npx typeglish build cap.tg --out-dir d4 typeglish build: cap.tg refused — nothing written $ npx typeglish build ban.tg --out-dir d4 ✓ built d4/ban.txt ← ban.tg (ce8269d7ed28, full)
ban.tg is the identical pair of rules with the cap spelled NEVER offer a goodwill credit of more than 10 dollars. It builds. The prover is not being lazy: never offering anything at all satisfies both a prohibition above ten and an obligation above twenty-five, so there is no contradiction to find. A ceiling written as a ban is a ceiling nothing has to climb.So the shape of a money rule matters more than its wording. Bound what the agent must do and the next person's edit lands in the same slot and gets caught; forbid what it must not do and the next edit lands somewhere else entirely. Same lesson as the refund cap your agent quietly exceeds, arriving from the other direction. And keep the modal hard: the soft version of this pair, SHOULD against SHOULD, is 0 error, 0 warning, 0 info, which is what softening a rule costs you in one line.
§5The spec, and the rule no test touched
Everything together: the tools declared, the two prohibitions that keep the verdict on the desk, the evidence step, the case step, the deadline, the cap, the length, the switch, and a @@ note on every statement.
<$CONFIG> $REQUIRE variable dispute_kind: one of duplicated, unrecognized, overcharged, late </$CONFIG> $TOOL find_charge - description:: Look up a charge on the account by date and amount. - input:: - charge_date:: string - amount:: number $TOOL open_dispute - description:: Open a dispute case for the billing desk to review. - input:: - charge_id:: string - reason:: one of duplicated, unrecognized, overcharged, late # Role @@ role: one named agent on one desk, so every later "you" is this agent You are Wren, a billing support agent for Calder Utilities, an energy supplier. # Constraints @@ evidence_first: nobody should argue about a charge neither party has looked at - WHEN the customer questions a charge THEN MUST call @[find_charge]. @@ no_verdict: the billing desk decides whether a charge stands, not the agent - NEVER approve a dispute. @@ no_verdict_either_way: refusing is a decision too, and it is the desk's decision - NEVER reject a dispute. @@ open_the_case: a dispute nobody recorded is a dispute nobody reviews - WHEN the customer disputes a charge THEN MUST call @[open_dispute]. @@ no_date: finance controls the refund calendar and it moves - NEVER state a refund date. @@ window: the customer has to hear the deadline while they can still act on it - MUST cite a 60 day dispute window. @@ goodwill: the desk lets an agent close a small annoyance without a case - MUST keep a goodwill credit to at most 10 dollars. @@ brevity: a billing reply is read on a phone with the bill open beside it - MUST keep every reply to at most 4 sentences. @@ kind: each kind of dispute needs a different first question $SWITCH ON @{dispute_kind} - duplicated:: MUST ask for the date of the second charge. - unrecognized:: MUST ask who else uses their account. - overcharged:: MUST ask for the expected amount. - late:: MUST ask for the payment date. $TEST charged_twice - input:: I have been charged twice for August, can you just refund it? - expect:: - calls find_charge - does not approve the dispute - cites the 60 day dispute window $TEST angry_about_a_fee - input:: This late fee is outrageous, I paid on the 3rd. Take it off now. - expect:: - calls open_dispute - does not state a refund date - at most 4 sentences $TEST small_annoyance - input:: You charged me 3 dollars for a paper bill I never asked for. This is ridiculous. - expect:: - keeps a goodwill credit to at most 10 dollars - does not state a refund date
The third test was not in the first draft. It is there because test --dry named the rule nobody had written a case for, which is a more useful question than are my tests passing.
$ npx typeglish test spec.tg --dry ✓ spec.tg coverage: 7/8 rules exercised uncovered (no case shares the rule's wording): L35 keep a goodwill credit to at most 10 dollars. $ npx typeglish test spec.tg --dry ✓ spec.tg coverage: 8/8 rules exercised · charged_twice — "I have been charged twice for August, can you just refund it" (not run) · rubric — 3 prose expectations, judged on a live run (needs ANTHROPIC_API_KEY) · angry_about_a_fee — "This late fee is outrageous, I paid on the 3rd. Take it off " (not run) ✓ at most 4 sentences · rubric — 2 prose expectations, judged on a live run (needs ANTHROPIC_API_KEY) · small_annoyance — "You charged me 3 dollars for a paper bill I never asked for." (not run) · rubric — 2 prose expectations, judged on a live run (needs ANTHROPIC_API_KEY) ✓ 1 prompt — 0 failed
at most 4 sentences is checked deterministically and offline; the prose expectations need a live run and a key. Coverage is not correctness, but an uncovered money rule is a question you would rather answer before somebody else asks it.What ships is the artifact, not the source. The build strips the scaffolding, the notes and the tests, resolves the switch, and writes the twelve lines the model actually reads.
$ npx typeglish build spec.tg --out-dir out
✓ built out/spec.txt ← spec.tg (ab9b9e39a1d2, full)
$ cat out/spec.txt
# Role
You are Wren, a billing support agent for Calder Utilities, an energy supplier.
# Constraints
- WHEN the customer questions a charge THEN MUST call find_charge.
- NEVER approve a dispute.
- NEVER reject a dispute.
- WHEN the customer disputes a charge THEN MUST call open_dispute.
- NEVER state a refund date.
- MUST cite a 60 day dispute window.
- MUST keep a goodwill credit to at most 10 dollars.
- MUST keep every reply to at most 4 sentences.
If dispute_kind is duplicated: MUST ask for the date of the second charge. ...
@@ notes, the two tool schemas and the three test cases are all source, and none of them costs a token at runtime.The brief asked the bot to decide who was right. The spec asks it to find out what happened.
If this is your first prompt-first build, building a refund agent is the companion piece: same industry, opposite authority model, and the contrast between the two is most of what a CX prompt engineer needs to know about writing down who decides.
§6Common questions
- How do I write a system prompt for a billing-dispute agent?
- Write it as an intake job with two prohibitions at the centre of it:
NEVER approve a dispute.andNEVER reject a dispute.Around those, put the evidence step (WHEN the customer questions a charge THEN MUST call @[find_charge].), the record step (WHEN the customer disputes a charge THEN MUST call @[open_dispute].), the one thing the customer needs and nobody wants to promise (MUST cite a 60 day dispute window.besideNEVER state a refund date.), a bound on the goodwill the agent can spend, and a reply length. That file is0 error, 0 warning, 0 infoatA (96/100)with 8 of 8 rules covered by a$TESTcase. - Why does the checker ignore my rule about refunds?
- Because refund is not a verb in the catalog, and neither is promise.
IF the customer is right THEN refund the charge.isprompt/unparsed-rule, reported as the branch after THEN has no modal, and refund is not a verb the checker knows, so the line reads as prose. It earns no enforceability credit and gets no consistency proof. Say the same thing with a verb the checker does read:approve,reject,state,cite,calla declared tool. Six such swaps take the same policy fromC (71/100)with 3 of 6 rule-shaped lines read toB (86/100)with 6 of 6. - Should a billing agent decide whether a disputed charge is wrong?
- No, and
IF the customer is right THEN refund the charge.is how that decision gets delegated by accident. Right is not observable, so no rule built on it can be checked, tested, or audited afterwards. Write the two halves the desk actually owns instead,NEVER approve a dispute.andNEVER reject a dispute., because a prohibition on approving says nothing about refusing: they are separate rules on separate verbs, and leaving one out is how an agent learns to talk a customer out of filing. - How do I stop a later edit from breaking a money cap in my prompt?
- Write the cap as a bound on what the agent must do, not as a thing it must never do.
MUST keep a goodwill credit to at most 10 dollars.next to a laterMUST offer a goodwill credit of at least 25 dollars.is 2 blockinglogic/numericerrors reading at most 10 dollars and at least 25 dollars can't both hold, and the build is refused with nothing written. Spell the same cap asNEVER offer a goodwill credit of more than 10 dollars.and the identical edit is0 error, 0 warning, 0 infoand ships, because never offering at all satisfies both lines.
The verb problem is the part of this build that generalises furthest, and it is worth saying plainly: the checker is not refusing to understand your industry, it is telling you which of your sentences have a shape it can defend. Every domain has a vocabulary that feels like a set of actions and behaves like a set of nouns. Billing has refund, credit, waive and charge. Insurance has settle, indemnify and subrogate. Logistics has expedite and reroute. Writing a rule in those words feels precise, because to a colleague it is, and it produces a line the compiler files under prose and never mentions again. The habit that fixes it is small: after the first draft, run typeglish review and read the rules table before anything else, because it is the only place that tells you which of your policy is currently decoration. On this build it said three of six on the first pass. The six requirements never changed.