Building a multilingual support agent, prompt-first
Three languages, one policy set, and a legal team that will only sign off on English disclaimers. The brief is four sentences long. Almost all of the work is deciding which parts of the prompt genuinely vary by language and which parts only look like they do.
TL;DR Do not ask the model to detect the language and translate: declare the locale as a typed input, put the small number of genuinely language-specific rules in a $SWITCH ON @{locale}, and build one artifact per language, so adding a fourth language becomes a compile error in every place that needs a decision instead of a silent gap in production.
Here is the brief, as handed over. "Our chat agent needs to work in English, French, and German. The policies are the same everywhere. Legal says the cover disclaimers have to stay in English, word for word. Don't make the French and German experience worse than the English one." The company sells bike insurance across Europe. That is everything anyone wrote down, and it is roughly what a real brief looks like.
§1The draft that brief produces
Left alone with those four sentences, almost everyone writes the same first draft. It is not stupid. It is a faithful transcription of the brief into instructions, which is exactly the problem: the brief was a summary of intent, and a prompt has to be a specification.
# Role You are a support agent for a European bike-insurance company. # Constraints - You SHOULD try to reply in the customer's language where possible. - You MUST be more formal with German customers. - Disclaimers should generally stay in English. - WHEN you cannot help THEN hand off to the right team.
agent.tg:5:14 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). agent.tg:5:54 info prompt/hedging Hedging - "where possible" turns this instruction into a suggestion the model may skip. agent.tg:6:1 error prompt/undefined-adjective "more" claims on an UNNAMED axis - the checker holds it with one hand. Define it: $CONFIG adjective more - axis:: <name> - opposite:: <pole> …or relax the dial ($CONFIG lexicon → undefined_adjectives). agent.tg:6:1 warn prompt/unmeasurable Unmeasurable rule - no observable action or bound, so nothing can check compliance. ✗ 1 file - 1 error, 1 warning, 2 info
Underneath the diagnostics there are three decisions the draft has quietly avoided: who decides what language the reply is in, what happens to the English-only disclaimer inside a French reply, and what "the right team" means when the queue that speaks German is a different queue. Take them in that order.
§2The locale is an input, not a guess
"Reply in the customer's language" makes the model responsible for a decision that has already been made somewhere else. Your chat widget knows what locale the page was served in. Your IVR knows what the caller pressed. Your CRM knows what the account is set to. Asking the model to re-derive that from the first message is a guess, and it is wrong precisely where it hurts: on a two-word opening message, on a name that looks German, on a customer who writes to you in English out of politeness and would rather be answered in French.
So declare it. A typed input with a closed domain gives the value a name, a fixed set of legal values, and a compile-time home.
<$CONFIG> $REQUIRE variable locale: one of en, fr, de $IMPORT tool lookup_policy, escalate_to_human </$CONFIG>
string: a typo in a free-text locale compiles clean and routes silently, which is the whole argument of the typed-inputs bake-off. Here the closed domain buys something extra, and §5 is about collecting it.With the value declared, the next question is what actually depends on it. This is where multilingual prompts go wrong in the other direction: people duplicate the entire prompt three times because three languages feel like three agents. They are not. The refund policy is the same in Lyon and Leipzig. What differs is small, and worth writing down explicitly: the language of the reply, and the formality register that language expects. French support writing takes vous. German takes Sie. English takes neither, which is why an English-speaking prompt engineer usually forgets that the choice exists.
@@ reply_language: the locale is known up front, so ship one language per artifact $SWITCH ON @{locale} - en:: You MUST write every reply in English. - fr:: You MUST write every reply in French, addressing the customer as vous. - de:: You MUST write every reply in German, addressing the customer as Sie.
§3The conflict the checker will not settle for you
Now the disclaimer. The brief says replies are in the customer's language and disclaimers are in English, and those two sentences are perfectly happy in a requirements document, because a human reading them knows which one is the exception. Put them in a prompt side by side and see what a compiler makes of it.
# Role @@ role: name the domain so "helpful" has a scope You are a support agent for a European bike-insurance company. # Constraints @@ reply_language: reply in the language the customer opened in - You MUST write every reply in French. @@ disclaimer_english: legal signs off on the English wording only - You MUST quote a policy disclaimer in English, word for word.
This is the species of contradiction we called a scope overlap back in your prompt argues with itself: two rules that are individually fine and collide only on certain inputs. It stays invisible until a French customer asks a question whose answer carries a disclaimer, and then the model has to invent a policy on the spot. It might quote the English block bare, which reads as a bug to the customer. It might translate the disclaimer, which is the one thing legal asked you not to do. It might do one on Tuesday and the other on Wednesday.
Nothing will decide this for you, so decide it, and write the decision down as rules rather than as an implicit exception.
# Constraints @@ disclaimer_english: legal signs off on the English wording only - You MUST quote a policy disclaimer in English, word for word. @@ disclaimer_gloss: an untranslated block of English is where a chat gets abandoned - You MUST follow a quoted disclaimer with a one-sentence summary in the reply language.
§4Localized copy is data, not instruction
The moment a prompt goes multilingual, localized strings start arriving: approved wordings, product names, the exact sentence the compliance team blessed in March. They arrive by copy and paste, out of a CMS or a spreadsheet or a translation tool, and they bring their typography with them.
# Role @@ role: name the domain so "helpful" has a scope You are a support agent for a European bike-insurance company. # Constraints @@ theft_cover: the wording legal approved for France - You MUST tell a French customer that le vol n’est couvert qu’avec un antivol homologué. @@ claim_form: the German claim form has a name and customers search for it - You MUST offer the Schadensmeldung – the claim form – on the first turn.
agent.tg:7:48 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. agent.tg:7:63 error structure/curly-quote A curly apostrophe is a lookalike. agent.tg:9:37 error structure/em-dash An em-dash splices two statements into one line: the checker reads one unparseable blob, and models mirror the tic (the canonical AI-tell). agent.tg:9:54 error structure/em-dash An em-dash splices two statements into one line. ✗ 1 file - 4 error, 0 warning, 0 info
homologué passes. Schadensmeldung passes. Accented letters are not the problem and never were: the ASCII plane is about typographic punctuation, the marks that hide meaning from a parser reading ASCII English. The AI-written-prompt teardown hit the same wall from the other direction.The fix is a separation worth internalizing beyond this one file. A rule is an instruction, written in the compiler's plane, in the language the compiler reads. An approved wording is data, and data belongs in a literal zone where the compiler stops parsing and starts copying. A code fence is the simplest one, and its contents reach the model byte for byte.
# Role @@ role: name the domain so "helpful" has a scope You are a support agent for a European bike-insurance company. # Constraints @@ theft_cover: legal signed off on this exact French wording, so quote it - You MUST quote the theft wording in the Output section to a French customer, unchanged. @@ claim_form: the German claim form has a name and customers search for it - You MUST offer the Schadensmeldung, the German claim form, on the first turn. # Output ``` Le vol n’est couvert qu’avec un antivol homologué. ```
§5Three artifacts from one file, and the fourth language
Assembled, the spec is one file with a declared surface, one switch over the locale, five rules that are the same in every language, and a case. It compiles clean and scores A (90/100), up from the first draft's D (60/100).
<$CONFIG> $REQUIRE variable locale: one of en, fr, de $IMPORT tool lookup_policy, escalate_to_human </$CONFIG> # Role @@ role: name the domain so "helpful" has a scope You are a support agent for a European bike-insurance company. # Constraints @@ reply_language: the locale is known up front, so ship one language per artifact $SWITCH ON @{locale} - en:: You MUST write every reply in English. - fr:: You MUST write every reply in French, addressing the customer as vous. - de:: You MUST write every reply in German, addressing the customer as Sie. @@ policy_lookup: cover varies by country, so never answer cover from memory - You MUST call @[lookup_policy] before stating what a policy covers. @@ disclaimer_english: legal signs off on the English wording only - You MUST quote a policy disclaimer in English, word for word. @@ disclaimer_gloss: an untranslated block of English is where a chat gets abandoned - You MUST follow a quoted disclaimer with a one-sentence summary in the reply language. @@ human_handoff: a queue that does not speak the language is a dead end - WHEN a customer asks for a human THEN you MUST call @[escalate_to_human]. @@ brevity: chat replies past four sentences get skimmed - You MUST reply in at most 4 sentences. $TEST cover_question_fr - input:: Est-ce que mon assurance couvre le vol du vélo devant chez moi? - expect:: - replies in French - does not state what the policy covers without calling lookup_policy
$TEST is written in French on purpose, because a case in English tells you nothing about the artifact you are actually shipping to French customers.Now build it. The switch is compile-time, so passing the locale in resolves it and the artifact contains exactly one language.
$ typeglish build agent.tg --vars '{"locale":"fr"}'
✓ built .typeglish/dist/agent.txt ← agent.tg (c68b5691db19, full)
$ cat .typeglish/dist/agent.txt
# Role
You are a support agent for a European bike-insurance company.
# Constraints
You MUST write every reply in French, addressing the customer as vous.
- You MUST call lookup_policy before stating what a policy covers.
- You MUST quote a policy disclaimer in English, word for word.
- You MUST follow a quoted disclaimer with a one-sentence summary in the reply language.
- WHEN a customer asks for a human THEN you MUST call escalate_to_human.
- You MUST reply in at most 4 sentences.
@@ notes, no $TEST, no <$CONFIG>. Run it again with de and en and you have three artifacts and still one file to maintain.Then the part that pays for the whole design. Six months from now someone sells a policy in Italy, and the change starts with one word in the domain.
agent.tg:12:1 error structure/non-exhaustive-switch @{locale} can be it, but no arm handles it - add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback). ✗ 1 file - 1 error, 0 warning, 0 info
Finish with the offline pass. test --dry validates the suite and reports coverage without sending anything to a model, which is what you want in CI on every locale you build.
✓ agent.tg coverage: 3/6 rules exercised · cover_question_fr - "Est-ce que mon assurance couvre le vol du vélo devant chez m" (not run) ✓ rubric 0.00 - not run (--dry) ✓ 1 prompt - 0 failed
$SWITCH ON a typed domain is total by construction: coverage is proven rather than reviewed, which is why adding it in §5 fails loudly at the one place a decision is owed. If you want the same guarantee at section scale, so an entire block of guidance appears only for some locales, that is what a when= guard on a tag section does, and turning parts of your prompt on and off walks through it. For the sibling build in a single language, see building a refund agent, prompt-first.
FAQCommon questions
- Should I write one system prompt per language or one prompt for all languages?
- Write one source file and build one artifact per language. Copies drift: a policy change lands in three files and misses the fourth. A single
.tgwith the locale declared as a typed input and the language-specific rules in a$SWITCH ON @{locale}gives you both, becausetypeglish build --vars '{"locale":"fr"}'resolves the switch at compile time and writes a prompt that contains only the French arm. The model never sees the other languages, and you never maintain a second copy of the refund policy. - Should the AI agent detect the customer's language itself?
- Usually not. Your chat widget, IVR, or CRM already knows the locale, so pass it in as a declared input rather than asking the model to infer it from the first message. Detection is a guess that can be wrong on short messages, code-switching, and place names, and every language-specific rule downstream inherits that error. Declare it as
$REQUIRE variable locale: one of en, fr, deand the value is fixed before the first turn. - How do I keep legal disclaimers in English when the agent replies in another language?
- State the carve-out explicitly in the prompt, because no checker will settle it for you. "You MUST write every reply in French" and "You MUST quote a policy disclaimer in English, word for word" both compile clean and score A (96/100): they are two different actions, so nothing proves they conflict, and the model resolves the overlap fresh each conversation. Write one rule that quotes the approved English wording verbatim and a second that requires a one-sentence summary in the reply language, so the exception is a rule rather than a collision.
- Can a TypeGlish prompt contain accented characters or non-English text?
- Accented letters are fine: a rule mentioning
homologuéwith an acute accent, or the GermanSchadensmeldung, compiles clean. What the ASCII plane rejects is typographic punctuation, and localized copy pasted out of a CMS is full of it. In the worked example one French sentence and one German sentence produced four blocking errors, twostructure/curly-quoteand twostructure/em-dash, because the copy arrived with typographic apostrophes and en dashes. Straighten the punctuation, or put verbatim approved wording inside a code fence, where it is treated as literal content and reaches the model byte for byte.