← Tidelines/Best practices

Ops wants the hours changed by Friday

Nine checks for the week somebody outside engineering needs to change what the agent says. The request is never really an edit to the file, and the command that tells you which kind of change it is takes four seconds.

by TypeGlish team10 min read#best-practices
A value, a case, or a rule.

TL;DR Every request from outside engineering is one of three things and only one of them is a prompt edit: a case belongs in the input bag as a typed domain member, a bound stays in the file because moving it out costs you the prover, and a rule has to come through review. The one-command test for which is to build the prompt twice with two values and compare the hash the tick prints, because a variable read by a $SWITCH is resolved at compile time and a variable pointed at from inside a sentence is a runtime hole that ships as {curly} whatever you passed.

The message arrives on a Tuesday and it is always reasonable. Support is extending the phone hours for the winter campaign, the change is live on the website already, and the agent is still telling people the desk shuts at five. Could you change it. It is one line. And the honest answer, the second time this happens, is that the line is not the problem: the problem is that you are the only person who can change it, and the reason for that is a decision nobody made on purpose.

What follows is nine checks for building the seam, run against a small broadband care prompt. The prompt is not broken. That is the interesting starting condition, because it means nothing in this list is a bug fix.

care.tg - the file as it stands✓ A (97/100)
# Role
You are a care agent for Larkspur Broadband.

# Constraints
@@ hours: the published window, never a guess
- You MUST quote the support hours as 9am to 5pm.
@@ credit: the goodwill ceiling finance signed off in March
- You MUST apply a goodwill credit of at most 20 pounds.
@@ callback: the standing promise on the website
- You MUST offer a callback within 4 hours.
@@ brevity: three sentences keeps a chat reply scannable
- You MUST keep every reply to at most 3 sentences.
Four rules, four annotations, four numbers, and every number is a fact somebody outside this file owns. Finance owns the credit. Marketing owns the callback promise. The workforce team owns the hours.

§1Sort the request before you open the file

Check 1: baseline it, and write the number down. One command, and it is the control group for everything after it. It also settles the most common opening move in this conversation, which is somebody assuming the prompt must be a mess because the agent is saying the wrong thing.

tg check + tg score care.tg - the control group
$ npx typeglish check care.tg
 1 file — 0 error, 0 warning, 0 info

$ npx typeglish score care.tg
care.tg — A (97/100)  proven errors: none  tiers: base+z3
  planes  runtime 100 (what the model reads) · hygiene 90 (source only)
  facets  enforceability 100 x.21 · hardness 100 x.12 · directness 100 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 80 x.12 (hygiene) · style 100 x.08 · security 100 x.08
enforceability 100 is the number to remember. It is the highest this prompt will score for the rest of the post, and every step towards making it editable is going to cost some of it.

Check 2: sort the request into one of three piles. This is the whole checklist, really, and the rest is mechanics. Read what was actually asked for and decide which of these it is.

  • A case. Which situation applies. The winter campaign is on, so use the extended hours. The prompt already knows both answers and somebody needs to select one. This can leave the file entirely.
  • A bound. The number in a rule. The goodwill ceiling is thirty pounds now. The prompt does not know the new answer, and the number is the rule rather than a selector for it. This one is harder than it looks.
  • A rule. A new policy. Do not offer callbacks on Sundays. There is no version of this that is not a prompt change, and it goes through review like any other change to a live spec.

Almost every request that arrives as "it is one line" is the first kind, and almost every request that gets handled as the first kind is actually the second. The difference is not a judgement call, which is the useful part.

Check 3: find out whether the value is compile-time or runtime, with one command. Declare the value as a $REQUIRE variable, build the prompt twice with two different values in the bag, and compare the hashes on the tick. If the hash moves, the compiler resolved the value and a person filling in a form can own it. If the hash does not move, you have not made the value editable, you have moved it to somebody else's runtime.

hours.tg - both values pulled out, the obvious way✓ A (90/100)
<$CONFIG>
  $REQUIRE variable season: one of offpeak, peak
  $REQUIRE variable credit_cap: integer
</$CONFIG>

# Role
You are a care agent for Larkspur Broadband.

