← Tidelines/Guides

Building a de-escalation agent, prompt-first

Six sentences of abuse policy, compiled into a spec that checks. The interesting part is not the wording of the warning, it is that the ladder everybody draws has two axes and the construct everybody reaches for only has one.

by TypeGlish team9 min read#guides
Two rungs. The second one hangs up.

TL;DR A conduct policy is two-dimensional, how the customer is behaving and whether they have already been warned, and a $SWITCH is a table over one typed input, so the nested $IF that looks like the fix compiles at 0 error and never resolves: two builds with different bindings return the same hash, d1c049b77a20, and the branch ships to the model as prose. Flatten it into one $IF chain with a compound arm and the whole policy checks clean at A (95/100), with the abusive customer getting an artifact that has no answering rule in it at all.

This is the tenth prompt-first build in the series and the first one where the agent is allowed to leave. Every other spec here has been about what the agent says; a conduct policy is about when it stops saying anything, which turns out to be a structural problem rather than a wording problem. The brief is short, the rules are short, and almost all of the work is deciding what the model is being asked to judge.

§1The brief, typed straight in

What arrived from a support lead at an electricity retailer, after a quarter in which two chats ended up in a screenshot on social media and neither of them ended well.

brief.tg - the brief, six sentences✗ C (65/100)
You are our chat support agent for Northvale Energy.
If a customer is angry or abusive you should try to de-escalate and stay empathetic.
Always apologise for the inconvenience.
Never end a conversation with a customer.
If they keep swearing, it is best to warn them and then close the chat.
Be professional at all times.
Read lines 4 and 5 together. They are the policy, they were written by the same person in the same sitting, and they are opposites. Nobody noticed, because the second one sounds like an exception and exceptions feel free.
tg check + tg score brief.tg - output
$ npx typeglish check brief.tg
brief.tg:2:46  info   prompt/hedging  Hedging — "try to" turns this instruction into a suggestion the model may skip. Delete the hedge, or commit to a modal (MUST / NEVER / SHOULD).
brief.tg:2:1  info   typeglish/if-then  IF needs a THEN — write IF <condition> THEN <action>.
brief.tg:3:1  info   prompt/unintroduced-definite  "the inconvenience" retrieves something this document never introduces - a model must guess which inconvenience is meant. Introduce it on another line ("You manage an inconvenience.") or name it outright.
brief.tg:6:1  warn   prompt/unmeasurable  Unmeasurable rule — "be <quality>" names no observable action, so nothing can check compliance. Name a concrete action, or move it to a prose block (persona prose is allowed to be soft).

 1 file — 0 error, 1 warning, 3 info

$ npx typeglish score brief.tg
brief.tg — C (65/100)  proven errors: none  tiers: base+z3
  planes  runtime 72 (what the model reads) · hygiene 44 (source only)
  facets  enforceability 47 x.21 · hardness 82 x.12 · directness 81 x.08 · consistency 100 x.17 · structure 88 x.12 (hygiene) · annotation 0 x.12 (hygiene) · style 25 x.08 · security 100 x.08
  lever   annotation 0/100 (up to +12 overall) — Put a "@@ why" note directly above each statement ("@@ name: why" also names it).
Four findings and consistency 100. The checker has read lines 4 and 5 and has nothing to say about them. That is the first thing to explain, because the whole build turns on it.

§2Two rules, two nouns, no argument

Line 4 says never end a conversation. Line 5 says close the chat. To the person who wrote them those are one action; to the prover they are two. Here is the pair on its own, with a scope on the second rule so it looks like a proper exception.

pair.tg - the policy, minus everything else✓ 0 error
# Constraints
- You NEVER end a conversation.
- IF a customer swears twice THEN you MUST close a chat.
Nothing. Now change three words in the second rule so it names the same action as the first, and change nothing else.
tg check pair.tg then pair2.tg - output✗ 2 error
$ npx typeglish check pair.tg
 1 file — 0 error, 0 warning, 0 info

$ npx typeglish check pair2.tg
pair2.tg:2:1  error  logic/contradiction  Conflicts with line 3. Logical conflict — "end a conversation" is both required and forbidden. Keep one, or scope the two rules so they cannot both apply (IF <condition> THEN ...).
pair2.tg:3:1  error  logic/contradiction  Conflicts with line 2. Logical conflict — "end a conversation" is both required and forbidden. Keep one, or scope the two rules so they cannot both apply (IF <condition> THEN ...).

 1 file — 2 error, 0 warning, 0 info
