← Tidelines/Deep dives

Your router assumes one thing is wrong

Every routed agent prompt starts from a reason code, and a reason code is one value. The customer who raises three things in one message is not an edge case, and there is no line in the file where you decided to ignore them.

by TypeGlish team10 min read#deep-dives
One message, three problems.

TL;DR A routing input holds exactly one value by construction, so $REQUIRE variable intents: any of billing, metering, tariff is a blocking structure/bad-input-type reading a variable's domain is ONE value, and the single-intent router built the legal way checks at 0 error, 0 warning and scores A (99/100) while quietly asserting that no contact ever carries two reasons. Model each reason as its own boolean input with its own $IF branch and the same policy is A (91/100), a three-reason contact and a one-reason contact compile to different artifacts, and the length cap you never questioned turns into a prompt/cap-tension warning.

Open your routing prompt and find the line that decides what the contact is about. It is a reason code, an intent label, an IVR selection or a classifier output, and whatever it is called, it is one value. Now read a week of real transcripts. My bill looks wrong and my meter has not sent a reading since March. I want to cancel, but first, why was I charged twice? Is my order still coming, and can I change the address? The single value is not a simplification you made for the router. It is a claim about your customers, it is in the prompt, and it is false several times an hour.

§1The prompt that cannot see the second reason

Saltmarsh Energy is a household energy retailer. This is its contact-handling prompt, written the way every guide tells you to write one: a typed input with a closed domain, a $SWITCH ON over it with one arm per member, and two rules that hold across all of them.

route.tg - one contact, one reason, one arm✓ A (99/100)
<$CONFIG>
  $REQUIRE variable intent: one of billing, metering, tariff
</$CONFIG>

# Role
@@ role: the doer every arm below is addressed to
You are a contact-handling agent for Saltmarsh Energy, a household energy retailer.

@@ router: one contact, one reason, one arm - the assumption this whole file is built on
$SWITCH ON @{intent}
  - billing:: Quote a balance and itemise a bill.
  - metering:: Book a meter engineer.
  - tariff:: Quote a tariff and offer a comparison.

# Constraints
@@ brevity: three sentences keeps a chat reply scannable
- MUST keep every response to at most 3 sentences.
@@ closure: a contact that stays open is a contact somebody has to triage twice
- MUST close a contact once you have answered.

There is nothing wrong with it. Every arm is covered, the domain is closed, the rules are bounded and annotated, and the toolchain agrees.

tg check, --strict and score - route.tg✓ A (99/100)
$ typeglish check route.tg
route.tg:11:3  info   prompt/unregistered-doer  A bare generic doer never enters the world
  model - instruction to the agent, or background about users? ...

 1 file - 0 error, 0 warning, 1 info

$ typeglish check route.tg --strict
 1 file - 0 error, 0 warning, 1 info

$ typeglish score route.tg
route.tg - A (99/100)  proven errors: none  tiers: base+z3
  planes  runtime 99 (what the model reads) · hygiene 100 (source only)
  facets  enforceability 100 x.21 · hardness 100 x.12 · directness 100 x.08
          consistency 100 x.17 · structure 100 x.12 · annotation 100 x.12
          style 89 x.08 · security 100 x.08
  L11  prompt/unregistered-doer  -0.25
One info, and it is about a noun phrase in the billing arm. Nothing in the file, and nothing the checker can reach, is about the fact that the router has room for one reason and the inbox does not.

Then build it, because the build is where the assumption becomes visible. Bind intent to billing and the compiler resolves the chain, keeps the winning arm and drops the other two.

tg build --vars - what the model actually receives
$ typeglish build route.tg --vars '{"intent":"billing"}'
 built .typeglish/dist/route.txt ← route.tg (242cce0f9ac3, full)

  # Role
  You are a contact-handling agent for Saltmarsh Energy, a household energy retailer.

  Quote a balance and itemise a bill.
  # Constraints
  - MUST keep every response to at most 3 sentences.
  - MUST close a contact once you have answered.
The metering instruction is not deprioritised in this artifact. It is not in it. If the same message also said my meter has not sent a reading since March, the document in front of the model contains no sentence about meters and one rule telling it to close the contact once it has answered.

This is why the failure looks like the model being careless. It is not. It is doing exactly what its input says, and its input is a prompt where the second half of the customer's message has no corresponding half.

A reason code is not a description of the contact. It is a decision about how much of the contact you are willing to see.

§2The type will not let you say it

The obvious fix is to make the input a set. English has the word for it, and the TypeGlish type grammar has any of right beside one of. Try it.

