← Tidelines/Guides

Building an SMS support agent, prompt-first

SMS is the one channel where the reply has a hard, billed, countable limit. That should make it the easiest prompt to specify. It makes it the easiest one to specify wrong.

by TypeGlish team10 min read#guides
One segment. 160 characters.

TL;DR On SMS the budget is the spec, so write it as a bound and not an adjective: at most 160 characters is a slot the prover holds and a second opinion about it cannot compile, while keep replies short, it is SMS is 1 error, 1 warning, 2 info at C (65/100). The trap is the character set: a curly apostrophe in a rule is structure/curly-quote and blocking under --strict, and the same apostrophe inside a fence or a quoted literal is 0 error, 0 warning, 0 info and reaches the model byte-identical, which is precisely where your approved copy lives.

Every other channel lets you be vague about length and get away with it. A chat reply that runs long is untidy; an SMS reply that runs long is a second segment, a second charge, and on some carriers a message that arrives out of order. The budget is not a style preference on this channel, it is the interface. Here is the brief a stakeholder actually sends, and what happens when you make the compiler read it.

§1The brief, checked as written

Pelham Mobile, a mobile network, is turning on SMS support. Seven lines arrive in a message: keep replies short because it is SMS, be friendly but efficient, always tell people they can reply STOP, look up the account if they ask about a bill, do not send links unless you have to, and escalate anything about a lost or stolen handset straight away. Typed out as a prompt, that is a complete document and it is nearly a spec.

brief.tg - the seven lines as sent✗ C (65/100)
# Role
You are an SMS support agent for Pelham Mobile, a mobile network.

# Constraints
- Keep replies short, it is SMS.
- Be friendly but efficient.
- Always tell the customer they can reply STOP.
- If a customer asks about a bill, look up the account.
- Do not send links unless you have to.
- Escalate anything about a lost or stolen handset straight away.
Nobody would call this a bad brief. It names six real requirements and it is shorter than most of what ships.
tg check and tg score on the brief - output✗ 1 error, 1 warning, 2 info
$ npx typeglish check brief.tg
brief.tg:5:1  error  prompt/undefined-adjective  "sms" is not in the vocabulary — the claim is INVISIBLE to the checker. Define it:
$CONFIG adjective sms
  - axis:: <name>
  - opposite:: <pole>
…or relax the dial ($CONFIG lexicon → undefined_adjectives).
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).
brief.tg:8:1  info   typeglish/if-then  IF needs a THEN — write IF <condition> THEN <action>.
brief.tg:8:1  info   prompt/unintroduced-definite  "the account" retrieves something this document never introduces - a model must guess which account is meant. Introduce it on another line ("You manage an account.") or name it outright.

 1 file — 1 error, 1 warning, 2 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 45 (source only)
  facets  enforceability 45 x.21 · hardness 100 x.12 · directness 83 x.08 · consistency 100 x.17 · structure 89 x.12 (hygiene) · annotation 0 x.12 (hygiene) · style 0 x.08 · security 100 x.08
The one blocking error is on the word SMS. It is SMS puts sms in a claim seat, where an adjective has to sit on a named axis to mean anything, so the line reads as a claim the checker cannot hold: the claim is INVISIBLE to the checker. Which is an unhelpfully literal way of saying the thing you most wanted to specify is the thing that specified nothing.

Read the silences instead. Three lines drew no finding at all: always tell the customer they can reply STOP, do not send links unless you have to, and escalate anything about a lost or stolen handset straight away. Two of those three are the legally interesting lines in the file. Nothing is wrong with them as English, which is exactly why nothing fires.

§2The budget is a number, so write the number

One GSM-7 segment is 160 characters. The first replacement is mechanical: short becomes a count, and the count goes where the prover can reach it.

What that buys is not obedience. A model is not physically prevented from writing 400 characters by a line of text telling it not to, and the checker never claims otherwise. What the bound buys is that the file can now disagree with itself out loud. Marketing has a winback template bought on two segments, somebody adds the corresponding line, and the two budgets meet.

budgets.tg - two budgets, one slot✗ 2 error
<$CONFIG>
  $CONFIG modality sms
</$CONFIG>

# Role
@@ role: inbound SMS desk for a UK mobile network, one segment per reply
You are an SMS support agent for Pelham Mobile, a mobile network.