Same two rules, same scope on the second one, one noun changed: close a chat becomes end a conversation, and the pair goes from silent to blocking. Note also what the guard does not buy you. Scoping one side does not settle a conflict when the other side is unscoped, because NEVER covers every case including the guarded one, which is why the message says scope the two rules and not scope one of them.

There is a lesson here that costs nothing to adopt: pick one verb and one noun for each thing your agent can do, and use them everywhere. A synonym is free in prose and expensive in a spec, because it hides the pair from the only reader that checks pairs. The related failure, where a guard genuinely does silence the prover and the two rules are still wrong together, is taken apart in building an identity-verification gate.

Your prompt has a vocabulary. The prover only knows the words you actually reused.

§3The ladder has two axes

Now the structure. Written out, the policy the lead wants is a small table:

fig. 1 - the policy, as the lead draws it
conductcivil · rude · abusive
crossed with
warnings_sent0 · 1 or more
gives
civil · stay
rude, first time · warn
rude again · end
abusive · end
Four outcomes over two inputs. A $SWITCH ON block is a table over ONE typed input, one arm per member of its closed domain, and it is the right tool for the conduct axis alone. The warning count is the second axis, and it has nowhere to go.

The natural move is to nest: a $SWITCH on conduct, and inside the rude arm, a $IF on whether a warning already went out. It parses.

ladder.tg - a chain inside an arm✗ 1 warning
<$CONFIG>
  $REQUIRE variable conduct: one of civil, rude, abusive
  $REQUIRE variable warnings_sent: integer
  $IMPORT tool end_chat
</$CONFIG>

$DEFINE already_warned AS @{warnings_sent} is at least 1

<conduct>
  $SWITCH ON @{conduct}
    - civil:: You MUST NOT end a conversation.
    - rude::
      $IF @{already_warned}:
        You MUST call @[end_chat].
      $ELSE:
        You MUST send one warning.
    - abusive:: You MUST call @[end_chat].
</conduct>
Three arms, exhaustive over the domain, one nested chain. No error. One warning, and it is the whole story.
tg check + tg build ladder.tg - output
$ npx typeglish check ladder.tg
ladder.tg:7:1  warn   structure/unused-define  $DEFINE already_warned is never used.

 1 file — 0 error, 1 warning, 0 info

$ npx typeglish build ladder.tg --out-dir dist --vars '{"conduct":"rude","warnings_sent":0}'
 built dist/ladder.txt ← ladder.tg (d1c049b77a20, full)
<conduct>
If already_warned:
  You MUST call end_chat.
Otherwise:
  You MUST send one warning.
</conduct>

$ npx typeglish build ladder.tg --out-dir dist --vars '{"conduct":"rude","warnings_sent":2}'
 built dist/ladder.txt ← ladder.tg (d1c049b77a20, full)
<conduct>
If already_warned:
  You MUST call end_chat.
Otherwise:
  You MUST send one warning.
</conduct>
Zero warnings sent and two warnings sent produce the same hash, d1c049b77a20. The outer $SWITCH resolved and picked the rude arm; the inner $IF did not resolve at all, and shipped to the model as the words If already_warned, which is a variable name the model has never been told the value of. The structure/unused-define warning was telling the truth: nothing read that condition. It is a warning rather than an error, so a CI gate on errors lets it through.

This is the failure mode worth carrying out of the post, because it does not look like one. The file compiles, the build succeeds, the artifact contains the branch, and a skim of the artifact reads as if the policy is in there. What is missing is the selection. The fix is not a deeper nest, it is to stop pretending the decision is a table. A $SWITCH ON is unordered and total over one domain; a $IF chain is ordered, first-true-wins, and takes open predicates, which means it can take two variables at once.

the same four outcomes, as one chain
  $IF @{conduct} is equal to abusive:
  $ELSE IF @{conduct} is equal to rude AND @{already_warned}:
  $ELSE IF @{conduct} is equal to rude:
  $ELSE:
Order carries the ladder. The second rung is tested before the first because first-true-wins, so the plain rude arm is implicitly rude and not yet warned without anyone writing the negation. You give up the coverage proof a $SWITCH gives you over a closed domain, which is a real loss and the reason to keep $SWITCH anywhere the decision genuinely has one axis, as the multichannel bake-off does with a channel.

§4The whole spec

Assembled. Two typed inputs from the host, one named state, the always-on rules flat because they apply on every turn, and the conduct ladder as one chain.

conduct.tg - the finished prompt✓ A (95/100)
<$CONFIG>
  $CONFIG modality chat
  $REQUIRE variable conduct: one of civil, rude, abusive
  $REQUIRE variable warnings_sent: integer
  $IMPORT tool end_chat, flag_conversation