route-any.tg - the honest type, refused✗ 2 errors
<$CONFIG>
  $REQUIRE variable intents: any of billing, metering, tariff
</$CONFIG>

# Role
You are a contact-handling agent for Saltmarsh Energy, a household energy retailer.

$SWITCH ON @{intents}
  - billing:: Quote a balance and itemise a bill.
  - metering:: Book a meter engineer.
  - tariff:: Quote a tariff and offer a comparison.
tg check route-any.tg✗ exit 1
$ typeglish check route-any.tg
route-any.tg:2:3  error  structure/bad-input-type  Input "intents": A variable's domain is
  ONE value - a primitive or a "one of a, b" set, never a list ("any of billing,
  metering, tariff").
route-any.tg:8:1  error  structure/opaque-switch  @{intents} has an open domain
  (untyped) - a $SWITCH on it needs a "- otherwise::" row (members can't cover an
  open domain).

 1 file - 2 error, 0 warning, 1 info    # exit 1
Two errors, and the second is the consequence of the first: with no valid domain the input is open, so nothing can prove the switch covers it. list of strings is refused on the same code. A prompt variable is a scalar, full stop.

Now the asymmetry, which is the part worth keeping. The same two words are perfectly legal one construct over. A $TOOL parameter can be a set, because a tool call is a message to a machine that can carry a list.

log-contact.tg - "any of" is legal in a tool parameter✓ builds
$TOOL log_contact
  - description:: Records a contact and the reasons a customer raised.
  - input::
    - reasons:: any of billing, metering, tariff: the reasons raised.
  - request:: POST https://api.example.com/contacts

# Role
You are a contact-handling agent for Saltmarsh Energy.

# Constraints
- MUST call @[log_contact] before you close a contact.

So the language will happily let your tool know that a contact had three reasons, and will not let your prompt know it. That is not an oversight, it is the shape of the thing: a prompt variable exists to be substituted into one document, and a document either contains the metering instruction or it does not. The set has to become branches before it can enter the prompt at all, and the type system is refusing to let you skip that step.

The workaround people reach for next is a bigger enum, with a member for each combination. It is legal, and the compiler will tell you exactly how much it costs.

route-combo.tg - seven members, three arms✗ 1 error
<$CONFIG>
  $REQUIRE variable intent: one of billing, metering, tariff, billing_metering, billing_tariff, metering_tariff, all_three
</$CONFIG>

# Role
You are a contact-handling agent for Saltmarsh Energy.

$SWITCH ON @{intent}
  - billing:: Quote a balance and itemise a bill.
  - metering:: Book a meter engineer.
  - tariff:: Quote a tariff and offer a comparison.
tg check route-combo.tg
$ typeglish check route-combo.tg
route-combo.tg:8:1  error  structure/non-exhaustive-switch  @{intent} can be
  billing_metering, billing_tariff, metering_tariff, all_three, but no arm handles
  them - add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback).

 1 file - 1 error, 0 warning, 1 info    # exit 1
Three reasons is seven non-empty combinations; four reasons is fifteen. Because a switch over a closed domain is total by construction, the error names every uncovered member, and the length of that list is the argument against the approach. The same property is what makes an added domain member fail loudly, which a catch-all arm vs. one arm per member works through in detail.

§3A reason is a flag, not a category

The reframe is small and it changes the file completely. Stop asking what is this contact about, which has one answer, and start asking which of these was raised, which has one answer per reason. Each reason gets a boolean the host sets independently, and each boolean gets a branch with both arms written out.

route-shipped.tg - one flag per reason, one branch per flag✓ A (91/100)
<$CONFIG>
  $CONFIG modality chat
  $REQUIRE variable billing_raised: boolean
  $REQUIRE variable metering_raised: boolean
  $REQUIRE variable tariff_raised: boolean
</$CONFIG>

# Role
@@ role: the doer every branch below is addressed to
You are a contact-handling agent for Saltmarsh Energy, a household energy retailer.

# Constraints
@@ billing_branch: one reason, one branch - a contact can carry this one and the next two
$IF @{billing_raised}:
  Quote a balance and itemise a bill.
$ELSE:
  NEVER quote a balance.
@@ metering_branch: a meter fault is a field job and it is the only branch that books anybody
$IF @{metering_raised}:
  Book a meter engineer.
$ELSE:
  NEVER book a meter engineer.
@@ tariff_branch: a tariff question is a sales conversation and it goes last
$IF @{tariff_raised}:
  Quote a tariff and offer a comparison.
$ELSE:
  NEVER quote a tariff.
