Your policy remembers. Your agent does not.
A contact-centre policy is written about a relationship. A system prompt is read by something whose entire past begins when the customer opened the chat. Most of the gap between them never shows up as an error.
TL;DR Rules that quantify over what happened before this conversation are unfollowable and completely invisible to the checker: five of them read 0 error, 0 warning, 3 info at C (73/100) and stay clean under --strict, while the honest rewrite is blocked by structure/undefined-ref and structure/undeclared-input until you declare where the fact comes from. Turn each historical question into one typed host input read by a $IF arm and the same policy is A (94/100), with two customers compiling to two different prompts.
Read a mature CX prompt with one question in mind, when did the thing this rule mentions happen, and the file splits in half. One half is about the sentence the customer just typed. The other half is about a relationship: the credit you already gave them, the third time they have called about the same fault, the apology that went out with the outage notice on Tuesday. Everybody writes the second half, because that is what the policy says. Almost nobody notices that the reader has no access to any of it.
§1Five rules, one missing faculty
This is a support prompt for a broadband provider. Five rules, each one lifted from something a real policy document says, each one written by somebody who knew the policy perfectly well.
# Role You are a support agent for Fernpost, a home broadband provider. # Constraints - NEVER offer the same goodwill credit twice. - IF a customer has contacted us before about this fault THEN escalate to a team leader. - ALWAYS waive the late fee for a first-time late payment. - Do not apologise for the outage again if we have already apologised. - IF a customer is new THEN explain the 30-day returns window.
Every one of those rules turns on a fact that is not in the model's input. Twice counts across an unbounded past. Before means before this chat. First-time is a lifetime count held in billing. Already apologised refers to a message the agent did not send. New is a date subtraction against an account record. The transcript in front of the model contains none of it, and the system prompt contains none of it either, so five rules out of five are addressed to a reader who cannot evaluate their conditions.
$ typeglish check care.tg care.tg:5:1 info prompt/unintroduced-definite "the same goodwill credit" retrieves something this document never introduces - a model must guess which credit is meant. care.tg:7:1 info prompt/unintroduced-definite "the late fee" retrieves something ... care.tg:8:1 info prompt/unintroduced-definite "the outage" retrieves something ... ✓ 1 file - 0 error, 0 warning, 3 info $ typeglish check care.tg --strict ✓ 1 file - 0 error, 0 warning, 3 info $ typeglish score care.tg care.tg - C (73/100) proven errors: none tiers: base+z3 planes runtime 80 (what the model reads) · hygiene 50 (source only) facets enforceability 60 x.21 · hardness 100 x.12 · directness 60 x.08 consistency 100 x.17 · structure 100 x.12 · annotation 0 x.12 style 63 x.08 · security 100 x.08 lever annotation 0/100 (up to +12 overall) L5 prompt/unintroduced-definite -0.25 L7 prompt/unintroduced-definite -0.25 L8 prompt/unintroduced-definite -0.25
--strict changes nothing, because there is no finding to escalate.The build goes through, and the artifact is 415 bytes of instructions that read beautifully and cannot be executed. That is the shape of the defect: not wrong, not contradictory, just addressed to the wrong reader.
A rule about the past is a question. If nothing in the input answers it, the model answers it anyway.
And it does answer. Faced with if a customer has contacted us before, a model does not stop and report that it lacks the information. It looks at what it has, which is a transcript and a tone, and produces a plausible reading: a frustrated opening line becomes evidence of a previous contact, a calm one becomes evidence of a first. The rule fires on sentiment. Nobody wrote that policy.
§2Three places a fact can live
The useful move is to stop asking whether a rule is well written and start asking where its facts are. There are exactly three answers, and the fix is different for each.
where the fact lives example what to do
in the window the customer gave a postcode scope the rule to a
earlier in this chat unit: per turn, per
reply, per contact
in a system contacts in 30 days, credits in make it a typed input
12 months, account tenure, a and branch on it
late-payment count
nowhere at all did we already apologise for build the record, or
this outage, is this customer delete the rule
annoyed with us generally
Row one is the one people conflate with the others, and it is genuinely different. A fact stated in this conversation is in the model's input, so the rule can be followed; the work is deciding the unit it counts over, which is its own long-running trap and the subject of once per what?. Row two and row three both look like row one when you read them quickly, because English does not mark the difference. Do not repeat yourself and do not offer the same credit twice are the same sentence shape carrying two completely different requirements.
Row three is worth being blunt about. Have we already apologised for this outage is a question with no owner in most contact centres. It is not in the CRM, because apologies are not events anybody logs. If the rule matters, somebody has to make it an event: a flag the outage tooling sets when the notice goes out. If nobody will, the rule is decoration and it is costing you tokens and review attention every week.
§3The honest version does not compile
Here is the asymmetry that makes this worth a post rather than a paragraph. Write the rule as a wish and the checker is silent. Write it as an instruction that admits where the fact comes from, and the checker stops you until you finish the job.
$ typeglish check care-half.tg care-half.tg:1:27 error structure/undefined-ref Dangling pointer - @{contacts_30d} names nothing. Declare it with "$REQUIRE variable contacts_30d" (a runtime variable), or "contacts_30d IS ..." (a fixed value). care-half.tg:1:1 error structure/undeclared-input $DEFINE repeat_contact reads @{contacts_30d}, which isn't a declared input or $DEFINE. care-half.tg:8:1 error structure/non-exhaustive-switch A chain over open-ended conditions can't be proven exhaustive - add an ELSE arm. ✗ 1 file - 3 error, 0 warning, 0 info # exit 1
That third error is the one worth sitting with. structure/non-exhaustive-switch is the compiler noticing that escalate repeat contacts says nothing about first contacts, and a decision with only one arm is not a decision. In prose that gap is invisible, because a bare IF reads as complete: the reader supplies the missing branch from common sense. The moment the branch is a compiler construct, the missing arm is a hole in a table and the table refuses to compile with a hole in it.
§4History as an input, not a memory
The rewrite does not give the agent a memory. It gives the agent an answer. For each historical question, find the one value that decides the rule, get the system that owns it to send that value, and let the compiler do the branching. Five questions, five integers and booleans, and the model never sees a question at all.
<$CONFIG> $CONFIG modality chat </$CONFIG> $REQUIRE variable contacts_30d: integer $REQUIRE variable credits_12m: integer $REQUIRE variable late_payments: integer $REQUIRE variable tenure_days: integer $REQUIRE variable outage_apology_sent: boolean $DEFINE repeat_contact AS @{contacts_30d} is at least 2 $DEFINE credit_used AS @{credits_12m} is at least 1 $DEFINE first_late_payment AS @{late_payments} is at most 1 $DEFINE new_customer AS @{tenure_days} is at most 30 # Role @@ role: the doer every rule below is addressed to You are a support agent for Fernpost, a home broadband provider. # Constraints @@ credit_once: the billing system counts the credits, so the prompt never has to remember them $IF @{credit_used}: @@ used: the year's credit is spent, so the answer is no and the agent does not relitigate it NEVER offer a goodwill credit. $ELSE: @@ unused: twenty pounds is the agent's own authority, above it the credit is a team-leader decision MAY offer a goodwill credit of at most 20 pounds. @@ repeat_escalation: two contacts in thirty days is a CRM fact, not a hunch about this chat $IF @{repeat_contact}: @@ second_time: the customer has already told this story once, so nobody makes them tell it again Escalate to a team leader. $ELSE: @@ first_time: a first contact is a troubleshooting job Troubleshoot a fault before you escalate it. @@ late_fee: first-time is a count the billing system holds $IF @{first_late_payment}: @@ goodwill: one waiver is policy, and the billing system is the one counting Waive a late fee. $ELSE: @@ no_goodwill: a second waiver is a payment-plan conversation, not a chat decision NEVER waive a late fee. @@ outage_apology: the host sets this when the outage notice goes out, so the agent never apologises twice $IF @{outage_apology_sent}: @@ apologised: a second apology reads as a script, not as regret NEVER apologise for an outage again. $ELSE: @@ not_yet: the first apology is the one that counts Apologise once for an outage. @@ returns_window: tenure is a date arithmetic problem the host already solved $IF @{new_customer}: @@ new: the window is news inside the first month Explain a 30-day returns window. $ELSE: @@ established: after a month it is trivia unless the customer raised it Explain a 30-day returns window only when a customer asks about returns.
$ typeglish check care-shipped.tg ✓ 1 file - 0 error, 0 warning, 0 info $ typeglish score care-shipped.tg care-shipped.tg - A (94/100) proven errors: none tiers: base+z3 planes runtime 95 (what the model reads) · hygiene 90 (source only) facets enforceability 88 x.21 · hardness 93 x.12 · directness 97 x.08 consistency 100 x.17 · structure 100 x.12 · annotation 80 x.12 style 100 x.08 · security 100 x.08 lever enforceability 88/100 (up to +3 overall)
C (73/100) to A (94/100), with enforceability carrying most of it: 60 to 88. No policy changed. Five conditions that were addressed to the model are now addressed to the host, and the five rules underneath them became ten, because every branch had to say what happens when the history says no.The payoff is not the grade, it is the artifact. A $IF chain is resolved by the compiler, so the losing arm is not text the model has to weigh: it is text the model never receives. Bind two different customers and you get two different prompts.
$ typeglish build care-shipped.tg --vars '{"contacts_30d":3,"credits_12m":1, "late_payments":4,"tenure_days":900,"outage_apology_sent":true}' ✓ built .typeglish/dist/care-shipped.txt ← care-shipped.tg (66f9b9f3c278, full) # Constraints NEVER offer a goodwill credit. Escalate to a team leader. NEVER waive a late fee. NEVER apologise for an outage again. Explain a 30-day returns window only when a customer asks about returns. $ typeglish build care-shipped.tg --vars '{"contacts_30d":1,"credits_12m":0, "late_payments":1,"tenure_days":12,"outage_apology_sent":false}' ✓ built .typeglish/dist/care-shipped.txt ← care-shipped.tg (7f1625527da6, full) # Constraints MAY offer a goodwill credit of at most 20 pounds. Troubleshoot a fault before you escalate it. Waive a late fee. Apologise once for an outage. Explain a 30-day returns window. $ typeglish build care-shipped.tg # unbound template ✓ built .typeglish/dist/care-shipped.txt ← care-shipped.tg (54ea2a13c76e, full) 629 bytes - both sides of every branch, as conditional prose
Bind nothing and the third build is what you get: 629 bytes carrying both arms of all five branches, which is the template build and is not what you deploy per conversation. That failure mode has its own shape, and your prompt cannot tell a missing input from a real one is the one to read if your host might quietly stop sending a variable.
§5The rule that cannot be followed scores highest
One controlled set, because the honest limit here is sharp and you should know it before you put a score floor in CI. Six one-rule files, each the same policy intention spelled a different way.
the rule tg check tg score NEVER offer the same goodwill credit twice. 1 info B (80/100) IF a customer has contacted us before THEN escalate. clean C (76/100) NEVER apologise twice in one conversation. clean B (81/100) ALWAYS remember what a customer told you earlier. clean B (87/100) IF a customer is a long-standing customer THEN waive. clean C (75/100) IF @{contacts_30d} is at least 2 THEN escalate. clean C (76/100) $ typeglish check h1.tg h2.tg h3.tg h4.tg h5.tg h6.tg ✓ 6 files - 0 error, 0 warning, 1 info
ALWAYS remember what a customer told you earlier, the only rule here that is pure wish. The lowest belong to the two that name a condition, including the typed one that actually works.The mechanism is not a bug, and it is worth understanding rather than working around. Look at the two ends:
$ typeglish score h4.tg # ALWAYS remember what a customer told you earlier. h4.tg - B (87/100) proven errors: none tiers: base+z3 planes runtime 100 (what the model reads) · hygiene 50 (source only) facets enforceability 100 x.21 · hardness 100 x.12 · directness 100 x.08 consistency 100 x.17 · structure 100 x.12 · annotation 0 x.12 $ typeglish score h6.tg # IF @{contacts_30d} is at least 2 THEN escalate ... h6.tg - C (76/100) proven errors: none tiers: base+z3 planes runtime 85 (what the model reads) · hygiene 50 (source only) facets enforceability 50 x.21 · hardness 100 x.12 · directness 90 x.08 consistency 100 x.17 · structure 100 x.12 · annotation 0 x.12
So the score is measuring the sentence, correctly, and the sentence is excellent. What it cannot measure is whether the reader can evaluate the words in it, because what a customer told you earlier is a perfectly ordinary noun phrase and nothing in the file says it refers to a Tuesday the model was not present for. Every guarded rule pays this tax, which means a prompt that gets more correct by getting more conditional will get a slightly worse enforceability number on the way. Take the trade. Just do not let a score floor talk you out of it, and do not read a high enforceability number as evidence that a rule fires.
Which leaves the reframe. Stop thinking of the system prompt as the place where your agent's knowledge lives and start thinking of it as the place where your agent's decisions live, with every fact those decisions need arriving from somewhere that owns it. The prompt is not a briefing document and it is certainly not a memory. It is a function, the history is an argument, and the argument has to be passed. That is the same boundary half your prompt arrives at runtime draws around injected content, seen from the other side: there it is text the host composes in, and here it is values the host binds, and in both cases the file is only half of what the model reads.
§6Common questions
- Why does my AI agent not remember previous conversations?
- Because nothing put them in front of it. An agent reads a system prompt plus the transcript of the conversation it is currently in, and the transcript begins when the customer opened the chat. Anything earlier, a contact last Tuesday, a credit issued in March, an apology sent with the outage notice, exists in your CRM and your billing system, not in the model's input. A rule that refers to it is not a difficult instruction, it is an instruction about a fact the reader was never given, and the model will do what any reader does with a question it cannot answer, which is guess from what is in front of it.
- How do I give a support agent access to customer history in a system prompt?
- You do not put the history in, you put the answer in. Reduce each historical question to the single value the rule actually needs, declare it as a typed input with
$REQUIRE variable contacts_30d: integer, name the threshold once with$DEFINE repeat_contact AS @{contacts_30d} is at least 2, and branch on it with a$IFarm. The host binds the value from the system that owns it, and because a$IFchain resolves at compile time the losing arm never reaches the model at all. On the five-rule prompt in this post that turned five wishes into five branches over five typed inputs,C (73/100)toA (94/100), and two different customers produce two genuinely different artifacts,66f9b9f3c278and7f1625527da6. - Does TypeGlish flag a rule about something the agent cannot know?
- No. All five history rules in this post check at
0 error, 0 warning, and clean under--strictas well, because a rule about the past is grammatically identical to a rule about the present. The checker refuses the opposite case: write the honest version and$DEFINE repeat_contact AS @{contacts_30d} is at least 2with nothing declared is 2 blocking errors,structure/undefined-refandstructure/undeclared-input, and a chain with no$ELSEaddsstructure/non-exhaustive-switch. So the compiler starts helping at the moment you admit the fact comes from outside the prompt, and says nothing at all while you are pretending it does not. - Is a rule about the current conversation the same problem?
- No, and the distinction is the useful part. A fact stated earlier in the same conversation is in the window, so the model can genuinely act on it, and the work there is scoping the rule to the right unit, once per turn, once per reply, once per contact, rather than sourcing the fact. A fact from before the conversation is not in the window at any scope, so no amount of rewording reaches it. Sort every history rule into those two piles first: the in-window ones are a wording problem and the cross-contact ones are a plumbing problem, and treating the second kind as the first is how you end up with a rule that reads perfectly and never fires.
There is a cheap audit for this, and it takes about twenty minutes on a real file. Print the prompt, and for every rule write in the margin the name of the system that would have to answer its condition. Rules that need only the transcript get a tick. Rules that name a system get that system's name, and those become $REQUIRE variable lines. Rules with a blank margin are row three, and the blank is the finding: nobody records that fact, so nobody can act on it. In our experience the third pile is never empty, and it is usually the pile with the most emotionally important rules in it.