← Tidelines/Best practices

Bake-off: the number in the prompt vs. the number at runtime

Every number in an agent prompt is a candidate for lifting out into a variable, and the argument for lifting it is always good. This is what it costs, measured.

by TypeGlish team9 min read#best-practices
The value you lift is the proof you lose.

TL;DR A policy number written as a literal is a bound the prover holds, and a number lifted into a $REQUIRE variable is a hole: the same drift is 2 blocking logic/time-strength errors with within 5 days in the file, and 0 error, 0 warning, 0 info with within @{refund_sla} days, clean under --strict, unmoved by --vars, and the same artifact hash either way. Keep the numbers your rules reason about as literals, and let a typed input pick between literals.

The pitch for lifting a number out of the prompt is the same pitch as every other configuration change, and it is a good one: ops can change the refund window without a deploy, one value serves four channels, and nobody has to grep for the number when it moves. Nobody ever costs the other side. Two files, the same four rules, the same support policy, and one number written two ways.

§1One SLA, two spellings

A chat desk at a furniture retailer. Four things have to be true: refunds settle in five days and the agent says so, a case is opened through the refund tool before anybody confirms anything, and replies stay at three sentences. Route A writes the SLA where a reader can see it.

literal.tg - the number in the file✓ A (98/100)
<$CONFIG>
  $IMPORT tool issue_refund
</$CONFIG>

# Role
@@ role: chat desk for a furniture retailer, refund timings rail-guarded
You are a chat support agent for Wickford Home, a furniture retailer.

# Constraints
@@ refund_sla: finance settles a card refund in five days and that is what we quote
- You MUST issue a refund within 5 days.
@@ refund_tool: a refund nobody recorded is a refund nobody makes
- You MUST call @[issue_refund] before you confirm a refund.
@@ brevity: a chat reply is read on a phone between other tasks
- You MUST keep every reply to at most 3 sentences.
Four lines, four reasons, one tool. The five is hardcoded, which is the thing everybody in review wants to fix.

Route B fixes it. The SLA becomes a declared input, the rule points at it, and finance can move the number in a config file instead of in the prompt.

variable.tg - the number at runtime✓ A (96/100)
<$CONFIG>
  $IMPORT tool issue_refund
  $REQUIRE variable refund_sla: integer
</$CONFIG>

# Role
@@ role: chat desk for a furniture retailer, refund timings rail-guarded
You are a chat support agent for Wickford Home, a furniture retailer.

# Constraints
@@ refund_sla: finance settles a card refund in five days and that is what we quote
- You MUST issue a refund within @{refund_sla} days.
@@ refund_tool: a refund nobody recorded is a refund nobody makes
- You MUST call @[issue_refund] before you confirm a refund.
@@ brevity: a chat reply is read on a phone between other tasks
- You MUST keep every reply to at most 3 sentences.
One line changed. Everything anybody would review in a diff is identical, and this is the version that gets approved.

Both check. The score already disagrees with the review, quietly, by two points.

tg check and tg score on both - output
$ npx typeglish check literal.tg variable.tg
 2 files — 0 error, 0 warning, 0 info

$ npx typeglish score literal.tg
literal.tg — A (98/100)  proven errors: none  tiers: base+z3
  planes  runtime 97 (what the model reads) · hygiene 100 (source only)
  facets  enforceability 90 x.21 · hardness 100 x.12 · directness 100 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 100 x.12 (hygiene) · style 100 x.08 · security 100 x.08

$ npx typeglish score variable.tg
variable.tg — A (96/100)  proven errors: none  tiers: base+z3
  planes  runtime 94 (what the model reads) · hygiene 100 (source only)
  facets  enforceability 80 x.21 · hardness 100 x.12 · directness 100 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 100 x.12 (hygiene) · style 100 x.08 · security 100 x.08
enforceability 90 against enforceability 80, on files that differ by eleven characters. The facet measures whether a rule carries a bound something can read, and @{refund_sla} is not one. Two points is not an argument. It is a receipt for what happens next.

§2The edit that arrives in December

