← Tidelines/Guides

Building an order-tracking agent, prompt-first

Where is my order is the single highest-volume question a retail contact centre answers, and the brief for it fits on a napkin. Here is what happens when you type the napkin into a compiler.

by TypeGlish team10 min read#guides
Five states. No guessing.

TL;DR Build a tracking agent in three separated pieces: policy as rules, live facts through a $TOOL, and the branch over order state as a typed $REQUIRE variable the flow fills, so a $SWITCH ON proves no state was forgotten. The seven-line brief this post starts from checks at 0 error, 0 warning, 4 info with consistency 100 while containing a flat contradiction; the finished tracking.tg is 0 error, 0 warning, 0 info at B (89/100), covers 4/4 rules, and builds to five different artifacts, one per state.

Marlow & Fen sell homeware online. Their tracking lane was split off the general support agent in March because it is sixty percent of the volume, and the brief for it came out of a planning doc as seven bullets. Nothing in those bullets is wrong, exactly. That is the problem with briefs.

§1The napkin, typed straight in

brief.tg - the seven bullets, unedited✗ C (68/100)
# Role
You are the order tracking assistant for Marlow & Fen, an online homeware retailer.

# Constraints
- Always give the customer a delivery date so they know when to expect their order.
- Never promise a delivery date, because the carrier owns the schedule.
- You should try to be helpful and reassuring when an order is late.
- Look up the order with lookup_order and tell the customer the latest scan.
- If the tracking has not updated, tell them we will chase the carrier and come back within 24 hours.
- We chase the carrier and come back within 2 working days.
- If the customer is upset, escalate.
Lines 5 and 6 say opposite things. Lines 9 and 10 give two different chase windows. Line 8 names a tool that does not exist yet. Read it and you can see all three.
tg check brief.tg - output
$ npx typeglish check brief.tg
brief.tg:6:1  info   prompt/unintroduced-definite  "the schedule" retrieves something this document never introduces - a model must guess which schedule is meant. Introduce it on another line ("You manage a schedule.") or name it outright.
brief.tg:7:14  info   prompt/hedging  Hedging — "try to" turns this instruction into a suggestion the model may skip. Delete the hedge, or commit to a modal (MUST / NEVER / SHOULD).
brief.tg:9:1  info   typeglish/if-then  IF needs a THEN — write IF <condition> THEN <action>.
brief.tg:11:1  info   typeglish/if-then  IF needs a THEN — write IF <condition> THEN <action>.

 1 file — 0 error, 0 warning, 4 info

$ npx typeglish score brief.tg
brief.tg — C (68/100)  proven errors: none  tiers: base+z3
  planes  runtime 77 (what the model reads) · hygiene 41 (source only)
  facets  enforceability 41 x.21 · hardness 85 x.12 · directness 83 x.08 · consistency 100 x.17 · structure 81 x.12 (hygiene) · annotation 0 x.12 (hygiene) · style 81 x.08 · security 100 x.08
Four cosmetic findings, zero errors, and consistency 100. The prover found nothing to disagree with, because on this file there is almost nothing it can read: enforceability 41 is the number saying so. A brief is not a weak spec. It is a document mostly outside the spec plane.

That is the whole reason to do this prompt-first rather than prompt-later. The defects are not introduced by writing the rules properly. They are exposed by it.

§2Pass one: make the lines into rules

Nothing clever yet. Every bullet becomes a directive with a modal and a subject, and the two IF lines get their THEN.

tg check pass2.tg - output✗ 2 error, 1 warning
$ npx typeglish check pass2.tg
pass2.tg:5:1  error  logic/contradiction  Conflicts with line 5. Logical conflict — "give the customer a delivery date" is both required and forbidden. Keep one, or scope the two rules so they cannot both apply (IF <condition> THEN ...).
pass2.tg:6:1  error  logic/contradiction  Conflicts with line 4. Logical conflict — "give the customer a delivery date" is both required and forbidden. Keep one, or scope the two rules so they cannot both apply (IF <condition> THEN ...).
pass2.tg:7:1  warn   prompt/unmeasurable  Unmeasurable rule — no observable action or bound, so nothing can check compliance. Name a concrete action ("cite the source", "at most 3 sentences") or move it to a prose block.

 1 file — 2 error, 1 warning, 0 info