@@ brevity: the unit is the reason, not the contact - a three-reason contact is nine sentences
- MUST use at most 3 sentences for each raised reason.
@@ closure: a contact closes when every raised reason has an answer
- MUST answer every raised reason before you close a contact.

$TEST two_reasons
  - input:: My bill looks wrong and my meter has not sent a reading since March.
  - expect::
    - contains "engineer"
    - at most 6 sentences
tg check, score and test --dry - route-shipped.tg✓ A (91/100)
$ typeglish check route-shipped.tg
 1 file - 0 error, 0 warning, 0 info

$ typeglish check route-shipped.tg --strict
 1 file - 0 error, 0 warning, 0 info

$ typeglish score route-shipped.tg
route-shipped.tg - A (91/100)  proven errors: none  tiers: base+z3
  planes  runtime 97 (what the model reads) · hygiene 75 (source only)
  facets  enforceability 88 x.21 · hardness 100 x.12 · directness 99 x.08
          consistency 100 x.17 · structure 100 x.12 · annotation 50 x.12
          style 100 x.08 · security 100 x.08
  lever   annotation 50/100 (up to +6 overall)

$ typeglish test route-shipped.tg --dry
 route-shipped.tg  coverage: 2/5 rules exercised
  · two_reasons - "My bill looks wrong and my meter has not sent a reading sinc" (not run)
       contains "engineer"
       at most 6 sentences
Two points below the router it replaces, and worth reading rather than defending: annotation 50 because the arm bodies inside a $IF chain take no @@ note of their own, and enforceability 88 because a branch is by construction less enforceable than an unconditional rule. That tax is the same one your policy remembers, your agent does not measures at length. Take it.

The $ELSE arms are the part that looks like busywork and is not. A single-intent router leaves the unraised reasons silent, which means nothing in the artifact says the agent should not do them. Once the reasons are independent flags, silence stops being safe: a contact about a meter that says nothing about tariffs is a contact where the agent must not start selling one. Writing the negative arm is how you say that, and the compiler makes you say it, because a $IF chain over an open predicate with no $ELSE is structure/non-exhaustive-switch.

§4Your length cap was sized for one reason

Here is the finding I did not go looking for. The first version of the shipped file kept the original brevity rule word for word, and the checker objected to it as soon as the closure rule became honest.

tg check - the rewrite, with the old cap still in it✗ B (89/100)
$ typeglish check route-shipped.tg      # "MUST keep every response to at most 3 sentences."
route-shipped.tg:31:1  warn   prompt/cap-tension  Cap tension - this mandate reads
  exhaustive ("every raised reason") while every response is capped at 3 sentences
  (line 22). Coherent as one default plus one exception - scope the cap ("except
  when covering every raised reason").

 1 file - 0 error, 1 warning, 0 info
route-shipped.tg - B (89/100)
Two rules that lived together happily in the single-intent file. The moment one of them says every raised reason and the other says every response, they are a bounded output beside an exhaustive mandate, and the checker names it as class 2 of the contradiction hunt.

It is right, and the fix is not to scope the cap but to notice that the cap had the wrong unit all along. Three sentences was never a fact about a reply, it was a fact about answering one thing. Change the unit and both rules are true at once.

the one-word edit that clears it
  before  - MUST keep every response to at most 3 sentences.   warn  B (89/100)
  after   - MUST use at most 3 sentences for each raised reason. clean A (91/100)
Same number, different unit, and the second one is what the policy always meant. A three-reason contact is now allowed nine sentences and nobody has to decide that in the moment. Which rule wins when two rules can both apply is the general version of this, and your prompt is a default and its exceptions is the map of it.

Go looking and you will find more of these. Any rule counted per reply, per contact or per conversation was written by somebody picturing one topic. A greeting, a verification step, a satisfaction question, a wrap-up summary: each one has a unit, and multi-intent is the thing that makes the unit matter.

§5Two contacts, two documents

The payoff is the same one every compile-time branch buys, and it is easier to see with flags than with a switch, because now the number of live branches varies with the contact.

tg build --vars - three reasons, then one
$ typeglish build route-shipped.tg --vars '{"billing_raised":true,
    "metering_raised":true,"tariff_raised":true}'
 built .typeglish/dist/route-shipped.txt ← route-shipped.tg (1d3a888d6607, full)

  # Constraints
  Quote a balance and itemise a bill. Book a meter engineer. Quote a tariff and
  offer a comparison.
  - MUST use at most 3 sentences for each raised reason.
  - MUST answer every raised reason before you close a contact.