Nothing above is the bake-off. The bake-off is the fourth edit, three months later, by somebody who has not read the file. Peak season: the courier backlog pushes settlement out, and the desk needs to say ten days while the backlog lasts. One rule goes in, above the brevity line, in both files.

peak-variable.tg - the same edit, on route B✓ 0 error
<$CONFIG>
  $IMPORT tool issue_refund
  $REQUIRE variable refund_sla: integer
</$CONFIG>

# Role
@@ role: chat desk for a furniture retailer, refund timings rail-guarded
You are a chat support agent for Wickford Home, a furniture retailer.

# Constraints
@@ refund_sla: finance settles a card refund in five days and that is what we quote
- You MUST issue a refund within @{refund_sla} days.
@@ refund_tool: a refund nobody recorded is a refund nobody makes
- You MUST call @[issue_refund] before you confirm a refund.
@@ peak_sla: in December the courier backlog pushes settlement out and we say so
- You MUST issue a refund within 10 days.
@@ brevity: a chat reply is read on a phone between other tasks
- You MUST keep every reply to at most 3 sentences.
Two rules now govern how long a refund takes. One of them names a number and one of them names an input, and nothing in the file says which wins.
tg check, the same edit on both routes - output✗ 2 error
$ npx typeglish check peak-literal.tg peak-variable.tg
peak-literal.tg:11:1  error  logic/time-strength  Conflicts with line 9. One bound per slot — "within 5 days" already entails "within 10 days"; the looser rule on "you issue a · refund" is dead weight. Keep one.
peak-literal.tg:15:1  error  logic/time-strength  Conflicts with line 7. One bound per slot — "within 5 days" already entails "within 10 days"; the looser rule on "you issue a · refund" is dead weight. Keep one.

 2 files — 2 error, 0 warning, 0 info

$ npx typeglish check peak-variable.tg --strict
 1 file — 0 error, 0 warning, 0 info

$ npx typeglish build peak-literal.tg --out-dir dist
typeglish build: peak-literal.tg refused — nothing written
Two files went in with the identical defect and one of them has a finding. Route A cannot be built at all: the prover reads both durations, proves the tighter one already entails the looser one, and refuses. Route B is clean, and --strict has nothing to escalate, because there is no finding to escalate.

The grade goes the wrong way too. peak-variable.tg scores A (97/100) at enforceability 85, a point above the file it was edited from, because the new rule is the only one in it carrying a concrete duration. The edit that broke the policy improved the number, which is the same trick a rule hidden in a tool description plays in the bake-off on where a tool rule goes.

Route A cannot ship the mistake. Route B cannot see it.

§3What --vars does not buy back

The obvious answer is to hand the checker the values. There is a flag for that, and it does not do this.

tg check --vars and tg build --vars - output
$ npx typeglish check peak-variable.tg --vars '{"refund_sla":5}'
 1 file — 0 error, 0 warning, 0 info

$ npx typeglish build peak-variable.tg --out-dir t1
 built t1/peak-variable.txt ← peak-variable.tg (e3c769d0f2f0, full)

$ npx typeglish build peak-variable.tg --vars '{"refund_sla":5}' --out-dir t2
 built t2/peak-variable.txt ← peak-variable.tg (e3c769d0f2f0, full)

$ cat t1/peak-variable.txt
# Role
You are a chat support agent for Wickford Home, a furniture retailer.

# Constraints
- You MUST issue a refund within {refund_sla} days.
- You MUST call issue_refund before you confirm a refund.
- You MUST issue a refund within 10 days.
- You MUST keep every reply to at most 3 sentences.
Same hash, e3c769d0f2f0, with the flag and without it. A @{name} in prose is not a compile-time substitution: it lowers to a {name} runtime placeholder and the host fills it in the moment the conversation starts. The value never exists while anything is checking.

That is the distinction worth keeping. The same @{refund_sla} is a compile-time read inside a $IF condition, a $SWITCH, a $DEFINE or a <tag when=…> guard, where the compiler evaluates it and the losing text never reaches the model. In prose it is a hole in the document, and the two look identical in a diff. Your system prompt has a compile time, and this is the line it runs along.