Same two sentences as the brief, minus the because clause and the bare Always, and now the build refuses. Helpful and reassuring becomes prompt/unmeasurable in the same pass, which is the correct verdict on it: it belongs in a # Context block, not in the rule list.

The delivery-date conflict resolves the way it always does once somebody is forced to pick: the carrier owns the schedule, so the agent does not name a date, and the customer gets the latest scan and the carrier's own window instead. One rule survives.

The chase window is the more interesting one, because it does not resolve, and the reason is worth an entire section of your SLA review.

tg check, two chase windows, two spellings - output✗ 2 error
$ npx typeglish check sla-days.tg sla-working.tg
sla-days.tg:5:1  error  logic/time-strength  Conflicts with line 5. One bound per slot — "within 24 hours" already entails "within 2 days"; the looser rule on "you chase the · carrier" is dead weight. Keep one.
sla-days.tg:6:1  error  logic/time-strength  Conflicts with line 4. One bound per slot — "within 24 hours" already entails "within 2 days"; the looser rule on "you chase the · carrier" is dead weight. Keep one.

 2 files — 2 error, 0 warning, 0 info
Two files, identical but for one word. sla-days.tg says within 2 days and is 2 blocking errors. sla-working.tg says within 2 working days and has no findings at all. Working days is the phrase every contact centre writes into every SLA, and the time layer does not read it as a duration.

That is a genuine blind spot, and the practical response is not to argue with it: write the bound in the prompt in hours or plain days, where the prover can hold it, and keep the working-days wording for the sentence the customer reads. The same trick fixes the pair here, and the brief's real answer turns out to be that there is one chase window, not two, and nobody had noticed that the planning doc contained both.

§3Who decides which answer the customer gets

A tracking reply is not one answer, it is five, and choosing between them is the actual design decision in this agent. The tempting version is three prose conditionals.

prose.tg - the branch as conditionals the model weighs✓ B (84/100)
# Role
You are the order tracking assistant for Marlow & Fen, an online homeware retailer.

# Constraints
- IF the order is preparing THEN you MUST say a picker has it and it has not left us yet.
- IF the order is moving THEN you MUST read back the latest scan.
- IF the order is late THEN you MUST offer a callback.
0 error, 0 warning, 0 info at B (84/100), with enforceability 90, which is higher than the finished spec at the end of this post scores. It is also missing the delivered-but-not-received case, which is the hardest call a tracking agent ever has to make, and nothing anywhere says so.

That is the trap in one figure. Three open conditionals cannot be proven complete, so their incompleteness is not a finding; it is just the shape of the document. Meanwhile the flow already knows the answer. Every mainstream contact-centre platform calls the order API before the model turn and has the state in hand. Declare it, and the branch becomes a table the compiler can audit.

tg check, one member renamed - output✗ 1 error
$ npx typeglish check collide.tg
collide.tg:14:3  error  structure/undeclared-tool  Tool "in_transit" is used but never imported (add it to a $IMPORT tool line).
collide.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 — 1 error, 0 warning, 1 info
Worth hitting once so you recognise it. A domain member spelled in_transit, in a file that declares tools, is read as a tool reference and blocks the build. Snake case is the tool namespace. Name the member transit and the error goes away.

§4The spec that ships

Five states, one tool behind one service, four rules, and a note on every statement.

tracking.tg - the finished spec✓ B (89/100)
<$CONFIG>
  $REQUIRE variable order_state: one of preparing, transit, delivered, delayed, missing
</$CONFIG>

$SERVICE oms
  - base:: https://api.marlowfen.com
  - headers::
    - Authorization:: Bearer @{env.OMS_TOKEN}

$TOOL lookup_order
  - description:: Returns the latest carrier scan and the promised window for one order.
  - input::
    - order_id:: string: the order reference the customer quotes.
  - request:: GET oms /orders/@{order_id}/tracking

# Role
@@ role: the tracking lane only, split off the general support agent in March
You are the order tracking assistant for Marlow & Fen, an online homeware retailer.