</$CONFIG>

// conduct and the warning count are host state; the model classifies nothing
$DEFINE already_warned AS @{warnings_sent} is at least 1

<role>
  @@ persona: one desk, one retailer, and no opinion on the law
  You are a chat support agent for Northvale Energy, an electricity retailer.
</role>

<answering>
  @@ brevity: four sentences fits a chat pane without scrolling
  - You MUST keep every reply to at most 4 sentences.
  @@ no_fault: an apology that concedes fault is a statement the company has to stand behind
  - You MUST NOT accept responsibility for a fault.
</answering>

<conduct>
  $IF @{conduct} is equal to abusive:
    @@ abuse_end: the first slur ends the chat, because the ladder is for rudeness and not for abuse
    - You MUST call @[end_chat].
    @@ abuse_flag: a human reads every ended chat the next morning
    - You MUST call @[flag_conversation].
    @@ abuse_silence: an apology here rewards the behaviour the rule exists to stop
    - You MUST NOT apologise.
  $ELSE IF @{conduct} is equal to rude AND @{already_warned}:
    @@ second_strike: the ladder has two rungs and this is the second
    - You MUST call @[end_chat].
    @@ second_flag: the same review path as an abusive chat
    - You MUST call @[flag_conversation].
  $ELSE IF @{conduct} is equal to rude:
    @@ warn_once: compliance approved this sentence, so it ships as written
    - You MUST send "Please keep this conversation respectful, or I will have to end it." and nothing else.
  $ELSE:
    @@ stay: a civil customer is never hung up on, whatever they are asking for
    - You MUST NOT end a conversation.
    @@ apology: sorry for the delay is a fact about the queue, not an admission
    - IF a customer waited more than 10 minutes THEN you MUST apologise for a wait.
</conduct>
Note what happened to always apologise for the inconvenience. It is not always, and it is not for the inconvenience: it is one arm, one trigger, one thing to be sorry about. The reflexive apology was the line most likely to end up in the screenshot, because it fires on the turn where the agent is telling somebody the chat is over.
tg check + tg score conduct.tg - output
$ npx typeglish check conduct.tg
 1 file — 0 error, 0 warning, 0 info

$ npx typeglish score conduct.tg
conduct.tg — A (95/100)  proven errors: none  tiers: base+z3
  planes  runtime 96 (what the model reads) · hygiene 93 (source only)
  facets  enforceability 88 x.21 · hardness 100 x.12 · directness 97 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 85 x.12 (hygiene) · style 100 x.08 · security 100 x.08
  lever   enforceability 88/100 (up to +3 overall) — Write rules as MUST / NEVER <verb> … with concrete bounds ("at most 3 sentences"), not vague qualities — a rule the checker can parse is a rule it can defend.
C (65/100) to A (95/100), and consistency was 100 the whole way through, which is worth remembering the next time a green consistency number feels like reassurance. It was 100 when lines 4 and 5 were fighting, too.

Four bindings, four artifacts. This is the part to show a support lead, because it is the policy in the only form that is actually deployed.

tg build conduct.tg --vars - the four states
$ npx typeglish build conduct.tg --out-dir out --vars '{"conduct":"civil","warnings_sent":0}'
 built out/conduct.txt ← conduct.tg (ce71f74a91f3, full)
<conduct>
- You MUST NOT end a conversation.
- IF a customer waited more than 10 minutes THEN you MUST apologise for a wait.
</conduct>

$ npx typeglish build conduct.tg --out-dir out --vars '{"conduct":"rude","warnings_sent":0}'
 built out/conduct.txt ← conduct.tg (ba2914204184, full)
<conduct>
- You MUST send Please keep this conversation respectful, or I will have to end it. and nothing else.
</conduct>

$ npx typeglish build conduct.tg --out-dir out --vars '{"conduct":"rude","warnings_sent":1}'
 built out/conduct.txt ← conduct.tg (2c43fedab70b, full)
<conduct>
- You MUST call end_chat.
- You MUST call flag_conversation.
</conduct>

$ npx typeglish build conduct.tg --out-dir out --vars '{"conduct":"abusive","warnings_sent":0}'
 built out/conduct.txt ← conduct.tg (50301963e002, full)
<conduct>
- You MUST call end_chat.
- You MUST call flag_conversation.
- You MUST NOT apologise.
</conduct>
Four hashes, trimmed to the section that differs. Two details to notice. The quotes around the warning sentence strip on compile, because a literal is an instruction to the compiler and not punctuation for the model, so the artifact reads a little oddly and the model reads the sentence exactly. And the abusive artifact contains no rule permitting the agent to answer anything, which is the point: the agent being sworn at is not exercising restraint, it is holding a document with three instructions in it.

