Building a returns-eligibility agent, prompt-first
Every returns policy starts with a sentence about thirty days. It is the one sentence your agent has no way to evaluate, and building the desk means getting it out of the prompt entirely.
TL;DR A returns agent cannot evaluate within 30 days of delivery, because the prompt has no delivery date and no today: compute the eligibility state where the order record lives, declare it as a closed typed input, and give every state one $SWITCH arm. The brief as sent is D (59/100) with 2 of 6 rule-shaped lines read; the spec is A (93/100), and what a late customer's model actually reads is five lines.
Kestrel Outdoors sells jackets and boots to people who then walk through bogs in them. Returns are the second-biggest contact type on the desk, after where is my order, and they are the one where a wrong answer costs money in both directions: a refund nobody authorised, or a customer told no by a bot that was guessing. The head of retail ops sends seven lines. Here is what the compiler makes of them, and the four decisions that turn them into a spec.
§1The brief, checked as written
Nothing in this is careless. It is a returns policy, correctly summarised, by somebody who has run the desk for six years.
# Role You are a returns assistant for Kestrel Outdoors. # Constraints - Returns are accepted within 30 days of delivery unless the item is final sale. - Be helpful and friendly. - If the item has been worn or washed we cannot take it back. - Always issue the refund to the original payment method. - Never tell the customer their return is approved before the warehouse checks it. - Offer store credit if they are just outside the window.
The score is the interesting part, because of which line lands where.
$ npx typeglish score brief.tg
brief.tg — D (59/100) proven errors: none tiers: base+z3
planes runtime 64 (what the model reads) · hygiene 45 (source only)
facets enforceability 30 x.21 · hardness 100 x.12 · directness 48 x.08 · consistency 100 x.17 · structure 89 x.12 (hygiene) · annotation 0 x.12 (hygiene) · style 4 x.08 · security 100 x.08
lever enforceability 30/100 (up to +15 overall) — 3 rule-shaped lines the checker reads as prose (L6 L7 L10): lead with a modal (MUST / NEVER / SHOULD); 2 hard rules it cannot prove (L8 L9): one action, one object, a catalog verb or a bound; 1 rule with no observable action or bound (L6). The rules table below has the edit per line
rules 2 of 6 rule-shaped lines read · 2 not provable · 1 unmeasurable
L5 = fact Returns are accepted within 30 days of delivery unless the item is final sale.
a declared fact, not a rule — the world model reads it; it has no modal to enforce
L6 ~ unmeasurable Be helpful and friendly.
no observable action or bound, so nothing can check compliance → name a concrete action or a bound ("at most 3 sentences")
L7 ✗ invisible If the item has been worn or washed we cannot take it back.
the condition has no THEN branch, so nothing is required when it holds → add THEN MUST / NEVER / SHOULD <verb> …
L8 ~ unprovable Always issue the refund to the original payment method.
"issue" is not a verb the checker knows → use a verb from the catalog (redirect, escalate, ask, cite …) or add a numeric bound
L9 ~ unprovable Never tell the customer their return is approved before the warehouse checks it.
the checker knows "tell" but cannot prove this shape: it stops reading at "before" — the clause after it is not part of the provable action → if the clause is a condition, make it one: WHEN <clause> THEN MUST <verb> …; otherwise give it its own rule
L10 ✗ invisible Offer store credit if they are just outside the window.
no modal (MUST / NEVER / SHOULD), and the checker knows "offer" but cannot prove this shape: it stops reading at "if" — 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 to make it provable
L6 prompt/unmeasurable −1 Unmeasurable rule — "be <quality>" names no observable action, so nothing can
L5 prompt/agentless-passive −0.25 The doer is deleted (agentless passive) - who performs this? Name the actor or
L7 typeglish/if-then −0.25 IF needs a THEN — write IF <condition> THEN <action>.
L8 prompt/unintroduced-definite −0.25 "the refund" retrieves something this document never introduces - a model must
L8 prompt/unintroduced-definite −0.25 "the original payment method" retrieves something this document never introduc
L9 prompt/agentless-passive −0.25 The doer is deleted (agentless passive) - who performs this? Name the actor or
L10 prompt/unintroduced-definite −0.25 "the window" retrieves something this document never introduces - a model must
= fact: a declared fact, not a rule. It is not flagged, not warned about, not counted against enforceability. It is simply not a rule, and nothing downstream will ever check it.That is the first decision of the build, and it arrives before any typing. The thirty-day window is not a rule the prompt lost the modal for. It is a rule the prompt cannot hold at all, and the next section is why.
§2Your agent has no calendar
Take line 5 seriously as a rule. Give it a modal, split the exception off, and write the pair a careful author would write.
# Role You are Rowan, a returns assistant for Kestrel Outdoors. # Constraints - MUST accept a return within 30 days of delivery. - NEVER accept a return after 30 days of delivery.
$ npx typeglish check rules.tg
✓ 1 file — 0 error, 0 warning, 0 info
$ npx typeglish score rules.tg
rules.tg — B (84/100) proven errors: none tiers: base+z3
planes runtime 96 (what the model reads) · hygiene 50 (source only)
facets enforceability 85 x.21 · hardness 100 x.12 · directness 100 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 0 x.12 (hygiene) · style 100 x.08 · security 100 x.08
lever annotation 0/100 (up to +12 overall) — 3 statements without a @@ note (L2 L5 L6) — put a "@@ why" line directly above each
rules 2 of 2 rule-shaped lines read · 1 not provable
L6 ~ unprovable NEVER accept a return after 30 days of delivery.
the checker knows "accept" but cannot prove this shape: it stops reading at "after" — the clause after it is not part of the provable action → if the clause is a condition, make it one: WHEN <clause> THEN MUST <verb> …; otherwise give it its own rule
Ask what the model does with it. To act on within 30 days of delivery, it needs the delivery date and it needs today's date. It has neither. The delivery date is in the order record, which the prompt does not contain, and today is not a thing a language model knows: it will infer one from context, from the customer's phrasing, from the last date anyone mentioned in the transcript. Your policy remembers and your agent does not is the general version of this; the returns desk is where it costs the most, because the answer is a yes or a no about money.
No checker catches that. B (84/100) with zero findings is an honest report on a file whose central rule cannot be evaluated by the thing reading it. The compiler proves properties of the document; it cannot know which facts your runtime is holding back. What it can do is prove things about whatever you put in place of the guess, which is the entire argument for doing the arithmetic somewhere else.
§3The day count is the wrong input
The obvious fix is to pass the number in. Declare a typed input, and let the chain compare it: the runtime knows the delivery date, so it can subtract. This is where the brief's last line, offer store credit if they are just outside the window, gets its real shape: thirty days for a refund, a fortnight of grace for store credit, nothing after that.
<$CONFIG> $REQUIRE variable days_since_delivery: integer </$CONFIG> # Role You are Rowan, a returns assistant for Kestrel Outdoors. # Constraints $IF @{days_since_delivery} is at most 45: - MUST offer store credit. $ELSE IF @{days_since_delivery} is at most 30: - MUST offer a refund to their original payment method. $ELSE: - NEVER offer a refund.
The arms are in the wrong order. A $IF chain is first-true-wins, so is at most 45 swallows every case the second arm was written for, and a customer who is ten days past delivery gets store credit instead of the refund they are owed. Here is the referee on that.
$ npx typeglish check days.tg ✓ 1 file — 0 error, 0 warning, 0 info $ npx typeglish build days.tg --out-dir out --vars '{"days_since_delivery": 10}' ✓ built out/days.txt ← days.tg (e4367da7ea98, full) $ cat out/days.txt # Role You are Rowan, a returns assistant for Kestrel Outdoors. # Constraints - MUST offer store credit.
MUST offer store credit. Nothing is flagged, because nothing is provably wrong: overlapping thresholds on an open integer are a legal chain, and the compiler has no way to know which arm you meant first.That is the limit of what a number buys you. The thresholds are still in the prompt, the ordering is still a human's job, and the failure is a silent one. Move the comparison out instead: let the runtime decide which state the order is in and pass the state, not the raw count.
§4One state, one arm each
List the states a return can be in, from the desk's point of view. Resaleable. Worn. Excluded, which is what final sale means once you stop saying it in marketing's words. Late but inside the grace period. Expired. Five members, mutually exclusive, and the order record decides which one applies before the conversation starts.
<$CONFIG> $REQUIRE variable return_state: one of resaleable, worn, excluded, late, expired </$CONFIG> # Role You are Rowan, a returns assistant for Kestrel Outdoors. # Constraints $SWITCH ON @{return_state} - resaleable:: MUST offer a refund to their original payment method. - worn:: MUST decline their return. - excluded:: MUST decline their return. - late:: MUST offer store credit. - expired:: MUST decline their return.
$SWITCH ON takes members of a closed domain, not predicates, so the arms are unordered and coverage is proven by construction rather than by reading.The payoff is the edit that has not happened yet. In January, the desk adds a sixth state for items that arrive damaged, because a damaged item is not a worn one and the difference is who pays the postage. Somebody adds damaged to the domain.
$ npx typeglish check states.tg ✓ 1 file — 0 error, 0 warning, 0 info $ npx typeglish check states-6.tg states-6.tg:9:1 error structure/non-exhaustive-switch @{return_state} can be damaged, but no arm handles it — add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback). ✗ 1 file — 1 error, 0 warning, 0 info
0 error, 0 warning, 0 info.This is the trade the whole build turns on. A number keeps the decision in the prompt where nothing can referee it. A closed domain moves the decision to the compile-time plane, where the compiler can prove you handled every case it knows about. The cost is that your runtime has to compute the state, which is a fifteen-line function over a table you already have.
§5The spec, and the five lines that ship
The switch is the eligibility half. The other half is the rules that hold no matter which state the order is in, and the brief had three of them hiding in its unreadable lines. The warehouse decides, so the agent never approves. Every return needs a case opened, or the parcel arrives at a loading bay expecting nobody. And the customer needs to hear the deadline while they can still act on it, which is the one part of the pasted policy doc that genuinely belongs in the prompt.
<$CONFIG> $REQUIRE variable return_state: one of resaleable, worn, excluded, late, expired </$CONFIG> $TOOL open_return - description:: Open a return authorisation for the warehouse to inspect. - input:: - order_id:: string - reason:: string # Role @@ role: one named agent on one desk, so every later "you" is this agent You are Rowan, a returns assistant for Kestrel Outdoors, an outdoor clothing retailer. # Constraints @@ authority: the warehouse inspects the item and decides, so the desk only opens the case - NEVER approve a return. @@ record: a return nobody opened is a parcel nobody expects - WHEN the customer asks to send an item back THEN MUST call @[open_return]. @@ window: the customer needs the deadline while they can still act on it - MUST cite a 30 day return window. @@ brevity: a returns reply is read on a phone beside an open parcel - MUST keep every reply to at most 3 sentences. @@ state: the runtime computes eligibility from the order record, so the prompt only says what follows from it $SWITCH ON @{return_state} - resaleable:: MUST offer a refund to their original payment method. - worn:: MUST decline their return. - excluded:: MUST decline their return. - late:: MUST offer store credit. - expired:: MUST decline their return. $TEST jacket_does_not_fit - input:: This jacket does not fit, can I send it back? It turned up last Tuesday. - expect:: - calls open_return - cites the 30 day return window - at most 3 sentences $TEST worn_on_a_hike - input:: I wore these boots on one hike and the soles are scuffed. I want my money back. - expect:: - does not approve the return - at most 3 sentences
$ npx typeglish score spec.tg
spec.tg — A (93/100) proven errors: none tiers: base+z3
planes runtime 91 (what the model reads) · hygiene 100 (source only)
facets enforceability 72 x.21 · hardness 100 x.12 · directness 88 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 100 x.12 (hygiene) · style 100 x.08 · security 100 x.08
lever enforceability 72/100 (up to +6 overall) — Lead every rule with a modal — MUST / NEVER / SHOULD — or put it after THEN; one action and one object per line, a verb the checker knows or a bound ("at most 3 sentences"). Descriptive detail with no bound does not move this facet. `typeglish score <file>` prints the rules table with the edit per line; `typeglish --explain enforceability <file>` prints just that table.
rules 4 of 9 rule-shaped lines read · 4/4 covered by $TEST
L26 · arm resaleable:: MUST offer a refund to their original payment method.
a $SWITCH / $IF arm body — selected at compile time; the checker does not read arm bodies as rules
L27 · arm worn:: MUST decline their return.
a $SWITCH / $IF arm body — selected at compile time; the checker does not read arm bodies as rules
L28 · arm excluded:: MUST decline their return.
a $SWITCH / $IF arm body — selected at compile time; the checker does not read arm bodies as rules
L29 · arm late:: MUST offer store credit.
a $SWITCH / $IF arm body — selected at compile time; the checker does not read arm bodies as rules
L30 · arm expired:: MUST decline their return.
a $SWITCH / $IF arm body — selected at compile time; the checker does not read arm bodies as rules
$ npx typeglish test spec.tg --dry
✓ spec.tg coverage: 4/4 rules exercised
· jacket_does_not_fit — "This jacket does not fit, can I send it back? It turned up l" (not run)
✓ at most 3 sentences
· rubric — 2 prose expectations, judged on a live run (needs ANTHROPIC_API_KEY)
· worn_on_a_hike — "I wore these boots on one hike and the soles are scuffed. I " (not run)
✓ at most 3 sentences
· rubric — 1 prose expectation, judged on a live run (needs ANTHROPIC_API_KEY)
✓ 1 prompt — 0 failed
4 of 9 rule-shaped lines read is the honest price of the switch: the five arm bodies are reported as selected at compile time, so they do not earn enforceability credit. They are not unproven, they are pre-decided. The four rules that hold in every state stay outside the switch, where they are proven and where a test can reach them.Then the build, for a customer thirty-eight days past delivery.
$ npx typeglish build spec.tg --out-dir out --vars '{"return_state": "late"}'
✓ built out/spec.txt ← spec.tg (43eb783fb1d4, full)
$ cat out/spec.txt
# Role
You are Rowan, a returns assistant for Kestrel Outdoors, an outdoor clothing retailer.
# Constraints
- NEVER approve a return.
- WHEN the customer asks to send an item back THEN MUST call open_return.
- MUST cite a 30 day return window.
- MUST keep every reply to at most 3 sentences.
MUST offer store credit.
@@ notes, the tool schema and both test cases are source: none of them costs a token, and none of them can be misread by the model, because the model never sees them.The brief asked the agent to work out whether a return was in time. The spec tells it what to say about a return that already is, or is not.
If the remedies themselves are the argument on your desk rather than the eligibility, a preference is not an order is the companion: it is the same retail conversation, one layer down, about why prefer a replacement over a refund decides nothing at all.
§6Common questions
- How do I write a system prompt for a returns agent?
- Keep the calendar out of it. Have the runtime compute one eligibility state from the order record, declare it as a closed domain (
$REQUIRE variable return_state: one of resaleable, worn, excluded, late, expired), and give every member one$SWITCHarm. Around the switch put the rules that hold whatever the state is:NEVER approve a return.because the warehouse inspects it,WHEN the customer asks to send an item back THEN MUST call @[open_return].,MUST cite a 30 day return window., and a reply length. That file scoresA (93/100)and builds, for a late return, into five lines. - Why does my agent get the return window wrong?
- Because the prompt asked it to do arithmetic on a date it was never given. Returns are accepted within 30 days of delivery reads to the checker as
= fact, a declared fact, not a rule, and even the tidied version,MUST accept a return within 30 days of delivery., checks clean atB (84/100)while remaining unevaluable: the model has no delivery date and no today. Nothing in the file is wrong, and nothing in it can be right. Compute the days elapsed where the order record lives and pass the answer in. - Should the return window be a number in the prompt or a variable?
- Neither, if you can avoid it. A day count as a typed input still leaves the thresholds in the prompt, and an ordered
$IFchain over a number is the one shape the compiler cannot referee: put the 45 day arm above the 30 day arm and the file is0 error, 0 warning, 0 infowhile every ten-day-old return is offered store credit instead of a refund. Pass the state, not the number. On a closed domain a missing arm isstructure/non-exhaustive-switch, a blocking error. - Does putting rules in a $SWITCH lower my TypeGlish score?
- Yes, and it is usually worth it. The score reports arm bodies as a $SWITCH / $IF arm body, selected at compile time; the checker does not read arm bodies as rules, so the finished spec reads
4 of 9 rule-shaped lines readandenforceability 72. The five arm bodies are not unproven, they are pre-decided: the losing arms never reach the model. Keep the rules that hold in every state outside the switch, where they are proven, and the file still lands atA (93/100).
The generalisable move here is not about returns, and it is not about dates. It is a question to ask of every line in a brief: can the thing reading this sentence actually evaluate it? A surprising share of a support policy fails that test quietly. Anything about elapsed time fails it. Anything about what the customer has done before fails it. Anything about stock, or the state of a case, or what another team decided this morning fails it. The prompt is a document, not a program with access to your database, and a rule whose truth depends on a fact the document does not carry is a rule the model will answer by vibe. The compiler will not tell you which lines those are, because from inside the file they look exactly like the good ones. What it will do, once you have moved the fact into a typed input, is refuse to build the day somebody adds a case you have not handled. That is the trade: you give up the comfortable sentence, and you get a build that fails instead of a customer who gets told no in January for a reason nobody can reconstruct in March.