# Constraints
@@ reference_first: a lookup with no reference returns the wrong order confidently
- You MUST ask the customer for an order reference before you look anything up.
@@ scan_before_story: every sentence about location comes from a scan, never from the state name
- You MUST call @[lookup_order] before you describe where an order is.
@@ no_date: the carrier owns the schedule, and a date from us reads as a promise from us
- You MUST NOT give the customer a delivery date.
@@ brevity: tracking answers get read on a phone, at the door
- You MUST keep every reply to at most 4 sentences.

@@ state_table: the flow resolves the state before the turn, so the model never guesses it
$SWITCH ON @{order_state}
  - preparing:: You MUST say a picker has the order and it has not left us yet.
  - transit:: You MUST read back the latest scan and name the town it happened in.
  - delivered:: You MUST ask the customer to check with a neighbour and any safe place, then offer to open a claim.
  - delayed:: You MUST say a courier holds the order and is running behind, then offer a callback.
  - missing:: You MUST open a claim and say a replacement ships within 2 days.

$TEST reference_asked_first
  - input:: Where is my order?
  - expect::
    - contains "order"
    - at most 4 sentences

$TEST no_date_offered
  - input:: My order reference is MF-40192. When will it turn up?
  - expect::
    - at most 4 sentences
    - The reply gives the latest scan and does not name a delivery date.
Note what the scan_before_story rule is doing. The switch arm tells the agent which kind of answer to give; the tool is the only source of any fact inside it. That split is what stops delivered becoming a sentence the model invents details for, which is the failure described in why your agent invents a case reference.
tg check, score and test on the finished file - output
$ npx typeglish check tracking.tg
 1 file — 0 error, 0 warning, 0 info

$ npx typeglish score tracking.tg
tracking.tg — B (89/100)  proven errors: none  tiers: base+z3
  planes  runtime 86 (what the model reads) · hygiene 100 (source only)
  facets  enforceability 57 x.21 · hardness 90 x.12 · directness 92 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 57/100 (up to +9 overall) — Write rules as MUST / NEVER <verb> … with concrete bounds ("at most 3 sentences"), not vague qualities — a rule the checker can parse is a rule it can defend.

$ npx typeglish test tracking.tg --dry
 tracking.tg  coverage: 4/4 rules exercised
  · reference_asked_first — "Where is my order?" (not run)
       contains "order"
       at most 4 sentences
  · no_date_offered — "My order reference is MF-40192. When will it turn up?" (not run)
       at most 4 sentences
       rubric 0.00 — not run (--dry)
 1 prompt — 0 failed
Read enforceability 57 honestly rather than chasing it. Nine candidate rule lines, four of which lower into IR; the other five are the switch arms, which the compiler resolves away before the model sees them. The prose version in §3 scored 90 on this facet and could not tell you it had forgotten a state. When the score and the proof disagree, the proof is the one that ships.

§5What the typed branch actually buys

Two things, and they only show up after the file is written. The first is that there are five prompts here, not one, and each is smaller than the document you were going to paste into a console.

tg build, one artifact per state - output
$ npx typeglish build tracking.tg --out-dir dist
 built dist/tracking.txt ← tracking.tg (ef8ec74c7165, full)

$ for s in preparing transit delivered delayed missing; do \
    npx typeglish build tracking.tg --vars "{\"order_state\":\"$s\"}" --out-dir dist/$s; done
 built dist/preparing/tracking.txt ← tracking.tg (975fc6bd55d6, full)
 built dist/transit/tracking.txt ← tracking.tg (0b7826cb1802, full)
 built dist/delivered/tracking.txt ← tracking.tg (7c0a599ebb31, full)
 built dist/delayed/tracking.txt ← tracking.tg (76278b1dd6b7, full)
 built dist/missing/tracking.txt ← tracking.tg (809ee9d8a271, full)