So the artifact ships a prohibition-free contradiction: within {refund_sla} days two lines above within 10 days, both unconditional, both addressed to the model, and whichever one the model picks it will pick freshly each conversation. That is the flakiness half the industry calls model variance.

§4Route C: a typed input picking between literals

The actual requirement behind route B was never make this number invisible. It was let the number differ. Those are separable, and the separation is a typed domain: the input decides which arm applies, and every arm still carries a number the prover can read.

arms.tg - the input picks, the literals stay✓ 0 error
<$CONFIG>
  $IMPORT tool issue_refund
  $REQUIRE variable region: one of uk, eu
</$CONFIG>

# Role
@@ role: chat desk for a furniture retailer, refund timings rail-guarded
You are a chat support agent for Wickford Home, a furniture retailer.

# Constraints
@@ refund_tool: a refund nobody recorded is a refund nobody makes
- You MUST call @[issue_refund] before you confirm a refund.
@@ brevity: a chat reply is read on a phone between other tasks
- You MUST keep every reply to at most 3 sentences.
@@ refund_sla: settlement is five days on a UK card and fourteen on a SEPA transfer
$SWITCH ON @{region}
  - uk:: You MUST issue a refund within 5 days.
  - eu:: You MUST issue a refund within 14 days.
Ops still gets a switch to flip, and it is a narrower one: two approved numbers rather than any integer anybody types into a config file. The domain is closed, so a third region added later fails loudly at this block instead of shipping an empty arm.

Route C has its own blind spot, and it is worth being blunt about. Add the standing ten-day line back, above the switch this time, and the file is still clean, because the arms are mutually exclusive and the prover will not cross a guard to look for a conflict.

tg check, then resolve once per member - output✗ 4 error
$ npx typeglish check arms.tg arms-drift.tg
 2 files — 0 error, 0 warning, 0 info

$ npx typeglish resolve arms-drift.tg --vars '{"region":"uk"}' > uk.tg
$ npx typeglish resolve arms-drift.tg --vars '{"region":"eu"}' > eu.tg
$ npx typeglish check uk.tg eu.tg
uk.tg:3:3  warn   structure/unused-import  Required variable "region" is never used.
uk.tg:3:21  warn   clarity/unused-variable  $REQUIRE variable "region" is never used — no @{region} reference fills it. Remove it, or reference it in the prompt.
uk.tg:16:1  error  logic/time-strength  Conflicts with line 11. One bound per slot — "within 5 days" already entails "within 10 days"; the looser rule on "you issue a · refund" is dead weight. Keep one.
uk.tg:17:1  error  logic/time-strength  Conflicts with line 10. One bound per slot — "within 5 days" already entails "within 10 days"; the looser rule on "you issue a · refund" is dead weight. Keep one.
eu.tg:3:3  warn   structure/unused-import  Required variable "region" is never used.
eu.tg:3:21  warn   clarity/unused-variable  $REQUIRE variable "region" is never used — no @{region} reference fills it. Remove it, or reference it in the prompt.
eu.tg:16:1  error  logic/time-strength  Conflicts with line 11. One bound per slot — "within 10 days" already entails "within 14 days"; the looser rule on "you issue a · refund" is dead weight. Keep one.
eu.tg:17:1  error  logic/time-strength  Conflicts with line 10. One bound per slot — "within 10 days" already entails "within 14 days"; the looser rule on "you issue a · refund" is dead weight. Keep one.

 2 files — 4 error, 4 warning, 0 info
typeglish resolve applies the conditionals and prints the resolved source, one file per member. Check those and both arms turn out to be broken, in different directions: the UK arm because five days already entails ten, the EU arm because ten already entails fourteen. The two warnings per file are expected noise, since the resolved source no longer references @{region}. Gate on the error count, not the finding count.

Each resolved file scores F (72/100) with consistency 0 and the grade capped at F. The file they were both resolved from reads A (93/100), 0 error, nothing to see. And on the clean version the builds come out per-region, which is the point of the whole arrangement: 093384f754f8 for uk, f4b894049369 for eu, and 24308bcd112f for the template build that ships both arms as conditional prose. Add a third region to the domain and arms.tg stops compiling until somebody writes its arm: structure/non-exhaustive-switch, @{region} can be us, but no arm handles it.

