Building a proactive outage agent, prompt-first
Every agent prompt on this blog so far has been written for something that answers. This one speaks first, which breaks two habits at once: there is no turn to react to, and there is no test that will compile without one.
TL;DR An outbound agent has no user turn, so $TEST refuses to compile without an - input:: line and the opening message is pinned by the artifact hash instead of by a case. What the compiler does referee is the brief's arithmetic: An outage takes 4 hours beside You MUST resolve an outage within 2 hours is 2 blocking logic/time errors and no build, and one message per outage is a policy only once it is written at most 1 message. Finished spec: 0 error, 0 warning, 0 info at A (95/100), three builds, three hashes.
Proactive contact is the cheapest thing a support org can do. A message that lands before the customer picks up the phone removes the call, and the message is short enough that nobody argues about automating it. Then somebody has to write the prompt, and the shape of the job turns out to be different from every inbound agent they have written before.
Here is the brief, six sentences from a head of CX at a regional broadband provider, quoted as received.
When we confirm a local outage, message the affected customers before they ring us. Say we know about it. An outage takes about four hours. Our SLA says we resolve an outage within two hours, so lead with that. One message per outage and one when it is fixed, nothing in between. If somebody replies asking for money off, do not promise anything, pass it to billing.
§1Type it in and read the refusal
The first move is always the same: put the brief in a file with no interpretation at all, so the report is about the brief rather than about your rewrite of it.
# Role You are an outbound notification agent for Northwind Broadband. # Constraints - When we confirm a local outage, you should probably message the affected customers before they ring us. - An outage takes 4 hours. - You MUST resolve an outage within 2 hours, so lead with that. - Keep it short and don't guess when service will be back. - One message per outage, and one when it is fixed, nothing in between. - If somebody replies asking for money off, don't promise anything and pass it to billing.
brief.tg:5:39 info prompt/hedging Hedging — "should probably" turns this instruction into a suggestion the model may skip. Delete the hedge, or commit to a modal (MUST / NEVER / SHOULD). brief.tg:5:1 info typeglish/if-then WHEN needs a THEN — write WHEN <condition> THEN <action>. brief.tg:6:1 error logic/time Conflicts with line 6. Time conflict — "takes 4 hours" and "within 2 hours" cannot both bound "outage · duration": the windows never intersect. brief.tg:7:1 error logic/time Conflicts with line 5. Time conflict — "takes 4 hours" and "within 2 hours" cannot both bound "outage · duration": the windows never intersect. brief.tg:9:1 info 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." brief.tg:10:1 info typeglish/if-then IF needs a THEN — write IF <condition> THEN <action>. ✗ 1 file — 2 error, 0 warning, 4 info
score reads F (52/100) with consistency 0 carrying the cap, and typeglish build brief.tg answers brief.tg refused, nothing written, so there is no artifact to deploy by accident.§2Two clocks nobody meant to write
The two errors are the interesting part, because neither of those lines is an instruction to the agent. Nothing in this system resolves an outage; engineers do. Both sentences arrived in the brief as context, and the compiler read them as two statements about one duration slot.
That reading comes from the frame that landed in 0.9.0: a sentence saying how long the work takes is typed time, so it meets a deadline about the same work and the intervals have to intersect. Four hours and within two hours do not, and the message says which slot they collided on, outage · duration.
Before leaning on that, find out how narrow it is. Three variants of the same brief, each with one word changed.
clocks/same-noun.tg:6:1 error logic/time Conflicts with line 6. Time conflict — "takes 4 hours" and "within 2 hours" cannot both bound "outage · duration": the windows never intersect. clocks/same-noun.tg:7:1 error logic/time Conflicts with line 5. Time conflict — "takes 4 hours" and "within 2 hours" cannot both bound "outage · duration": the windows never intersect. ✗ 4 files — 2 error, 0 warning, 16 info program: 4 independent files — no $IMPORT compositions
repair.tg says A repair takes 4 hours against the same SLA line: two nouns, two slots, no conflict. restore.tg keeps the noun and writes MUST restore an outage within 2 hours: only four completion verbs put a deadline on a duration slot, and restore is not one of them. widened.tg says within 8 hours, which contains four hours, so it is genuinely fine.Read that as a limit on the tool and a fact about the brief at the same time. The compiler caught this pair because the handover happened to use one noun twice. Rename half of it and the same disagreement ships. The catch was still worth having, because it forced the question nobody had asked: which of these two numbers is true, and does either belong in a message to a customer?
The answer to the second half is no, and it is the whole editorial decision of this build. A restoration estimate in the prompt is a number the model will repeat, and a customer hears a promise. So neither clock goes in the file. What goes in is the prohibition.
Context pasted into a prompt is not context. It is text the model will read out.
§3One message per outage is not yet a policy
The anti-spam line is the one the CX lead cares about most and the one the brief states least usefully. One message per outage and one when it is fixed, nothing in between is three claims in a sentence, and none of them is a count the file holds.
Written as a bound it becomes one rule with a number in it, and the number is what makes it survive contact with the next person to open the file.
cap/two-messages.tg:5:1 error logic/contradiction Conflicts with line 5. Counted rule — "send at most 1 message for each outage stage." vs "send at least 2 messages for each outage stage." is bound to at most 1 but at least 2. The counts cannot both hold. cap/two-messages.tg:5:1 error logic/action-count Conflicts with line 5. One action, two counts — "send at most 1 message" but "send at least 2 messages". A directive carries ONE count for "send messages for each outage stage" — state how many once. cap/two-messages.tg:5:1 error logic/numeric Conflicts with line 5. Numeric conflict — "at most 1 message" and "at least 2 messages" can't both hold. cap/two-messages.tg:6:1 error logic/contradiction Conflicts with line 4. Counted rule — "send at most 1 message for each outage stage." vs "send at least 2 messages for each outage stage." is bound to at most 1 but at least 2. The counts cannot both hold. cap/two-messages.tg:6:1 error logic/action-count Conflicts with line 4. One action, two counts — "send at most 1 message" but "send at least 2 messages". A directive carries ONE count for "send messages for each outage stage" — state how many once. cap/two-messages.tg:6:1 error logic/numeric Conflicts with line 4. Numeric conflict — "at most 1 message" and "at least 2 messages" can't both hold. ✗ 3 files — 6 error, 0 warning, 0 info program: 3 independent files — no $IMPORT compositions
You MUST send at most 1 message for each outage stage., a later rule saying at least 2 messages is six blocking errors on three codes, one set per participating line. The two silent files are daily.tg (MUST send a daily update for each outage stage) and progress.tg (MUST send an update whenever engineers make progress), which are the two edits somebody actually ships.Two silent out of three is not a great hit rate, and it is the honest number. A cap catches the edit that contradicts its arithmetic and not the edit that invents a second kind of message. Which is the argument for writing the bound rather than the word: without at most 1 in the file, all three of those edits land clean, which is the ablation we ran on exactly this question. With it, one of them cannot.
The word stage in that rule is doing real work too. The brief describes two moments, and the two moments are a closed set, so they become a typed input and one arm each.
§4The test you cannot write
Now the part that is specific to speaking first. Every inbound prompt on this blog ends with a $TEST pinning the behaviour that matters. The behaviour that matters here is the first message, and the first message answers nothing.
opening/empty-input.tg:7:1 error structure/bad-test $TEST first_message needs the user turn on the "- input::" line. opening/no-input.tg:7:1 error structure/bad-test $TEST first_message needs an "- input:: …" line. ✗ 2 files — 2 error, 0 warning, 0 info program: 2 independent files — no $IMPORT compositions
- input:: with nothing after it and the checker says the row needs the user turn. There is no third spelling and no empty-turn form.This is the right call by the language and an awkward one for this agent. A case is a turn plus an expectation; an unprompted message is an expectation with nothing to attach it to. So the opening does not get a test, and it needs a different kind of pin.
The first instinct is to put the approved sentence in the rule as a literal, which is where the build earns its place in the loop.
$ npx typeglish check quoted.tg ✓ 1 file — 0 error, 0 warning, 0 info $ npx typeglish build quoted.tg ✓ built .typeglish/dist/quoted.txt ← quoted.tg (c59443ad7f72, full) # Role You are an outbound notification agent for Northwind Broadband. # Constraints - You MUST open every message with Automated message from Northwind Broadband. - You MUST keep every message to at most 3 sentences.
A fence keeps the bytes. The approved line goes under # Output between triple backticks, the rule points at it, and the artifact carries it verbatim. Then the pin for the opening message is the hash: when legal changes a word, the hash changes, and that is the diff you review.
What the test suite covers instead is the only turn a customer starts, which is the reply asking for money off. That one has an input, so it has a case.
§5The finished spec
<$CONFIG> $REQUIRE variable stage: one of confirmed, resolved $IMPORT tool open_billing_case </$CONFIG> # Role @@ role: outbound notification for a broadband network, not a support agent You are an outbound notification agent for Northwind Broadband. # Constraints @@ disclosure: an unprompted message from a company says what it is before it says anything else - You MUST open every message with the exact line under # Output. @@ brevity: an unread notification is a wasted one - You MUST keep every message to at most 3 sentences. @@ cap: one message per stage is the whole anti-spam policy - You MUST send at most 1 message for each outage stage. @@ no_eta: a restoration time is an engineering estimate and a customer hears a promise - You NEVER state a restoration time. @@ no_credit: goodwill is a billing decision and this agent has no authority - You NEVER offer a credit. @@ reply_path: the compensation reply is the only turn this agent answers - WHEN a customer asks for compensation THEN you MUST call @[open_billing_case]. $SWITCH ON @{stage} - confirmed:: You MUST say that engineers are working on a fault. - resolved:: You MUST say that service is back and ask the customer to restart their router. # Output @@ opening: compliance signed off these bytes, so they ship as bytes The first line of every message, verbatim: ``` Automated message from Northwind Broadband. ``` $TEST compensation_reply - input:: This is the third outage this month. I want money off my bill. - expect:: - contains "billing" - matches /^(?!.*credit).*$/
$ npx typeglish check outage.tg ✓ 1 file — 0 error, 0 warning, 0 info $ npx typeglish score outage.tg outage.tg — A (95/100) proven errors: none tiers: base+z3 planes runtime 95 (what the model reads) · hygiene 95 (source only) facets enforceability 83 x.21 · hardness 100 x.12 · directness 94 x.08 · consistency 100 x.17 structure 100 x.12 (hygiene) · annotation 89 x.12 (hygiene) · style 100 x.08 · security 100 x.08 $ npx typeglish test outage.tg --dry ✓ outage.tg coverage: 1/6 rules exercised · compensation_reply — "This is the third outage this month. I want money off my bil" (not run) ✓ contains "billing" ✓ matches /^(?!.*credit).*$/ ✓ 1 prompt — 0 failed
enforceability 83 is the lever and it is honest: two of the six rules are prohibitions with no bound to give them, and one is a conditional, which is genuinely less enforceable than a count. coverage: 1/6 is the number this whole post is about. Five of the six rules govern a message nobody sent us, and the suite can only reach the one that answers.Last step, and for an outbound agent it is the one that matters most, because the deliverable is a message rather than a conversation. Build once per member of the domain and read what the model is actually handed.
$ npx typeglish build outage.tg --vars '{"stage":"confirmed"}' ✓ built .typeglish/dist/outage.txt ← outage.tg (f013a438153f, full) $ npx typeglish build outage.tg --vars '{"stage":"resolved"}' ✓ built .typeglish/dist/outage.txt ← outage.tg (8ff5552f3d89, full) $ npx typeglish build outage.tg ✓ built .typeglish/dist/outage.txt ← outage.tg (09af5e39a567, full) $ cat .typeglish/dist/outage.txt # Role You are an outbound notification agent for Northwind Broadband. # Constraints - You MUST open every message with the exact line under # Output. - You MUST keep every message to at most 3 sentences. - You MUST send at most 1 message for each outage stage. - You NEVER state a restoration time. - You NEVER offer a credit. - WHEN a customer asks for compensation THEN you MUST call open_billing_case. If stage is confirmed: You MUST say that engineers are working on a fault. If stage is resolved: You MUST say that service is back and ask the customer to restart their router. # Output The first line of every message, verbatim: ``` Automated message from Northwind Broadband. ```
stage hands the model a document telling it both that engineers are working on a fault and that service is back, in a message capped at three sentences. The two bound artifacts are identical to it except that the conditional line is replaced by the single sentence of its arm, and the fence comes through byte for byte in all three.§6Common questions
- How do I write a system prompt for an outbound or proactive AI agent?
- Write it as a message spec rather than a conversation spec, because there is no incoming turn to react to. Three things change against an inbound prompt. The opening text is fixed copy somebody signed off, so it belongs in a code fence and is regression-tested by the artifact hash, not by a
$TEST. The contact frequency is a real policy and has to be a number, since one message per outage is silent andMUST send at most 1 message for each outage stagerefuses a later daily update rule. And the only turn you can actually test is the reply, so the suite covers the reactive half and the coverage number says so. - Why does my $TEST fail to compile with structure/bad-test?
- Because a
$TESTcase is a user turn plus expectations, and it will not compile without the turn. Leaving the row out entirely reports $TEST first_message needs an "- input:: …" line, and writing a bare- input::with nothing after it reports $TEST first_message needs the user turn on the "- input::" line. Both are blocking errors, so the prompt does not build either. For an agent whose first message is unprompted there is no honest input to supply, which is the real answer: that message is pinned by the artifact, and the test suite covers the turns a customer starts. - How do I stop a notification agent from promising when service will be back?
- Write the prohibition and keep the estimate out of the file entirely. A restoration time in the prompt is a number the model will repeat, and the two numbers in a typical outage brief do not even agree: An outage takes 4 hours beside You MUST resolve an outage within 2 hours is 2 blocking
logic/timeerrors reading the windows never intersect, andtypeglish buildrefuses to write an artifact. The boundary is narrow, so do not rely on it: rename the noun to a repair and the same pair is silent, and swap resolve for restore and it is silent too. - How do I keep an exact, compliance-approved sentence intact in a compiled prompt?
- Put it in a triple-backtick fence and reference the fence from the rule. A straight double quote binds a string literal and the quotes strip on compile, so a rule reading open every message with "Automated message from Northwind Broadband." reaches the model as open every message with Automated message from Northwind Broadband., with no boundary left between the instruction and the copy. A fenced block passes through byte for byte, the rule points at it, and the artifact hash is what you diff when legal changes a word.
The line in the brief that took the longest to write down was the one that is not in the file. Say we know about it is the whole emotional point of a proactive message and it is not a rule: it has no count, no clock and no object a checker can hold, and the two arms of the switch are the closest thing to it that survives. That is not a failure of the toolchain, it is where the toolchain stops. The compiler settled the two clocks, refused the widening edit to the cap, and told us twice that a test needs a turn. What it never had an opinion about is whether an outage message reads like a company that noticed. Somebody still has to read the artifact out loud.