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.
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.
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.
$ 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).
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.
# Constraints - You NEVER end a conversation. - IF a customer swears twice THEN you MUST close a chat.
$ 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
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:
$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.
<$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>
$ 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>
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.
$IF @{conduct} is equal to abusive: $ELSE IF @{conduct} is equal to rude AND @{already_warned}: $ELSE IF @{conduct} is equal to rude: $ELSE:
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.
<$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>
$ 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.
$ 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>
§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.
$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
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.$ 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, abusiveand$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 changingclose a chattoend a conversationmakes the identical pair 2 blockinglogic/contradictionerrors. 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
$IFchain inside a$SWITCHarm? - It parses, and it does not resolve. A
$IFplaced inside a- member::arm compiles at0 errorand 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 withwarnings_sentset 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$DEFINEis never used. Flatten the two dimensions into one$IFchain 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 apologisein 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.
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.