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.
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.
<$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.
$ 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
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.
$ 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.
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.
<$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.
$ 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
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.
$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.
<$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.
$ 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
§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.
<$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
$ 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
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.
$ 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)
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.
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)
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.
$ 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
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: booleanand one line each for the others, give each reason its own$IFbranch 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, tariffis a blockingstructure/bad-input-typereading a variable's domain is ONE value, a primitive or a one of a, b set, never a list, andlist of stringsis refused on the same code. The English type grammar does haveany ofandlist of, but they are for$TOOLparameters: a tool can receive a set of reasons and record them, and the same words in a$REQUIRE variablerow 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 ONover 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 contactbesideMUST keep every response to at most 3 sentencesis aprompt/cap-tensionwarning, 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 ONa 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 blockingstructure/non-exhaustive-switchthat namesbilling_metering,billing_tariff,metering_tariffandall_threeby 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.
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.