$ typeglish build route-shipped.tg --vars '{"billing_raised":false,
    "metering_raised":true,"tariff_raised":false}'
 built .typeglish/dist/route-shipped.txt ← route-shipped.tg (9186985bbe7b, full)

  # Constraints
  NEVER quote a balance. Book a meter engineer. NEVER quote a tariff.
  - MUST use at most 3 sentences for each raised reason.
  - MUST answer every raised reason before you close a contact.

$ typeglish build route-shipped.tg          # unbound template
 built .typeglish/dist/route-shipped.txt ← route-shipped.tg (147147f04dd2, full)
  497 bytes - both arms of all three branches, as conditional prose
Two contacts, two hashes, two genuinely different documents. The three-reason prompt contains three instructions and no prohibitions; the one-reason prompt contains one instruction and two prohibitions, so the agent is told in writing not to volunteer a tariff. One honest caveat on the layout: arm bodies reflow, so the branch instructions arrive as one paragraph under the heading.

What has actually changed is the question the prompt is built to answer. The switch version asks the model to act on a classification somebody upstream already collapsed. The flag version asks it to act on a description, and a description of a contact is a set. That is the same boundary your system prompt is an API contract draws around inputs and tools, applied to the one input everybody types as a scalar without thinking about it.

None of this makes the classifier's job easier, and it is worth being blunt about the part that is not a prompt problem. Something upstream still has to decide that my bill looks wrong and my meter has not sent a reading raised two flags rather than one, and if your platform only emits a single top intent, the flags will be wrong in a new way. But at least the prompt will be asking for the right thing, and a host that can only answer part of the question is a conversation you can now have with the people who own the classifier, rather than a shape the prompt file quietly agreed to.

§6Common questions

How do I handle a customer who asks about two things at once?
Stop modelling the reason for contact as a category and model it as a set of flags. Declare one boolean input per reason, $REQUIRE variable billing_raised: boolean and one line each for the others, give each reason its own $IF branch with an explicit $ELSE, and let the host set as many flags as the message earned. A contact raising three reasons then compiles to an artifact carrying three instructions (1d3a888d6607) and a contact raising one compiles to a different artifact carrying one instruction and two explicit prohibitions (9186985bbe7b). The model is never asked to notice that a second thing was raised, because the branch for it is either in the prompt or it is not.
Can a TypeGlish input hold more than one value?
No, and the compiler says so outright. $REQUIRE variable intents: any of billing, metering, tariff is a blocking structure/bad-input-type reading a variable's domain is ONE value, a primitive or a one of a, b set, never a list, and list of strings is refused on the same code. The English type grammar does have any of and list of, but they are for $TOOL parameters: a tool can receive a set of reasons and record them, and the same words in a $REQUIRE variable row will not compile. So a prompt variable is a scalar by construction, and any router built on one is asserting single intent whether or not its author meant to.
Why does my agent only answer part of a multi-part question?
Usually because the prompt only gave it one answer to give. A $SWITCH ON over a typed reason code resolves to exactly one arm at compile time, so the artifact the model reads for a billing-and-metering contact contains the billing instruction and no trace of the metering one: the second reason is not being deprioritised, it is absent. The other common cause is the length cap. Once you do branch per reason, MUST answer every raised reason before you close a contact beside MUST keep every response to at most 3 sentences is a prompt/cap-tension warning, because three sentences was sized for one reason and a three-reason contact needs nine.
Should I add a combined member like billing_and_metering to my intent enum?
Only if you enjoy writing arms. Three reasons have seven non-empty combinations and four reasons have fifteen, and because a $SWITCH ON a closed domain is proven total by construction, the compiler will make you write every one of them: a seven-member domain with three arms is a blocking structure/non-exhaustive-switch that names billing_metering, billing_tariff, metering_tariff and all_three by name. That error is doing you a favour twice over. It refuses to let the combinations ship unhandled, and the size of the list it prints is the argument for flags instead.
Field note

The cheapest way to size this problem is to stop guessing at it. Take two hundred consecutive transcripts, read only the customer's first message, and tally how many carry more than one thing you would route differently. Write your guess down before you start, because the interesting output of this exercise is the gap between the guess and the tally, and the pairs that show up most are the ones with a policy conflict inside them: billing plus cancellation, fault plus compensation, delivery plus address change. Then look at what happens to those contacts today. If a human handler is quietly answering both halves and marking the ticket with one disposition code, your reporting has been rounding the same way your prompt does, and neither of them has been telling you.

∿ washed up Sep 8, 2026 ∿