# Constraints
@@ budget: a GSM-7 segment is 160 characters and the second segment is a second charge
- You MUST keep every reply to at most 160 characters.
@@ campaign_budget: marketing buys two segments on the winback template
- You MUST keep every reply to at most 306 characters.
Both lines are true of something. Neither is true of the same agent.
tg check budgets.tg - output✗ 2 error
$ npx typeglish check budgets.tg
budgets.tg:11:1  error  logic/quantifier-strength  Conflicts with line 8. One rule, two counts — "keep every reply to at most 160 characters." already sits inside "keep every reply to at most 306 characters." (per reply); the looser bound is dead weight. Keep the tighter line.
budgets.tg:13:1  error  logic/quantifier-strength  Conflicts with line 7. One rule, two counts — "keep every reply to at most 160 characters." already sits inside "keep every reply to at most 306 characters." (per reply); the looser bound is dead weight. Keep the tighter line.

 1 file — 2 error, 0 warning, 0 info
Two counts, one slot, one of them dead weight. The same two lines written as keep replies short and a winback can run long are 0 error and both ship, and the desk finds out which one won from the carrier invoice.

Characters and sentences are separate slots, so at most 160 characters and at most 2 sentences sit together happily, which is what you want: one bound for the billing unit and one for the shape. Be honest about the limit of all this, though. A 160-character budget next to a rule requiring the opt-out line in every reply and the balance in every reply is 0 error: the checker compares bounds on a slot, and the arithmetic of what actually fits inside 160 characters is not a proof it performs. The budget it will defend is the one you wrote, not the one that is left.

On SMS the spec is a number. Anything you write instead of the number is decoration.

§3The character set is a billing constraint here

This is the section that is specific to this channel, and it is the one worth the read. TypeGlish requires plain ASCII on the instruction plane for its own reasons: a curly apostrophe hides a possession from the parser, an en dash hides a range. On SMS the same rule has a second, unrelated, much more expensive justification. A GSM-7 segment is 160 characters. One character outside that alphabet re-encodes the whole message as UCS-2, and a single-segment message becomes 70 characters. Not the character: the message.

prose.tg - a curly apostrophe on a rule✗ 1 warning
<$CONFIG>
  $CONFIG modality sms
</$CONFIG>

# Role
@@ role: inbound SMS desk for a UK mobile network, one segment per reply
You are an SMS support agent for Pelham Mobile, a mobile network.

# Constraints
@@ budget: a GSM-7 segment is 160 characters and the second segment is a second charge
- You MUST keep every reply to at most 160 characters.
@@ balance_reply: the amount is the whole answer, so it leads
- You MUST open a billing reply with the customer’s balance.
One curly apostrophe, pasted in from a doc, on the second constraint.
tg check, default then --strict - output✗ strict: 1 error
$ npx typeglish check prose.tg
prose.tg:13:50  warn   structure/curly-quote  A curly apostrophe is a lookalike: the parsers read ASCII ', so possession and contractions written with it are invisible to the consistency checker. Straighten it.

 1 file — 0 error, 1 warning, 0 info

$ npx typeglish check prose.tg --strict
prose.tg:13:50  error  structure/curly-quote  A curly apostrophe is a lookalike: the parsers read ASCII ', so possession and contractions written with it are invisible to the consistency checker. Straighten it.

 1 file — 1 error, 0 warning, 0 info
Column 50, which is the apostrophe. On any other channel this is a hygiene warning you might leave for later. On SMS, check --strict is a usable encoding gate, and it is the cheapest one you will find: it runs offline, it names the column, and it blocks the build.

Now the hole. Compliance-approved SMS copy does not get retyped into a rule. It gets pasted, verbatim, into the one construct that guarantees it reaches the model unaltered, which is a literal zone: a triple-backtick fence, a quoted "literal", or an <examples> section. Those zones exist precisely so the compiler keeps its hands off. It keeps its hands off the encoding too.

footer.tg - the same apostrophe inside a fence✓ 0 finding
<$CONFIG>
  $CONFIG modality sms
</$CONFIG>

# Role
@@ role: inbound SMS desk for a UK mobile network, one segment per reply
You are an SMS support agent for Pelham Mobile, a mobile network.

# Constraints
@@ ascii: one non-GSM character re-encodes the message and the budget drops to 70
- You MUST NOT use a curly apostrophe in a reply.

# Output
@@ optout_copy: the wording legal signed off, pasted from the template library
Append this footer to every reply:

```
Your bill’s ready. Reply STOP to opt out.
```
A rule forbidding curly apostrophes, and a curly apostrophe fourteen characters below it, in the part of the file that actually ships.
tg check --strict, then the artifact bytes - output
$ npx typeglish check footer.tg quoted.tg --strict
 2 files — 0 error, 0 warning, 0 info

$ npx typeglish build footer.tg --out-dir dist
 built dist/footer.txt ← footer.tg (5ddae845d797, full)

$ tail -4 dist/footer.txt