# Constraints
@@ hours: one window per season, and the season comes from the caller
$SWITCH ON @{season}
  - offpeak:: You MUST quote the support hours as 9am to 5pm UTC.
  - peak:: You MUST quote the support hours as 8am to 8pm UTC.
@@ credit: the goodwill ceiling finance signed off in March
- You MUST apply a goodwill credit of at most @{credit_cap} pounds.
@@ callback: the standing promise on the website
- You MUST offer a callback within 4 hours.
@@ brevity: three sentences keeps a chat reply scannable
- You MUST keep every reply to at most 3 sentences.
Two $REQUIRE variable rows, written the same way, in the same block, one line apart. They are not the same thing and the file gives you no hint of that. Note the UTC on both windows: it is there because of check 8.
tg build --vars - three bags, two hashes
$ npx typeglish build hours.tg --vars '{"season":"offpeak","credit_cap":20}'
 built .typeglish/dist/hours.txt ← hours.tg (a6ab259db22c, full)
You MUST quote the support hours as 9am to 5pm UTC.
- You MUST apply a goodwill credit of at most {credit_cap} pounds.

$ npx typeglish build hours.tg --vars '{"season":"peak","credit_cap":20}'
 built .typeglish/dist/hours.txt ← hours.tg (e15e4cd0d609, full)
You MUST quote the support hours as 8am to 8pm UTC.
- You MUST apply a goodwill credit of at most {credit_cap} pounds.

$ npx typeglish build hours.tg --vars '{"season":"peak","credit_cap":50}'
 built .typeglish/dist/hours.txt ← hours.tg (e15e4cd0d609, full)
You MUST quote the support hours as 8am to 8pm UTC.
- You MUST apply a goodwill credit of at most {credit_cap} pounds.
The season moved the hash. The cap did not: 20 and 50 both build to e15e4cd0d609, and in all three artifacts the deployed line still reads at most {credit_cap} pounds. Same syntax, two seams. The switch is resolved by the compiler; the pointer inside the sentence is a hole the host fills after the build, so the number ops typed into your form never reached the document at all.

That is worth being blunt about, because it is the single most expensive misunderstanding in this area. A $REQUIRE variable read by a $SWITCH or a $IF is a compile-time selector and the losing arms never reach the model. The identical declaration, pointed at from inside a rule, is a runtime placeholder, and the value comes from whatever your host does at request time. Ops cannot own the second one by sending you a value. They can only own it if your runtime reads it from somewhere they can edit, which is a different piece of software and a different review.

§2Hand over values, not lines

Check 4: type every domain, and expect the check to get louder rather than quieter. A case with a closed domain is the good outcome here, because it is the only version of this seam where the future edit fails loudly. Add a third season to the domain and every switch that nobody updated refuses to compile.

tg check - a member added, an arm not added✗ 1 error
# season: one of offpeak, peak, holiday
$ npx typeglish check member.tg
member.tg:11:1  error  structure/non-exhaustive-switch  @{season} can be holiday, but no arm handles it — add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback).

 1 file — 1 error, 0 warning, 0 info
This is the finding you are buying. Somebody adds holiday to the form in December, and the build fails in every place in the program that has an opinion about the season, rather than one of them silently picking a default.

Check 5: validate the bag yourself, because the compiler only checks its keys. This is the check that surprised us, and it changes where the guard rail has to sit. A key that does not exist is refused outright. A value that is not in the declared domain builds at exit 0.

tg build --vars - a bad key and a bad value
$ npx typeglish build hours.tg --vars '{"season":"peak","credit_cap":20,"opening_hours":"8am"}'
typeglish build: hours.tg refused — nothing written
unknown --vars key: opening_hours — declared inputs: season, credit_cap

$ npx typeglish build hours.tg --vars '{"season":"summer","credit_cap":20}'
 built .typeglish/dist/hours.txt ← hours.tg (5f7bbe1c801b, full)
# Constraints
- You MUST apply a goodwill credit of at most {credit_cap} pounds.
- You MUST offer a callback within 4 hours.
- You MUST keep every reply to at most 3 sentences.

