← Tidelines/Guides

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.

by TypeGlish team11 min read#guides
The desk decides. You gather.

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.

brief.tg - the six lines as sent✗ C (71/100)
# 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.
Nothing here is careless. It is how a competent operations lead describes a job to another person, and every line of it is a real policy.

The checker disagrees about how much of it is a policy it can hold.

tg score brief.tg - output
$ 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
Three of six. The half the compiler cannot read is not the sloppy half: it is the half carrying the refund decision, the evidence step, and the goodwill policy. Everything about the money is invisible.

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.

verbs.tg - the same six requirements, in verbs the checker reads✓ B (86/100)
$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.

kinds.tg - one arm per kind of dispute✗ 3 info
<$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.
Coverage is proven by construction here: every member of the domain has an arm, and the losing arms never reach the model. Add a fifth kind to the domain later and this block stops compiling until somebody writes its question.

Three of those four arms draw an info-level finding, and the pattern in which ones is worth two minutes of your life.

tg check kinds.tg, then the same file with adjective members - output
$ 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
Lines 11, 13 and 14 are the arms named 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.

cap.tg - the cap as a bound, with the December edit under it✗ 2 error
# 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.
The second line is the edit that arrives in a storm week, from somebody who has not read the file, and it is a perfectly reasonable instruction on its own.
the same edit, over both spellings of the cap - output
$ 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.

spec.tg - the billing-dispute desk, finished✓ A (96/100)
<$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
Twelve rule-shaped lines, eight of them read as rules, four of them arm bodies the compiler selects rather than proves. The word refund survives in exactly one place: a prohibition on stating a date for one.

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.

tg test --dry, before and after the third case - output
$ 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
The uncovered rule was the money one, which is the one an auditor asks about. 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.

tg build spec.tg - output
$ 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. ...
Twelve lines for a desk that handles the most argued-about contact type in utilities. The nine @@ 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. and NEVER 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. beside NEVER state a refund date.), a bound on the goodwill the agent can spend, and a reply length. That file is 0 error, 0 warning, 0 info at A (96/100) with 8 of 8 rules covered by a $TEST case.
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. is prompt/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, call a declared tool. Six such swaps take the same policy from C (71/100) with 3 of 6 rule-shaped lines read to B (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. and NEVER 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 later MUST offer a goodwill credit of at least 25 dollars. is 2 blocking logic/numeric errors 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 as NEVER offer a goodwill credit of more than 10 dollars. and the identical edit is 0 error, 0 warning, 0 info and ships, because never offering at all satisfies both lines.
Field note

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.

∿ washed up Sep 17, 2026 ∿