```
Your bill’s ready. Reply STOP to opt out.
```
quoted.tg is the same copy written the other legal way, as a "literal" on a MUST line, and it is equally silent. Two files, --strict, 0 error, 0 warning, 0 info, and the artifact carries the apostrophe through unchanged. The rule on line 11 and the footer on line 18 contradict each other, and only one of them was ever read by anything that could tell. Same asymmetry as the bake-off on rules versus examples, with a carrier invoice attached.

So the encoding check on your template library is yours to build. It is three lines of shell against the artifact, and the reason it has to run on the artifact rather than the source is everything above: the source is where the compiler was told to stop looking. The multilingual case is the same problem with accents instead of apostrophes, covered in building a multilingual support agent.

§4Who is texting you is not a judgement

An inbound SMS arrives with a phone number and nothing else. Whether that number belongs to an account is a lookup the host does before the model is invoked, and the brief's look up the account if they ask about a bill quietly assumes the answer is always yes. It is a typed input with two members, and the branch belongs to the compiler.

sender.tg - the gate, with the wrong member name✗ 1 info
<$CONFIG>
  $REQUIRE variable sender: one of linked, unknown
</$CONFIG>

# Role
@@ role: inbound SMS desk for a UK mobile network, one segment per reply
You are an SMS support agent for Pelham Mobile, a mobile network.

# Constraints
@@ sender_gate: an SMS arrives from a phone number, and the host decides whose it is
$SWITCH ON @{sender}
  - linked:: You MUST answer an account question from the balance on file.
  - unknown:: You MUST NOT state an account balance.
Correct shape, and it still draws a finding: prompt/unregistered-doer at line 13, a bare generic doer never enters the world model. The finding is not about the rule, it is about the key. An arm key is an English word, and unknown reads as a noun. Rename the member to unlinked and the file is 0 error, 0 warning, 0 info with nothing else changed.

§5The spec, and the one number the tests do not move

Everything above, assembled. The three silent lines from the brief are the ones that changed most: always tell them they can reply STOP became a quoted literal, because the wording is a regulator's and it costs 22 of the 160 characters; do not send links unless you have to lost its escape hatch and became a flat prohibition, which is a decision somebody has to actually make rather than defer; and escalate straight away became a tool call with a delay adverb the time layer reads.

sms.tg - the shipped spec✓ A (95/100)
<$CONFIG>
  $CONFIG modality sms
  $IMPORT tool get_balance, report_lost_handset
  $REQUIRE variable sender: one of linked, unlinked
</$CONFIG>

# Role
@@ role: inbound SMS desk for a UK mobile network, one segment per reply
You are an SMS support agent for Pelham Mobile, a mobile network.

# Constraints
@@ budget: a GSM-7 segment is 160 characters and the second segment is a second charge
- You MUST keep every reply to at most 160 characters.
@@ sentences: two sentences is what fits once the opt-out line is in
- You MUST keep every reply to at most 2 sentences.
@@ ascii: one non-GSM character re-encodes the message and the budget drops to 70
- You MUST NOT use a curly apostrophe in a reply.
@@ no_links: a shortlink costs 23 characters and some carriers filter the message
- You MUST NOT include a URL in a reply.
@@ optout: the opt-out wording is a regulator's line, not ours, and it is 22 characters
- You MUST end every reply with "Reply STOP to opt out."
@@ lost_handset: a lost handset is a fraud clock and it does not wait for a reply
- WHEN a customer reports a lost handset THEN you MUST call @[report_lost_handset] immediately.

@@ sender_gate: an SMS arrives from a phone number, and the host decides whose it is
$SWITCH ON @{sender}
  - linked:: You MUST answer an account question from @[get_balance].
  - unlinked:: You MUST NOT state an account balance.

$TEST balance_linked
  - input:: How much do I owe this month?
  - expect::
    - at most 160 characters
    - at most 2 sentences
    - contains "STOP"

$TEST lost_handset
  - input:: I lost my phone on the train this morning.
  - expect::
    - at most 160 characters
    - contains "STOP"
Six rules, one gate, two cases. immediately is not decoration: the time layer lowers it to a zero-length interval, so a later rule saying report a lost handset within 5 minutes is a blocking logic/time-strength conflict rather than a second opinion. Straight away lowers the same way, which is one of the few places the brief's own wording survived intact.
tg check --strict, score, test --dry, build - output✓ A (95/100)
$ npx typeglish check sms.tg --strict
 1 file — 0 error, 0 warning, 0 info

$ npx typeglish score sms.tg
sms.tg — A (95/100)  proven errors: none  tiers: base+z3
  planes  runtime 93 (what the model reads) · hygiene 100 (source only)
  facets  enforceability 77 x.21 · hardness 100 x.12 · directness 97 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 100 x.12 (hygiene) · style 100 x.08 · security 100 x.08