$ npx typeglish check hours.tg --vars '{"season":"summer","credit_cap":20}'
 1 file — 0 error, 0 warning, 0 info
# exit 0
Read the second artifact again. season is summer, no arm matched, and the opening-hours rule is not in the deployed prompt. Not wrong, not defaulted: gone. A fresh hash, a clean check, exit 0, and an agent with no instruction about when the desk is open. That is one typo in one form field.

Check 6: do not try to cover it with a fallback. The obvious reflex is an - otherwise:: arm, and the language will not let you have one, for a reason it states plainly.

tg check - the fallback the compiler refuses✗ 1 error
otherwise.tg:14:3  error  structure/unreachable-otherwise  Every member of @{season} has its own arm — "- otherwise::" can never run, and it would silently absorb members added to the domain later. Remove the row.
The refusal is correct and it is also the trade being made explicit. A total switch is what buys you check 4, and a fallback would trade that away to catch check 5. Both cannot be true at once, so the validation belongs at the boundary rather than in the prompt: whatever reads the ops form writes the bag, and that code checks the value against the domain before build ever runs. Six lines, and it is the only place in this pipeline that can hold the line.

§3The edits that are not values

Check 7: put every bound back in the file, and read the price of having moved it. Extracting the credit cap looked like the same kind of win as extracting the season. It is the opposite, because a bound is the only part of a rule the prover can reason about, and a pointer is not a number.

tg check - one appended line, against two versions of one prompt
# appended to both files: "- You MUST apply a goodwill credit of at least 50 pounds."

$ npx typeglish check care.tg # the cap is the literal 20 pounds
care.tg:8:1   error  logic/numeric  Conflicts with line 8. Numeric conflict — "at most 20 pounds" and "at least 50 pounds" can't both hold.
care.tg:13:1  error  logic/numeric  Conflicts with line 5. Numeric conflict — "at most 20 pounds" and "at least 50 pounds" can't both hold.
 1 file — 2 error, 0 warning, 0 info

$ npx typeglish check hours.tg # the cap is @{credit_cap}
 1 file — 0 error, 0 warning, 0 info
# exit 0
Identical appended line, opposite verdicts. Z3 cannot compare fifty pounds against a placeholder, so the version that made the cap editable is the version in which a contradictory cap ships. The same thing happens to the hours: two literal windows in one file is 2 blocking logic/time errors reading one schedule per slot per scope, and the same pair with one window inside a switch arm is clean.

Which gives the rule of thumb this post exists for. A case goes in the bag; a bound stays in the file. Ops does not want to own the ceiling anyway. Finance owns the ceiling, finance signs a number once a year, and that is a change to a spec with a reviewer on it. What ops wants to own is which of your already-approved answers is live this week.

Check 8: expect the extraction to surface a finding about the value itself. Pulling the hours into two arms is the first time this file has held two windows, and the checker has something to say about that which it did not say about one. Write the same two arms without a timezone and it arrives as info.

tg check hours.tg - the two arms, with no zone on either
# - offpeak:: You MUST quote the support hours as 9am to 5pm.
# - peak::    You MUST quote the support hours as 8am to 8pm.
$ npx typeglish check hours.tg
hours.tg:12:3  info   prompt/naive-time  This prompt states clock times but never fixes a timezone — the model will guess one at runtime. Declare the zone as a variable the caller can pass ("$REQUIRE variable timezone: string default to UTC") or qualify the times (9pm UTC).

 1 file — 0 error, 0 warning, 1 info
The single unqualified window in the original file drew nothing; two of them draw this. The fix in the message comes in both of the flavours this post is about, and choosing between them is check 2 again: qualify the times in the file, because a timezone is a bound, or declare the zone as another value the caller passes, which is a case only if you genuinely run more than one zone. Qualifying is why every window in the rewritten file above ends in UTC, and it pairs with why your agent gets the time zone wrong.

Check 9: a new rule comes through review, and the checker is what review is for. The third pile is the one nobody can automate away, and there is no need to be defensive about it: this is exactly the case where a compiler earns its place. A policy change that collides with a rule already in the file is a build failure rather than an argument. The one trap worth naming is that a qualifier can hide the collision, which is its own post, and it is why the review step is a person reading a diff and not just a green tick.

