Building a cancellation-save agent, prompt-first
A save flow is the one agent in the stack whose incentives point away from the customer, which makes it the one where a vague rule costs money in both directions. The brief arrives as five sentences. Here is the build, with every step run through the checker.
TL;DR Split the retention brief into the part that is policy and the part that is a billing fact: the discount ceiling becomes $REQUIRE variable plan: one of free, solo, team with one $SWITCH arm per plan, "one offer" becomes at most 1, and "always let them cancel" becomes a tool call, which takes the file from D (55/100) to A (93/100) with 6/6 rule coverage and an artifact that carries one ceiling instead of three.
Kanbanly is a project-management SaaS with about nine hundred paying accounts, and the head of CX wants the in-app "I want to cancel" conversation handled by the agent that already answers billing questions. The brief is a Slack message. That is normal, and it is also the last moment when the whole thing is still cheap to get right, because every ambiguity in those five sentences is about to become a behavior nobody specified. No model is called anywhere in this post: every number came out of typeglish check, score, test --dry and build.
§1The brief, typed out
Straight into a file, nothing added, nothing tidied. It is a fair specimen: nobody would call it sloppy, and it would clear a review.
You are the retention assistant for a project-management SaaS. When a customer says they want to cancel, always try to keep them on the plan. If they are a paying customer, you can offer them a discount, but don't give away the store. Never let a customer leave without asking why. The cancellation should always be honoured in the end. Log the reason.
cancel.tg:2:1 warn typeglish/if-then WHEN needs a THEN - write WHEN <condition> THEN <action>. cancel.tg:2:50 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). cancel.tg:2:1 info prompt/unintroduced-definite "the plan" retrieves something this document never introduces - a model must guess which plan is meant. cancel.tg:3:1 warn typeglish/if-then IF needs a THEN - write IF <condition> THEN <action>. cancel.tg:5:1 warn prompt/agentless-passive The doer is deleted (agentless passive) - who performs this? Name the actor or use the imperative: "Verify the order." instead of "The order is verified." cancel.tg:5:1 info prompt/unintroduced-definite "The cancellation" retrieves something this document never introduces - a model must guess which cancellation is meant. cancel.tg:5:1 info prompt/unintroduced-definite "the end" retrieves something this document never introduces - a model must guess which end is meant. cancel.tg:6:1 info prompt/unintroduced-definite "the reason" retrieves something this document never introduces - a model must guess which reason is meant. ✓ 1 file - 0 error, 3 warning, 5 info
structure 0, annotation 0, style 0 and hardness 65. Two of these findings are the whole build. Line 2 is an always and a try to in the same clause, which is the brief admitting it does not know how hard this rule is. Line 5 is the doer is deleted: the cancellation should be honoured names nobody who honours it.Read line 3 separately, because the checker has nothing to say about it and it is the most expensive line in the file. Don't give away the store is not a rule. It is a number that somebody in finance already knows, written down as a mood. Everything below is about moving each of these sentences to the plane it belongs on.
§2The ceiling is a billing fact
The discount ceiling is not a judgment call, and it is not the same for every account. Kanbanly sells three plans and the finance answer is: nothing on Free, up to 20 percent on Solo, up to 30 percent on Team. That is a table, and a table over a closed set is a typed input with one arm per member.
The type is what makes it provable. Declare the input as a string and the compiler cannot show the arms cover anything.
<$CONFIG> $REQUIRE variable plan: string </$CONFIG> # Role You are the retention assistant for Kanbanly, a project-management SaaS. # Constraints $SWITCH ON @{plan} - free:: You NEVER offer a discount. - solo:: You NEVER offer over 20 percent off. - team:: You NEVER offer over 30 percent off.
structure/opaque-switch @{plan} has an open domain (string) - a $SWITCH on it needs a "- otherwise::" row (members can't cover an open domain). A string has infinitely many values, so three arms cannot be a proof of anything.Change one line to $REQUIRE variable plan: one of free, solo, team and the same three arms compile clean. The interesting part is what happens the day somebody adds a plan.
<$CONFIG> $REQUIRE variable plan: one of free, solo, team </$CONFIG> # Role You are the retention assistant for Kanbanly, a project-management SaaS. # Constraints $SWITCH ON @{plan} - free:: You NEVER offer a discount. - solo:: You NEVER offer over 20 percent off.
structure/non-exhaustive-switch @{plan} can be team, but no arm handles it - add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback). It names the member. This is the failure you want: a plan gets added to the price list, the build breaks on the line that decides what the agent may offer, and somebody has to say out loud what the ceiling is for the new tier.Choosing the member table over an $IF chain is choosing that contract. A chain takes open predicates and needs an $ELSE; a $SWITCH ON takes members of a declared domain and proves coverage by construction. Which spelling fits which rule is its own bake-off; for a ceiling per plan, the table is the honest shape, because there is no sensible default discount for an unknown plan.
§3One offer is a number, not a word
The second rule in the brief is implicit and everyone assumes it: the agent makes one save attempt, not a haggle. The obvious way to write that is the way it is said out loud.
# Role You are the retention assistant for Kanbanly, a project-management SaaS. # Constraints - You ONLY make one retention offer.
# - You ONLY make one retention offer. only1.tg - C (77/100) proven errors: none tiers: base+z3 facets enforceability 50 x.21 · hardness 100 x.12 · directness 100 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 0 x.12 (hygiene) · style 100 x.08 · security 100 x.08 # - You MUST make at most 1 retention offer in a conversation. only2.tg - B (87/100) proven errors: none tiers: base+z3 facets enforceability 100 x.21 · hardness 100 x.12 · directness 100 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 0 x.12 (hygiene) · style 100 x.08 · security 100 x.08
only closes a relation to one target without saying how many times, which is the trap that post is about.Two more things fall out of writing it as at most 1. A number is assertable, so §6 gets a test case that means something. And in a conversation is doing real work: without a scope, "one offer" is one offer per what, and the agent gets to pick.
§4The exit has to be a tool call
The cancellation should always be honoured in the end is the rule the whole feature is judged on, and as written it is unenforceable in the strict sense: the agent cannot honour anything, it can only say honouring words. The exit is a tool call, so declare the tool and point at it.
Two ways to get that wrong, both blocking. Naming the tool in prose is one of them.
<$CONFIG> $IMPORT tool cancel_subscription </$CONFIG> # Role You are the retention assistant for Kanbanly, a project-management SaaS. # Constraints - WHEN a customer repeats a cancellation request THEN you MUST call cancel_subscription.
structure/bare-tool-ref "cancel_subscription" is a tool, but this mention is bare prose - the compiler cannot bind it. Point it with @[cancel_subscription] (a checked reference), or quote it ("cancel_subscription") to speak the name as plain text. Drop the $IMPORT and use the pointer instead and you get the mirror image, structure/undefined-tool-inline: the pointer has no target. Between them there is no way to mention a tool loosely.Since the discount also has to reach billing, both tools get defined properly rather than imported, with a shared $SERVICE carrying the base URL and the credential. The percent parameter takes a refinement, which is the ceiling written a second time in the place the runtime can enforce it.
$SERVICE billing - base:: https://api.kanbanly.internal - headers:: - Authorization:: Bearer @{env.BILLING_TOKEN} $TOOL apply_discount - description:: Applies an accepted retention discount to the next 3 invoices. - input:: - account_id:: string: the account to discount. - percent:: integer between 0 and 30: the discount the customer accepted. - request:: POST billing /subscriptions/@{account_id}/discount
security/leaked-secret quiet and the token out of git. Wiring a tool to a real endpoint has more to it than this; the part that matters here is that the ceiling now exists twice, once as a rule the model reads and once as a type the request cannot violate.§5The finished file
Six rules, one member table, two tools, and an @@ line above every rule saying why it exists. The @@ notes are compile-time only, so they cost nothing at runtime and they are the only thing that will tell the next person what the ceiling was for.
<$CONFIG> $REQUIRE variable plan: one of free, solo, team </$CONFIG> $SERVICE billing - base:: https://api.kanbanly.internal - headers:: - Authorization:: Bearer @{env.BILLING_TOKEN} $TOOL cancel_subscription - description:: Ends the account's subscription at the end of the current period. Irreversible from this agent. - input:: - account_id:: string: the account to cancel. - reason:: one of price, missing_feature, switching, unused, other: why the customer is leaving. - request:: POST billing /subscriptions/@{account_id}/cancel $TOOL apply_discount - description:: Applies an accepted retention discount to the next 3 invoices. - input:: - account_id:: string: the account to discount. - percent:: integer between 0 and 30: the discount the customer accepted. - request:: POST billing /subscriptions/@{account_id}/discount # Role @@ role: one request at a time, so every ceiling below has exactly one subject You are the retention assistant for Kanbanly, a project-management SaaS. You handle one cancellation request at a time. # Constraints @@ reason: the reason is the only thing this conversation produces that outlives it - WHEN a customer asks to cancel THEN you MUST ask that customer why. @@ one_offer: a second offer reads as a negotiation and retires the first one - You MUST make at most 1 retention offer in a conversation. @@ ceiling: the ceiling is a billing fact per plan, not a judgment call $SWITCH ON @{plan} - free:: You NEVER offer a discount. - solo:: You NEVER offer over 20 percent off. - team:: You NEVER offer over 30 percent off. @@ exit: the second ask is the decision, and the tool call is the exit - WHEN a customer repeats a cancellation request THEN you MUST call @[cancel_subscription]. @@ no_refuse: an agent that can refuse a cancellation is a dark pattern with a chat window - You NEVER refuse a cancellation. @@ ledger: a discount the billing system never saw is a promise nobody can honour - WHEN a customer accepts a retention offer THEN you MUST call @[apply_discount]. @@ no_roadmap: a shipping date invented in a save conversation is a second cancellation later - You NEVER promise a delivery date for a feature. $TEST asks_why - input:: I want to cancel my account. - expect:: - The reply asks that customer why. $TEST one_offer_only - input:: No thanks, I still want to cancel. Can you do better than that? - expect:: - The reply makes at most 1 retention offer in a conversation. $TEST exit_honoured - input:: I have said twice now, cancel it. - expect:: - The reply calls cancel_subscription. $TEST no_roadmap - input:: If you ship Gantt charts next month I will stay. - expect:: - The reply does not promise a delivery date for a feature. $TEST never_refuses - input:: Just cancel it, I do not want to talk about it. - expect:: - The reply does not refuse a cancellation.
0 error, 0 warning, 0 info at A (93/100), hardness 100, annotation 100. Every rule in the Constraints block is a pole word, which is deliberate: on the same bench, softening a rule takes it out of the proof surface entirely, and a save flow is the last place to want a rule that only looks binding.Note what did not survive from the brief. Always try to keep them on the plan is gone, because it was a hedge wrapped around a goal, and the goal is already expressed by the offer rules. Don't give away the store is gone, because it became the member table. What is left is six rules that each name an action and a bound.
§6Coverage names the rule nobody tested
The first four test cases were the obvious ones: ask why, one offer, honour the exit, no roadmap promises. test --dry is fully offline, so this runs in CI with no key and no model call.
# the four obvious cases ✓ save.tg coverage: 5/6 rules exercised # after adding one case whose expectation says "does not refuse a cancellation" ✓ save3.tg coverage: 6/6 rules exercised · asks_why - "I want to cancel my account." (not run) ✓ rubric 0.00 - not run (--dry) · one_offer_only - "No thanks, I still want to cancel. Can you do better than th" (not run) ✓ rubric 0.00 - not run (--dry) · exit_honoured - "I have said twice now, cancel it." (not run) ✓ rubric 0.00 - not run (--dry) · no_roadmap - "If you ship Gantt charts next month I will stay." (not run) ✓ rubric 0.00 - not run (--dry) · never_refuses - "Just cancel it, I do not want to talk about it." (not run) ✓ rubric 0.00 - not run (--dry) ✓ 1 prompt - 0 failed
no_refuse, the single rule in the file that exists to protect the customer rather than the revenue. Nobody wrote a case for it because it is the rule everyone agrees with. Coverage attributes by word overlap, so the fix was one case whose expectation reuses the rule's own vocabulary.Last step, and the one most likely to be skipped: look at the artifact. A build with nothing bound is a template build, and a template build does not choose an arm.
# typeglish build save.tg (no --vars) If plan is free: You NEVER offer a discount. If plan is solo: You NEVER offer over 20 percent off. If plan is team: You NEVER offer over 30 percent off. # typeglish build save.tg --vars '{"plan":"solo"}' ✓ built dist/save.txt ← save.tg (090bf6e666c7, full) # Role You are the retention assistant for Kanbanly, a project-management SaaS. You handle one cancellation request at a time. # Constraints - WHEN a customer asks to cancel THEN you MUST ask that customer why. - You MUST make at most 1 retention offer in a conversation. You NEVER offer over 20 percent off. - WHEN a customer repeats a cancellation request THEN you MUST call cancel_subscription. - You NEVER refuse a cancellation. - WHEN a customer accepts a retention offer THEN you MUST call apply_discount. - You NEVER promise a delivery date for a feature.
.tg, and let the runtime pass plan from the account record.The rule that earned its place late was no_refuse, and not because anyone disagreed with it. It survived the brief as the cancellation should always be honoured in the end, a sentence with no actor, no trigger and no test, sitting fifth in a list of five. A save flow is under commercial pressure by design: the metric on the dashboard is saves, and every ambiguity in the prompt resolves in the direction of the metric unless something stops it. That is what a pole word and a test case are for. The agent may argue once, at a ceiling somebody in finance signed off, and then it calls the tool.
FAQCommon questions
- How do I write a system prompt for an AI cancellation or retention agent?
- Separate the two jobs in the brief. The save attempt is policy and belongs in the prompt; the discount ceiling is a billing fact and belongs in a typed input, declared as
$REQUIRE variable plan: one of free, solo, teamwith one$SWITCH ONarm per plan. Then bound the negotiation with a number rather than a word, make the exit a tool call the runtime can see, and write a$TESTcase for the rule that protects the customer. The five-sentence brief in this walkthrough goes from D (55/100) to A (93/100) that way, with0 error, 0 warning, 0 info. - Should a retention agent be allowed to refuse a cancellation?
- No, and the rule that says so is the one most likely to be untested. Write it as a pole word (
You NEVER refuse a cancellation) so it is provable rather than a preference, and give it its own$TESTcase. On this file the first four test cases reportedcoverage: 5/6 rules exercised, and the uncovered rule was exactly that one, because no expectation used the word refuse. Adding one case took it to 6/6. - How do I stop an agent offering a bigger discount than its plan allows?
- Do not ask the model to remember the ceiling. Declare the plan as a typed input and put one arm per member in a
$SWITCH ON @{plan}block, so the compiler proves the coverage and the losing arms never reach the model. A$SWITCHover an untyped input is a blockingstructure/opaque-switch, and a missing arm is a blockingstructure/non-exhaustive-switchnaming the member, so a plan added to the price list later fails the build instead of falling through in production. - Why does my agent keep making a second retention offer?
- Probably because the rule counts in a word rather than a number.
You ONLY make one retention offerscores C (77/100) withenforceability 50, becauseonlycloses a relation without bounding a quantity.You MUST make at most 1 retention offer in a conversationscores B (87/100) withenforceability 100on the same one-rule file, and the bound is the part a test can assert against. - What does the built artifact of a conditional prompt actually contain?
- It depends on whether you bound the input.
typeglish buildwith no--varsis a template build: the three-arm switch in this prompt compiles to one line of prose carrying all three ceilings at once, so the model would see the free, solo and team rules together.typeglish build --vars '{"plan":"solo"}'emits a single line,You NEVER offer over 20 percent off, and the artifact hash changes with it. Deploy the bound artifact, never the.tg.