§5The verdict

Route C wins, and route B is not wrong so much as mis-sold. The useful way to decide is not literal or variable but does any rule in this file reason about this value.

  • Keep it a literal when a rule compares it. Durations, caps, counts, thresholds, sentence and character budgets: every one of these is a slot, and the slot axiom is the only reason your prompt catches its own second opinion. A number you lift out is a slot the prover stops watching, permanently, on every command.
  • Lift it when nothing in the file compares it. A customer name, an order id, an account balance, a store address, the agent's own display name. These are facts the prompt reports rather than reasons about, and there was never a proof to lose. Ops wants the hours changed by Friday is the same seam from the other side.
  • Use a typed domain when the value varies by a known thing. Region, tier, channel, business hours state. Two approved literals behind a closed domain beats one open integer, and it survives somebody adding a third region, because the switch fails when the domain grows.
  • Resolve before you gate. Whatever route you take, typeglish resolve --vars into a file and check that file, once per member of every domain your prompt branches on. A conditional prompt checked unresolved is a prompt where every guard is also a blindfold.

One last thing about the two-point score gap in §1. It is the only signal you get before the defect exists, and it is available on day one, in review, on a file where nothing is wrong yet. enforceability 90 versus enforceability 80 is the compiler telling you which of two approved files it will be able to defend in December.

§6Common questions

Should policy values live in my system prompt or come in as variables?
Keep the ones your rules reason about. A refund SLA written as within 5 days scores A (98/100) at enforceability 90, and the same file with $REQUIRE variable refund_sla and within @{refund_sla} days scores A (96/100) at enforceability 80. Then add a second rule about the same slot: the literal file is 2 blocking logic/time-strength errors and the build refuses, and the variable file is 0 error, 0 warning, 0 info and ships. Lift the values that change weekly and that nothing in the file compares, and keep the bounds, the caps and the clocks as literals.
Does typeglish check --vars validate my prompt with the input values?
No. On a file whose rules read @{refund_sla}, check --vars '{"refund_sla":5}' reports 0 error, 0 warning, 0 info, exactly like the run without it, and on a file with a $SWITCH it does not resolve the arms either. build --vars does not substitute a prose pointer either: the artifact ships {refund_sla} as a runtime placeholder and the hash is e3c769d0f2f0 with and without the flag. To check a prompt the way it will actually reach the model, run typeglish resolve <file> --vars '<json>' into a file and check that.
Why does my prompt score drop when I replace a number with a variable?
Because enforceability measures whether a rule carries a concrete bound, and @{refund_sla} is not one. The same four rules score enforceability 90 with within 5 days and enforceability 80 with within @{refund_sla} days, which moves the file from A (98/100) to A (96/100). The scorer is telling you the truth before the prover has to: a bound the compiler cannot read is a bound it cannot defend.
How do I check every branch of a conditional system prompt?
Resolve it once per member of the domain and check the resolved sources. A file with a standing within 10 days rule plus a $SWITCH ON @{region} whose arms say within 5 days and within 14 days is 0 error on its own, because the arms are mutually exclusive and the prover will not cross them. Resolve it for uk and for eu and check both, and the same file is 4 error, 4 warning across two files, F (72/100) with consistency 0 on each. Expect the two resolve warnings: the resolved source no longer references @{region}, so the declaration reads as unused. Gate on the error count.
Field note

The reason this one is worth an argument in review is that lifting a number out of a prompt is indistinguishable, in a diff, from every other good refactor. It removes a magic value, it names a thing, it moves configuration out of content, and it is the change a careful engineer makes on instinct. In a program that would all be true. In a prompt, the number was not a magic value: it was the only part of the sentence a prover could hold onto, and the refactor traded a proof for a config row. There is no diagnostic for it, because nothing is wrong yet, and the file that used to catch its own second opinion about the refund window will now quietly ship both. If you want one habit out of this post, make it the score line: read enforceability on the before and after of any edit that replaces a number with a pointer, and if it dropped, you just moved a slot out of the compiler's reach. Try again is not a number is the same lesson approached from a prompt that never had the number in the first place.

∿ washed up Sep 12, 2026 ∿