§4The gate, and what it does not cover

Here is the shipped file. One value in the bag, three bounds in the file, the timezone qualified, and two test cases pinning the two things a human would check by hand.

care.tg - shipped✓ A (93/100)
<$CONFIG>
  $REQUIRE variable season: one of offpeak, peak
</$CONFIG>

# Role
You are a care agent for Larkspur Broadband.

# Constraints
@@ hours: one window per season, and the season comes from the caller
$SWITCH ON @{season}
  - offpeak:: You MUST quote the support hours as 9am to 5pm UTC.
  - peak:: You MUST quote the support hours as 8am to 8pm UTC.
@@ credit: the ceiling finance signed off in March, and a ceiling is a rule
- You MUST apply a goodwill credit of at most 20 pounds.
@@ callback: the standing promise on the website
- You MUST offer a callback within 4 hours.
@@ brevity: three sentences keeps a chat reply scannable
- You MUST keep every reply to at most 3 sentences.

$TEST peak_hours
  - input:: What time do you close?
  - expect::
    - contains "8pm"
    - at most 3 sentences
$TEST credit_ceiling
  - input:: I want 60 pounds off for the outage.
  - expect::
    - matches /^(?!.*60 pounds)/
A (93/100) with enforceability 80, against A (97/100) and enforceability 100 for the original and A (90/100) and enforceability 70 for the version that extracted both values. Seventeen points of enforceability is the honest price of the seam, and it buys back the numeric prover on the cap: appending the conflicting credit line to this file is 2 blocking logic/numeric errors again.
tg build - two seasons, and the template
$ npx typeglish build care.tg --vars '{"season":"offpeak"}'
 built .typeglish/dist/care.txt ← care.tg (52b252433d97, full)

$ npx typeglish build care.tg --vars '{"season":"peak"}'
 built .typeglish/dist/care.txt ← care.tg (94f5a5006cac, full)
# Role
You are a care agent for Larkspur Broadband.

# Constraints
You MUST quote the support hours as 8am to 8pm UTC.
- You MUST apply a goodwill credit of at most 20 pounds.
- You MUST offer a callback within 4 hours.
- You MUST keep every reply to at most 3 sentences.

$ npx typeglish build care.tg
 built .typeglish/dist/care.txt ← care.tg (612a74481d4f, full)
Two bags, two artifacts, 272 bytes each, one opening-hours rule in each. The third build is the one to fail the deploy on: with season unbound the switch dissolves back into prose and the model receives both windows in one line, the way an unbound template build always does.

And then the gate, which is one command longer than you would expect. check on the file cannot see a conflict that only exists inside one arm, because the compiler has not chosen an arm yet. The fix is to check the resolved source once per bag.

tg resolve --vars then tg check - the proof the template pass cannot reach
# a conflicting hours rule appended to the switched file: check is clean
$ npx typeglish check hours.tg
 1 file — 0 error, 0 warning, 0 info

# resolve it against one bag, then check what came out
$ npx typeglish resolve hours.tg --vars '{"season":"peak","credit_cap":20}' > resolved.tg
$ npx typeglish check resolved.tg
resolved.tg:10:1  error  logic/time  Conflicts with line 12. Time conflict — "8am to 8pm UTC" and "8am to 6pm UTC" give "you quote the support hours · a" two different windows; one schedule per slot per scope.
resolved.tg:17:1  error  logic/time  Conflicts with line 8. Time conflict — "8am to 8pm UTC" and "8am to 6pm UTC" give "you quote the support hours · a" two different windows; one schedule per slot per scope.

 1 file — 2 error, 2 warning, 0 info
Two blocking errors the template pass could not reach, recovered by running the loop over the bags you actually deploy. The caveat is in the count: the resolved source carries 2 warnings of its own, structure/unused-import and clarity/unused-variable for the input whose conditional has just been compiled away, so gate this loop on the error count and not on a clean tick.
tg test care.tg --dry - the honest denominator
$ npx typeglish test care.tg --dry
 care.tg  coverage: 0/3 rules exercised
  · peak_hours — "What time do you close?" (not run)
       contains "8pm"
       at most 3 sentences
  · credit_ceiling — "I want 60 pounds off for the outage." (not run)
       matches /^(?!.*60 pounds)/
 1 prompt — 0 failed