$ wc -c dist/tracking.txt dist/*/tracking.txt
 879 dist/tracking.txt
 442 dist/delayed/tracking.txt
 457 dist/delivered/tracking.txt
 422 dist/missing/tracking.txt
 421 dist/preparing/tracking.txt
 426 dist/transit/tracking.txt

$ cat dist/delivered/tracking.txt
# Role
You are the order tracking assistant for Marlow & Fen, an online homeware retailer.

# Constraints
- You MUST ask the customer for an order reference before you look anything up.
- You MUST call lookup_order before you describe where an order is.
- You MUST NOT give the customer a delivery date.
- You MUST keep every reply to at most 4 sentences.

You MUST ask the customer to check with a neighbour and any safe place, then offer to open a claim.
Five distinct hashes, roughly 420 to 460 bytes each against 879 unbound. The delivered artifact contains exactly one branch instruction and no trace of the other four, so a customer whose parcel is in a van is never one instruction away from being told to ask a neighbour.

The second thing is the one that pays off in a year. A tracking domain grows: someone adds returned when the returns lane merges in, and every switch that was not updated fails loudly rather than quietly picking the nearest arm.

tg check after a sixth state joins the domain - output✗ 1 error
$ npx typeglish check grown.tg
grown.tg:31:1  error  structure/non-exhaustive-switch  @{order_state} can be returned, but no arm handles it — add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback).

 1 file — 1 error, 0 warning, 0 info
Deleting the delivered arm from today's file produces the identical error about delivered. Completeness here is not a review habit somebody has to remember; it is a property of the file, and the person who breaks it finds out in CI. Whether that is worth giving up a catch-all arm for is its own bake-off.
The brief was never wrong. It was unreadable by anything that could have told you.

The last thing to wire is the tool, and the bundle is where you check it rather than in the prompt text, since a tool schema travels beside the artifact and not inside it. --bundle writes the $TOOL and the $SERVICE out as JSON with the base URL merged into the request and the order_state domain recorded as enum(preparing|transit|delivered|delayed|missing). Read it once before you ship, because most of what can go wrong in a tool block is a warning at worst.

§6Common questions

How do I write a system prompt for a where is my order agent?
Separate the three things a tracking answer is made of. The policy goes in rules, as MUST and MUST NOT with bounds the checker can read. The live facts come from a tool, so the prompt says the agent MUST call it before describing a location rather than describing one itself. The branch over order state comes from the flow as a typed input, so a $SWITCH ON proves every state has an arm. The finished spec here is 0 error, 0 warning, 0 info at B (89/100) with 4/4 rules exercised by its own test suite, and it builds to a different artifact for each of the five states.
Why does my tracking agent promise delivery dates it should not?
Usually because the prompt says both things and nothing ever told you. Written as a brief, Always give the customer a delivery date and Never promise a delivery date, because the carrier owns the schedule check at 0 error, 0 warning, 4 info with consistency 100. Rewrite the same two lines as directives and they are 2 blocking logic/contradiction errors reading give the customer a delivery date is both required and forbidden. The conflict did not appear when the rules were tightened, it was always there; the prover simply cannot reach a claim buried in a because clause or a bare prose sentence.
Should the order status be a prompt variable or something the model works out?
A variable, if the flow already knows it, because that is the only version the compiler can prove is complete. Three prose conditionals covering preparing, moving and late check at 0 error, 0 warning, 0 info at B (84/100) with enforceability 90, and they silently omit the delivered-but-not-received case, which is the hardest call a tracking agent has to make. The same five states as $REQUIRE variable order_state: one of preparing, transit, delivered, delayed, missing with a $SWITCH ON cannot omit one: deleting the delivered arm is a blocking structure/non-exhaustive-switch error, and so is adding a sixth state to the domain later.
Does TypeGlish catch a conflict between two SLA windows?
Only if you write the window in a unit the time layer reads. You MUST chase the carrier within 24 hours beside You MUST chase the carrier within 2 days is 2 blocking logic/time-strength errors saying one bound per slot. Change days to working days and the identical pair is 0 error, 0 warning, 0 info. Working days is the phrase every contact centre actually writes in its SLA, and it is invisible to the prover, so convert the SLA to plain hours or days in the prompt and keep the working-days wording for the customer-facing sentence.
Field note

The state that decides whether this agent is any good is delivered, and it is the one a brief never mentions. Every other arm is a fact the carrier already gave you, restated politely. Delivered means the system says the parcel arrived and the person typing says it did not, and the whole job in that turn is to run a short, unembarrassing checklist and then get out of the way with a claim open. It is also the arm most likely to be missing from a prose conditional chain, because when you are writing the prompt you are thinking about tracking and not about disagreement. That is the argument for a typed domain in one sentence: the states you remember to handle are the ones you were already thinking about, and the compiler is not thinking about anything, which is exactly why it notices the gap.

∿ washed up Sep 7, 2026 ∿