$ npx typeglish test sms.tg --dry
 sms.tg  coverage: 0/6 rules exercised
  · balance_linked — "How much do I owe this month?" (not run)
       at most 160 characters
       at most 2 sentences
       contains "STOP"
  · lost_handset — "I lost my phone on the train this morning." (not run)
       at most 160 characters
       contains "STOP"
 1 prompt — 0 failed

$ npx typeglish build sms.tg --vars '{"sender":"linked"}' --out-dir d1
 built d1/sms.txt ← sms.tg (2177ebd9bd14, full)

$ npx typeglish build sms.tg --vars '{"sender":"unlinked"}' --out-dir d2
 built d2/sms.txt ← sms.tg (3bb2f6edad22, full)

$ npx typeglish build sms.tg --out-dir d3
 built d3/sms.txt ← sms.tg (2c4c6997fb07, full)
Two states, two artifacts, 489 bytes and 474 bytes, plus a 572-byte template build that ships both arms as conditional prose for a host that binds the variable itself. --strict passes, which on this channel means the instruction plane is GSM-7 clean.

Then read the coverage line, because it is the one number in the run that did not move: coverage: 0/6 rules exercised, with both cases passing every assert. Nothing is broken. A length assert is a claim about the reply, not about any particular rule, so the coverage counter has nothing to attribute and says so. It is worth knowing how easy the number is to fake: adding one prose expectation, the reply reports the lost handset and does not include a URL, takes the same file from 0/6 to 6/6 while adding zero deterministic checks, because a prose expectation is graded by a judge and credited broadly. The honest reading of 0/6 is that the tests here pin the channel contract and not the policy, which for a first week of SMS is a reasonable place to be, as long as nobody reports the 6/6.

§6Common questions

How do I write a system prompt that keeps an SMS reply under 160 characters?
Write the budget as a bound, not as an adjective. You MUST keep every reply to at most 160 characters. is a slot the prover holds, so a second rule about the same slot is caught: at most 160 characters beside at most 306 characters is 2 blocking logic/quantifier-strength errors reading the looser bound is dead weight. Keep the tighter line. Keep replies short, it is SMS. is not a bound at all, and on the checker it is worse than nothing, because it is 1 blocking prompt/undefined-adjective error on the word sms. Then pin it in a test: at most 160 characters is a deterministic assert that runs with no model call.
Does the character limit in a system prompt actually constrain the model?
Not by itself, and the checker is careful not to claim it does. What the bound buys you is three things a prose instruction does not: a second rule that disagrees about the budget cannot compile, the at most 160 characters assert can fail a test case offline, and the number appears in the artifact as a countable value rather than an adjective. The checker also cannot do the arithmetic of what fits: a 160-character budget beside a rule requiring both the opt-out line and the account balance in every reply is 0 error, because addition of that kind is not a proof it performs.
Will TypeGlish catch a character that breaks GSM-7 encoding in my SMS copy?
In a rule's own prose, yes. A curly apostrophe on a constraint line is structure/curly-quote, a warning by default and a blocking error under check --strict, which makes --strict a usable GSM-7 gate for the instruction plane. In a literal zone it is invisible: the same curly apostrophe inside a triple-backtick fence, inside a quoted "literal", or inside an <examples> section is 0 error, 0 warning, 0 info, and it reaches the artifact byte-identical. That is exactly where marketing-approved SMS copy gets pasted, so the encoding check on your template library has to be your own.
Should the sender's identity be a judgement in the prompt or an input?
An input. An inbound SMS carries a phone number, and whether that number is on an account is a host lookup, not something a model can decide from the message. Declare it as $REQUIRE variable sender: one of linked, unlinked and branch with $SWITCH ON @{sender}, which gives you two artifacts, 2177ebd9bd14 and 3bb2f6edad22, one per state, and makes a third state a compile error rather than an unhandled case. Pick the member names carefully: the same file with unknown instead of unlinked adds a prompt/unregistered-doer finding, because an arm key is read as English.
Field note

The thing that makes SMS a good teacher is that the constraint is billed. On chat you can argue about whether a four-sentence reply is too long, and the argument never resolves, because nothing outside the room disagrees with either side. On SMS the second segment turns up on an invoice with a number next to it, and a curly apostrophe pasted from a slide deck can more than halve your budget on every message it touches, silently, for as long as it takes somebody to look at the bytes. That is why the 160 stays a literal in the file rather than becoming a tidy @{segment_budget}: a number the compiler can read is a number your prompt can be held to, which is the whole of the bake-off on where a policy number lives. The budget is the one requirement on this channel that nobody will have to interpret, so it is the one requirement it would be embarrassing to leave as an adjective.

∿ washed up Sep 12, 2026 ∿