← Tidelines/Deep dives

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.

by TypeGlish team10 min read#deep-dives
The transcript starts today.

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.

care.tg - five rules about the past✓ builds
# 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.

tg check, --strict and score - care.tg✗ C (73/100)
$ 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
Three info findings, and all three are about the word the. Not one of them is about the fact that no rule in the file can be evaluated. --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.

sorting the five rules by where the fact lives
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 a wording problem. Row two is a plumbing problem. Row three is a product decision, and the honest options are to start recording the fact or to stop writing rules about it. Nothing in the middle helps.

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.

tg check care-half.tg - the honest attempt, half done✗ 3 errors
$ 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
Same policy, one line of it made explicit, and the file will not build. Two of the three errors ask the same question in two registers: who supplies this. The third asks what happens when the answer is no, which is the question the prose version never had to face because it never branched.

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.

care-shipped.tg - the same policy, sourced✓ A (94/100)
<$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.
tg check and score - care-shipped.tg✓ A (94/100)
$ 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.

tg build --vars - two customers, two 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
Two hashes, two documents. The long-standing customer's prompt contains no permission to give a credit and no sentence about waiving a fee; the new customer's contains no prohibition on either. Neither model is being asked to remember anything, and neither is being asked to resist an instruction that does not apply. One honest caveat on the layout: arm bodies reflow, so the five rules arrive as one paragraph under the heading.

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.

tg check and score - six spellings of a history rule✗ no separation
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
The highest score in the set belongs to 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:

tg score - the wish against the working rule
$ 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
Runtime 100 against 85, enforceability 100 against 50. The scorer reads ALWAYS remember X as an unconditional obligation with a catalogued verb and a concrete object, which is the strongest rule shape there is. It reads the typed conditional as a rule that only sometimes applies, and a rule that only sometimes applies is by construction less enforceable than one that always does.

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 $IF arm. The host binds the value from the system that owns it, and because a $IF chain 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) to A (94/100), and two different customers produce two genuinely different artifacts, 66f9b9f3c278 and 7f1625527da6.
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 --strict as 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 2 with nothing declared is 2 blocking errors, structure/undefined-ref and structure/undeclared-input, and a chain with no $ELSE adds structure/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.
Field note

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.

∿ washed up Sep 3, 2026 ∿