> Markdown rendition of https://typeglish.dev/blog/build-returns-agent ("Building a returns-eligibility agent, prompt-first - Tidelines"). Canonical page: https://typeglish.dev/blog/build-returns-agent · All pages: https://typeglish.dev/llms.txt

[← Tidelines](https://typeglish.dev/blog)/ Guides Sep 23, 2026

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

by **TypeGlish team** 11 min read #guides

Thirty days since what?

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

## §1 The 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.

brief.tg - the seven lines as sent ✗ D (59/100)

```typeglish
# 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.
```

Six constraints. Five of them are things the desk genuinely does. The sixth, *be helpful and friendly*, is in every prompt ever written.

The score is the interesting part, because of which line lands where.

tg score brief.tg - output

```
$ 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
```

Line 5 is the whole returns policy, and the rules table files it as `= 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.

## §2 Your 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.

rules.tg - the window, promoted to two rules ✓ B (84/100)

```typeglish
# 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.
```

Two lines, one boundary, no contradiction: the prover is happy that *within 30 days* and *after 30 days* do not overlap.

tg check and tg score on the promoted rules - output

```
$ 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
```

Twenty-five points better than the brief, zero findings, both lines read as rules. And the file is now precisely as useful as it was before.

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](https://typeglish.dev/blog/agent-has-no-history) 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.

## §3 The 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.

days.tg - the window as a threshold chain ✓ 0 error

```typeglish
<$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.
```

Read it the way you would read it in review. Three arms, three thresholds, the grace period and the window both present, every number where the policy says it should be.

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.

tg check, then the build for a ten-day-old order - output

```
$ 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.
```

Ten days after delivery, inside every version of the policy anyone has ever written, and the prompt the model receives says `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.

## §4 One 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.

states.tg - a closed domain with an arm per member ✓ 0 error

```typeglish
<$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.
```

There is no order to get wrong. A `$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.

tg check, before and after a sixth state joins the domain - output

```
$ 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
```

A blocking error, so the build is refused and nothing ships until somebody writes the damaged arm. Compare the same negligence on the day-count chain: a new case there is a new threshold, and a new threshold in the wrong position is `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.

## §5 The 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](https://typeglish.dev/blog/teardown-pasted-policy) that genuinely belongs in the prompt.

spec.tg - the returns desk, finished ✓ A (93/100)

```typeglish
<$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
```

Note what is missing. No thirty-day arithmetic, no *final sale*, no *just outside the window*, and no *be helpful and friendly*. Every one of those was either a fact the runtime owns or a sentence nothing could check.

tg score and tg test --dry on the spec - output

```
$ 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.

tg build spec.tg, bound to the late state - output

```
$ 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.
```

Five instruction lines. The other four states, the five `@@` 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](https://typeglish.dev/blog/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.

## §6 Common 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 `$SWITCH` arm. 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 scores `A (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 at `B (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 `$IF` chain over a number is the one shape the compiler cannot referee: put the 45 day arm above the 30 day arm and the file is `0 error, 0 warning, 0 info` while 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 is `structure/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 read` and `enforceability 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 at `A (93/100)`.

Field note

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.

∿ washed up Sep 23, 2026 ∿