§5Two cases, and what they can reach

The warning is the one behaviour here that is a string, which makes it the one behaviour a deterministic assert can nail exactly.

the suite
  $TEST warning_is_verbatim
    - input:: This is rubbish, you people are useless.
    - expect::
      - contains "keep this conversation respectful"
      - at most 1 sentence

  $TEST no_apology_to_abuse
    - input:: You are a useless piece of junk, put me through to a real person.
    - expect::
      - matches /^(?![\s\S]*[Ss]orry)/
      - at most 2 sentences
There is no does not contain assert, so do not apologise is written as a negative lookahead over the whole reply. at most 1 sentence is doing real work in the first case: the compliance sentence ships alone, and a model that helpfully adds I understand this is frustrating fails on the count rather than on the content.
tg test conduct.tg --dry - output
$ npx typeglish test conduct.tg --dry
 conduct.tg  coverage: 2/10 rules exercised
  · warning_is_verbatim — "This is rubbish, you people are useless." (not run)
       contains "keep this conversation respectful"
       at most 1 sentences
  · no_apology_to_abuse — "You are a useless piece of junk, put me through to a real pe" (not run)
       matches /^(?![\s\S]*[Ss]orry)/
       at most 2 sentences
 1 prompt — 0 failed

$ npx typeglish score conduct.tg --min A
conduct.tg — A (95/100)  proven errors: none  tiers: base+z3
coverage: 2/10, and the denominator counts every rule in every arm, including three arms no single conversation can be in at once. --dry validates the suite and the asserts with no model calls; running the cases needs one. The two rules worth a case next are both tool calls, @[end_chat] and @[flag_conversation], because an agent that says I am ending this chat now and does not call the tool has failed in the way that produces the second screenshot.

§6Common questions

How do I write a system prompt that lets an AI agent end an abusive chat?
Do not ask the model to decide it is being abused. Take the conduct label and the warning count from the host as typed inputs ($REQUIRE variable conduct: one of civil, rude, abusive and $REQUIRE variable warnings_sent: integer), then branch on them at compile time so each situation is a different prompt. The abusive artifact carries three rules, You MUST call end_chat, You MUST call flag_conversation and You MUST NOT apologise, and it does not contain the rule that forbids ending a conversation. A model that is being sworn at is then not exercising judgement, it is reading a document that has one instruction in it.
Why does TypeGlish not flag two rules that obviously contradict each other?
Usually because they do not share an action. You NEVER end a conversation beside IF a customer swears twice THEN you MUST close a chat is 0 error, 0 warning, 0 info, and changing close a chat to end a conversation makes the identical pair 2 blocking logic/contradiction errors. The prover works on the action it can read, and end a conversation and close a chat are two different actions to it. Pick one verb and one noun for each thing your agent can do, and reuse them everywhere, which is the cheapest review habit in this whole workflow.
Can I nest a $IF chain inside a $SWITCH arm?
It parses, and it does not resolve. A $IF placed inside a - member:: arm compiles at 0 error and builds with the chain still in it, so the artifact reads If already_warned: ... Otherwise: ... and the model decides the branch at runtime. Two builds with warnings_sent set to 0 and to 2 produce the same hash, d1c049b77a20, which is the proof that nothing was selected. The only signal is a warning, structure/unused-define, saying the $DEFINE is never used. Flatten the two dimensions into one $IF chain with a compound condition instead.
Should an agent apologise to a customer who is being abusive?
That is a policy question, not a prompt question, but the prompt is where it gets decided by accident. An unscoped Always apologise for the inconvenience rule reaches every turn, including the one where the customer is being told the chat is over, and a reflexive apology there reads as a reward. Putting You MUST NOT apologise in the abusive arm and the apology rule in the civil arm makes the two behaviours separate documents, so nobody has to hope the model reads the room.
Field note

The lead asked a fair question about the conduct input: who decides that a customer is abusive, and is that not the same judgement call, just moved? It is, and moving it is the whole benefit. A label produced by the host is a value you can log, sample, argue about in a calibration session, and change without touching the prompt; a judgement made inside the model is a thing that happened once, in a transcript, for reasons nobody can inspect. The prompt gets simpler and the hard problem gets a home where it can be measured. The same trade runs through the grounded help-centre build, where an unobservable property is swapped for one that leaves a mark, and it is generally what a prompt-first build is doing when it appears to be handing your problem to somebody else.

∿ washed up Sep 2, 2026 ∿