Building an email support agent, prompt-first
Email is the channel most support teams hand to an AI agent first, and the one whose prompt usually gets copied from the chat assistant. Chat is a conversation. An email gets one reply, possibly to several people, and sometimes the sender is a machine.
TL;DR An email agent writes one reply per message, so its prompt must answer every question at once and bound the paragraph rather than the reply, while who is copied and whether the sender is a machine come in as typed inputs from the helpdesk, which also has to map raw header values onto those inputs, because a value outside the domain builds with exit 0 into an artifact with no rules in it.
Larchwood Home sells homeware online and runs support out of a shared inbox. The chat assistant has been live for a quarter; the inbox is next. The brief from the support lead is seven lines long and every one of them is reasonable. Here it is as a prompt, then as a spec, and the three things about email that the brief does not know yet.
§1The brief, checked as written
# Role You are the email support assistant for Larchwood Home, an online homeware shop. # Constraints - Reply to every email in a friendly, professional tone. - Keep replies short. - Answer all of the customer's questions. - Ask for the order number if they don't give it. - Never reply to automatic replies. - Don't share order details with anyone except the account holder. - Sign off as The Larchwood Team.
$ npx typeglish check brief.tg | tail -1 ✓ 1 file — 0 error, 0 warning, 7 info $ npx typeglish score brief.tg brief.tg — C (77/100) proven errors: none tiers: base+z3 planes runtime 87 (what the model reads) · hygiene 50 (source only) facets enforceability 71 x.21 · hardness 100 x.12 · directness 79 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 0 x.12 (hygiene) · style 72 x.08 · security 100 x.08 lever annotation 0/100 (up to +12 overall) — 8 statements without a @@ note (L2 L5 L6 L7 L8 L9 L10 L11) — put a "@@ why" line directly above each rules 3 of 7 rule-shaped lines read L5 ✗ invisible Reply to every email in a friendly, professional tone. no modal (MUST / NEVER / SHOULD), and the checker knows "reply" but cannot prove this shape: it stops at "," — the tail after it is not part of the provable rule, so it reads as prose → lead with a modal (MUST …) to make it a rule, and one action, one object per line; give the tail its own line to make it provable L8 ✗ invisible Ask for the order number if they don't give it. no modal (MUST / NEVER / SHOULD), and the checker knows "ask" but cannot prove this shape: it stops reading at "if" — the clause after it is not part of the provable action, so it reads as prose → lead with a modal (MUST …) to make it a rule, and if the clause is a condition, make it one: WHEN <clause> THEN MUST <verb> …; otherwise give it its own rule to make it provable L10 ✗ invisible Don't share order details with anyone except the account holder. no modal (MUST / NEVER / SHOULD) and "Don't" is not a verb the checker knows, so it reads as prose → lead with a modal, or start with a verb from the catalog (Escalate …, Ask …) L11 ✗ invisible Sign off as The Larchwood Team. no modal (MUST / NEVER / SHOULD) and "Sign" is not a verb the checker knows, so it reads as prose → lead with a modal, or start with a verb from the catalog (Escalate …, Ask …) L5 prompt/unregistered-doer −0.25 A bare generic doer never enters the world model - instruction to the agent, o L8 prompt/unintroduced-definite −0.25 "the order number" retrieves something this document never introduces - a mode L10 prompt/unintroduced-definite −0.25 "the account holder" retrieves something this document never introduces - a mo
check passes this. The rules table says more: four of the seven lines are invisible, and two of the four are the privacy line and the sign-off.The invisible lines are the usual fixes and the rest of this post does them without ceremony: lead with a modal, turn the trailing if into a WHEN ... THEN, give the order number a line that introduces it. The more interesting lines are two of the three the checker did read. Keep replies short and Never reply to automatic replies both parse as rules. Both are wrong for email, and neither is wrong in a way a parser can see from the words alone.
§2One email, one reply
The fastest way to fill in keep replies short is to copy the bound from the chat assistant, which has been tuned for a widget. Two of its rules come along for the ride: a three-sentence cap and the one-question-at-a-time rule that every chat prompt eventually grows. Then the email-specific line goes in, because the support lead's third bullet is the most important thing on the page: answer all of their questions.
# Role @@ role: one sentence names the writer, so every later "you" is the agent You are the email support assistant for Larchwood Home, an online homeware shop. # Constraints @@ brevity: carried over from the chat assistant, where three sentences fit the widget - MUST keep every reply to at most 3 sentences. @@ one_question: carried over from chat, where a second question gets lost below the fold - MUST ask at most 1 question in each reply. @@ every_question: an email gets one reply, so a skipped question costs the customer a day - MUST answer every question in the email.
$ npx typeglish check carryover.tg carryover.tg:11:1 warn prompt/cap-tension Cap tension — this mandate reads exhaustive ("every question in") while every reply is capped at 3 sentences (line 7). Coherent as one default plus one exception — scope the cap ("except when covering every question in"). ↳ line 7: - MUST keep every reply to at most 3 sentences. ✓ 1 file — 0 error, 1 warning, 0 info $ npx typeglish score carryover.tg carryover.tg — A (94/100) proven errors: none tiers: base+z3 planes runtime 92 (what the model reads) · hygiene 100 (source only) facets enforceability 100 x.21 · hardness 100 x.12 · directness 100 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 100 x.12 (hygiene) · style 25 x.08 · security 100 x.08 lever style 25/100 (up to +6 overall) — 1 finding below (L11) — Commit to modals, delete hedges, and apply the flagged rewrites. rules 3 of 3 rule-shaped lines read L11 prompt/cap-tension −1 Cap tension — this mandate reads exhaustive ("every question in") while every
This is the same class of finding Your prompt is a default and its exceptions walks through: two rules that do not contradict, and a decision nobody made. On email the decision is easy once it is said out loud. A reply that skips a question is not short, it is incomplete, and the customer's next message arrives tomorrow. The length of an email is set by what the customer asked. What the cap was really protecting is readability, and readability lives in the paragraph.
On chat a short reply is cheap, because the customer can ask again. On email, asking again costs a day.
So the cap moves: MUST keep every paragraph to at most 3 sentences is a bound on a different slot, and beside answer every question it checks with no finding at all (it is in the final spec in §5). The one-question rule is the other carry-over, and the checker says nothing about it, because asking at most one question and answering every question are not in tension as logic. They are in tension as email. If a customer leaves out both the order number and which item arrived damaged, a chat agent asks for one and waits ten seconds. An email agent that asks for one waits a day, then asks for the other. Delete it by hand; that one is yours to catch.
§3Reply-all is a recipient list
The brief's privacy line, don't share order details with anyone except the account holder, was invisible to the checker, and the obvious repair is to make it a rule: NEVER, a verb from the catalog, one object. That would check clean, and it would still ask the model to make a call it is badly placed to make. On chat, the person in the widget is the person you verified. On email, a partner forwards the delivery confirmation and adds themselves on copy; a customer copies a neighbour who took in the parcel; a business customer copies three colleagues. The reply goes back on the thread to all of them.
Who is on the thread is not something to infer from the body of an email. It is a header the helpdesk has already parsed. That makes it an input, typed, with a closed domain, the way the SMS walkthrough treated the sender's phone number:
directwhen the reply goes only to the address on the account.copiedwhen anyone else is on the To or Cc line.
The two states get two arms of a $SWITCH, so each build carries exactly one of them. The copied arm withholds the delivery address, the most personal detail an order carries; your list will be longer. One naming note from a draft of this file: the first version used holder as the member name, and the checker flagged the arm with prompt/unregistered-doer, because an arm key is read as an English word and holder reads as a person. direct does not, and the finding goes away.
§4The mail loop
Never reply to automatic replies is the line in the brief that sounds the most like a rule and is the least like one. Read it from where the model sits. The helpdesk trigger fires on an inbound message, the platform sends the prompt plus the email to the model, and whatever comes back is sent. Asking the model not to reply is asking it to decline the only job it was called to do, on the evidence of a message body that says I am out of the office until Monday. When it gets that wrong, one out-of-office meets one support autoresponse, and each answers the other.
Mail has a standard for this. RFC 3834 defines an Auto-Submitted header that automatic responders are meant to set, with auto-replied and auto-generated as its values, and the host can read it before the model is ever called. The best version of this rule is not in the prompt at all: the host does not invoke the model for an automated message. If your platform invokes it anyway, which many helpdesk triggers do, make the state an input and give it a section of its own, so the automated build does not carry the reply rules:
<reply when=@{inbound} is equal to written>holds every rule about writing a reply, including the recipients switch.<autoreply when=@{inbound} is equal to automated>holds one rule: close the ticket with a tool call.
A guarded section is not a hiding place: the rules inside it are still proven against each other. A draft that put a 3-sentence and a 5-sentence paragraph cap in the reply section drew two logic/quantifier-strength errors, the same as it would at the top level. Then build the state that needs watching. Here is the automated state, spelled the way the header spells it rather than the way the domain does:
$ npx typeglish build email.tg --vars '{"inbound":"auto-replied","recipients":"direct"}' --out-dir d4 ✓ built d4/email.txt ← email.tg (7a15293dc3ab, full) $ echo $? 0 $ cat d4/email.txt <role> You are the email support assistant for Larchwood Home, an online homeware shop. </role> <context> Every Larchwood order has an order number. </context>
auto-replied is not written and it is not automated, so neither guard holds and both sections drop. The build succeeds. The artifact that ships is a role and a fact: no reply rules, no recipients arm, no tool call. The model gets an out-of-office message and no instruction about what to do with it, so it writes a reply, which is the loop.That is not a checker bug, it is a boundary. check proves the file against its declared domain, and the domain says one of written, automated. What the host puts in the bag at build time is the host's side of the contract, and nothing on the prompt side sees it. The general case, a key missing from the bag rather than a wrong value in it, is the subject of Your prompt cannot tell a missing input from a real one. The fix here is one function in the host: map the header onto the domain, and refuse to call the build with anything else.
§5The spec, built three ways
Everything above, assembled. The brief's seven lines are all still here, but three changed owner: the sign-off became a deterministic assert, the privacy line became a recipients input, and the auto-reply line became a section the host selects. The order number is introduced in the context, so the rule that asks for it is no longer pointing at nothing.
<$CONFIG> $REQUIRE variable inbound: one of written, automated $REQUIRE variable recipients: one of direct, copied </$CONFIG> $TOOL find_order - description:: Look up a Larchwood order by its order number. - input:: - order_number:: string: the order number from the email. $TOOL close_ticket - description:: Close the ticket without sending a reply. <role> @@ role: one sentence names the writer, so every later "you" is the agent You are the email support assistant for Larchwood Home, an online homeware shop. </role> <context> @@ order_number_fact: introduces the order number the lookup needs Every Larchwood order has an order number. </context> <reply when=@{inbound} is equal to written> @@ every_question: an email gets one reply, so a skipped question costs the customer a day - MUST answer every question in the email. @@ paragraph_cap: the questions set the length, the paragraph sets the readability - MUST keep every paragraph to at most 3 sentences. @@ order_lookup: an order status comes from the system, never from memory - WHEN the customer asks about an order THEN MUST call @[find_order]. @@ missing_number: ask in this reply, not in a second round trip - WHEN the email gives no order number THEN MUST ask for the order number. @@ signoff: the sign-off the team uses on every channel - MUST close every reply with "Larchwood Customer Care". @@ reply_all: a reply goes to everyone on the thread, not only the account holder $SWITCH ON @{recipients} - direct:: You MAY state the delivery address. - copied:: You MUST NOT state the delivery address. </reply> <autoreply when=@{inbound} is equal to automated> @@ loop_gate: an auto-reply answered by an auto-reply is a mail loop - MUST call @[close_ticket]. </autoreply> $TEST two_questions - input:: Hi, where is order LH-20417? And can I still change the colour of the lamp on it? - expect:: - calls find_order for LH-20417 - answers every question in the email - keeps every paragraph to at most 3 sentences - contains "Larchwood Customer Care" $TEST no_number - input:: My rug arrived with a tear in it. What happens now? - expect:: - asks for the order number - contains "Larchwood Customer Care"
contains asserts are what hold it to the exact wording.$ npx typeglish check email.tg ✓ 1 file — 0 error, 0 warning, 0 info $ npx typeglish score email.tg email.tg — A (96/100) proven errors: none tiers: base+z3 planes runtime 95 (what the model reads) · hygiene 100 (source only) facets enforceability 88 x.21 · hardness 90 x.12 · directness 96 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 100 x.12 (hygiene) · style 100 x.08 · security 100 x.08 lever enforceability 88/100 (up to +3 overall) — Lead every rule with a modal — MUST / NEVER / SHOULD — or put it after THEN; one action and one object per line, a verb the checker knows or a bound ("at most 3 sentences"). Descriptive detail with no bound does not move this facet. `typeglish score <file>` prints the rules table with the edit per line; `typeglish --explain enforceability <file>` prints just that table. rules 6 of 8 rule-shaped lines read · 6/6 covered by $TEST L37 · arm direct:: You MAY state the delivery address. a $SWITCH / $IF arm body — selected at compile time; the checker does not read arm bodies as rules L38 · arm copied:: You MUST NOT state the delivery address. a $SWITCH / $IF arm body — selected at compile time; the checker does not read arm bodies as rules $ npx typeglish test email.tg --dry ✓ email.tg coverage: 6/6 rules exercised · two_questions — "Hi, where is order LH-20417? And can I still change the colo" (not run) ✓ contains "Larchwood Customer Care" · rubric — 3 prose expectations, judged on a live run (needs ANTHROPIC_API_KEY) · no_number — "My rug arrived with a tear in it. What happens now?" (not run) ✓ contains "Larchwood Customer Care" · rubric — 1 prose expectation, judged on a live run (needs ANTHROPIC_API_KEY) ✓ 1 prompt — 0 failed $ npx typeglish build email.tg --vars '{"inbound":"written","recipients":"direct"}' --out-dir d1 ✓ built d1/email.txt ← email.tg (a5d13edac99b, full) $ npx typeglish build email.tg --vars '{"inbound":"written","recipients":"copied"}' --out-dir d2 ✓ built d2/email.txt ← email.tg (613536913d92, full) $ npx typeglish build email.tg --vars '{"inbound":"automated","recipients":"direct"}' --out-dir d3 ✓ built d3/email.txt ← email.tg (923020d22e21, full) $ cat d3/email.txt <role> You are the email support assistant for Larchwood Home, an online homeware shop. </role> <context> Every Larchwood order has an order number. </context> <autoreply> - MUST call close_ticket. </autoreply>
auto-replied build in §4, which is the same file minus the one line that matters.Two honest limits on that run. First, the switch arms are listed as · arm in the rules table: the checker selects them at compile time and does not read their bodies as rules, which is why the score counts 6 of 8. The privacy rule is enforced by which artifact ships, not by a proof. Second, 6/6 rules exercised is a statement about wording, and this file shows how generous that can be. Delete the one expectation calls find_order for LH-20417 and coverage drops to 4/6, with both call @[find_order] and call @[close_ticket] listed as uncovered: one prose line about an order lookup was being credited to the auto-reply rule because the two share a verb. No case sends an out-of-office or a copied thread. The only checks that run with no model are the two contains asserts on the sign-off; everything else is judged on a live run. The automated and copied states are tested where they are decided, in the host.
§6Common questions
- How do I write a system prompt for an AI email support agent?
- Start from the fact that email gets one reply per message, not a conversation. The prompt needs a rule to answer every question in the email, a rule to ask for every missing detail in that same reply, a length bound on the paragraph rather than on the whole reply, and a fixed sign-off. Who is copied on the thread and whether the sender is a machine are facts the email platform already holds, so pass them in as typed inputs instead of asking the model to work them out.
- Why does my AI email agent reply to out-of-office messages?
- Because the helpdesk trigger runs on every inbound message, and whatever the model writes is sent. A rule like never reply to automatic replies asks the model to decline the one job it was invoked to do. Read the Auto-Submitted header from RFC 3834 in the host, map it onto a typed input, and give the automated state its own section that closes the ticket. Then check the mapping: in TypeGlish a value outside the declared domain still builds with exit 0, into an artifact with neither section in it.
- How long should an AI agent's email reply be?
- As long as the questions in the email need, and no longer per paragraph. A whole-reply cap copied from a chat prompt, such as at most 3 sentences, fights a rule to answer every question, and TypeGlish flags that pair as prompt/cap-tension. Bounding each paragraph instead, MUST keep every paragraph to at most 3 sentences, keeps the reply readable without truncating the third answer.
- Should an AI email agent reply-all when other people are copied?
- It usually has to, because the reply goes back on the thread, so the safe design is to change what the reply contains rather than who receives it. Pass whether anyone besides the account holder is on the thread as a typed input, and write the copied case as its own arm that withholds personal details such as the delivery address. The recipient list is a header the platform already parses, so it should never be a judgement the model makes from the email body.
Before the inbox goes live, build every state the host can produce and read the artifacts side by side: each member of each input, plus the values the host might send by mistake, such as the raw header spelling, an empty string, or the old name of a member someone renamed. typeglish build --vars writes each one to disk with its hash, so a state that silently drops a section shows up as a short file and a hash you have not seen before. The prompt can only prove what is in the file. The bag is the host's half, and the cheapest test of that half is reading what it builds.