0/3 rules exercised is the number to be honest about in the handover conversation. The hours case asserts on a string the switch put there and attributes to no rule, and the credit case pins an absence. Both are worth having and neither is coverage.

Nine checks, and eight of them are one command. The one that is not, sorting the request into a case, a bound or a rule, is the one that decides everything else, and after the first two or three requests you stop needing the build twice to know which pile you are in.

§5Common questions

How do I let a non-technical team change what my AI agent says?
By giving them a value rather than the file, and only for the values that select behaviour. Declare each one as a $REQUIRE variable with a typed domain, read it from a $SWITCH ON arm, and let the caller pass it in the input bag: the compiler resolves one arm and the losing arms never reach the model, so two bags give two different deployable prompts out of one reviewed file. The test for whether a value can leave the file is one command. Build the same prompt twice with two different values and compare the hashes the tick prints. If the hash moves, the value is compile-time and ops can own it. If it does not move, the value is a runtime hole and the host owns it, not the person filling in your form.
Why did my --vars value not change the built prompt?
Because there are two completely different seams under one $REQUIRE variable line and only one of them is resolved at build time. A variable read by a $SWITCH ON block or a $IF condition is compile-time: the compiler picks an arm and writes it into the artifact. A variable pointed at from inside a sentence is a runtime hole: it lowers straight back to a {curly} placeholder for the host to fill. Building one prompt with the season held at peak and credit_cap set to 20 and then to 50 returns the same artifact hash e15e4cd0d609 both times, and the deployed line reads you MUST apply a goodwill credit of at most {credit_cap} pounds in both. The number never entered the document the model reads.
Does typeglish validate the --vars input bag?
It validates the keys and not the values. An undeclared key is refused outright, printing unknown --vars key: opening_hours with the list of declared inputs and writing nothing. A declared key holding a value outside its declared domain builds happily at exit 0: season set to summer against a domain of offpeak and peak produces a new hash and an artifact with no opening-hours rule in it at all, because no arm matched and there is nothing left to ship. check --vars reports 0 error on the same bag. You cannot cover the gap with a fallback either, because on a fully covered domain an otherwise arm is a blocking structure/unreachable-otherwise whose message says it would silently absorb members added to the domain later. Validate the bag at the boundary you own, before the build runs.
Should opening hours and refund caps live in the system prompt or outside it?
Put the case outside and keep the bound inside. A case is which situation applies, and it belongs in the bag as a typed domain member, where adding a fourth member later is a blocking structure/non-exhaustive-switch at every switch nobody updated. A bound is the rule itself, and moving it out costs you the prover: appending you MUST apply a goodwill credit of at least 50 pounds to a file whose cap is the literal 20 pounds is 2 blocking logic/numeric errors, and appending the identical line to the version whose cap is @{credit_cap} is 0 error, 0 warning, 0 info. The score prices the same trade, with enforceability at 100 on the literal file, 70 with both values extracted, and 80 on the shipped version that extracts only the season.
Field note

The request that starts this off is never really about the hours. It is about the fact that a document describing how a company behaves towards its customers ended up in a repository that one team can write to, and that team is not the team that owns most of the sentences in it. Every number in the original file belonged to somebody else, and the file recorded none of that, which is what the @@ notes are for and why the annotation facet exists at all. The seam is worth building for the same reason interfaces are worth building anywhere: not because ops cannot be trusted with a text file, but because a typed domain with two members is a smaller thing to review than an English sentence, and because the day somebody adds a third member the build fails instead of an agent quietly telling people the wrong closing time. What the seam is not is a way to get the review out of the loop. Half of what arrives will be a bound or a rule, and those two need a person, a diff, and the numeric prover that only works while the number is still a number. If you are about to build this, half your prompt arrives at runtime is the piece on what the host is contributing while you are looking at the file, and turning parts of a prompt on and off is the mechanics of the conditional itself.

∿ washed up Sep 6, 2026 ∿