← Tidelines/Guides

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.

by TypeGlish team8 min read#guides
One source. Three artifacts.

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.

agent.tg - the first draft✕ D (60/100)
# 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.
Four lines, four different ways of not deciding something.
tg check - output
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
"More formal" is the interesting one. Formality is a real axis and more is a real claim on it, but the prompt never says more than what, so there is nothing to compare against. That whole failure mode is the subject of the brand-voice rewrite, and it turns up in multilingual prompts constantly, because register really does differ between languages and people reach for adjectives to say so.

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.

agent.tg - the declared surface
<$CONFIG>
  $REQUIRE variable locale: one of en, fr, de
  $IMPORT tool lookup_policy, escalate_to_human
</$CONFIG>
Three languages, closed. Not 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.

agent.tg - the part that varies
@@ 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.
Three lines instead of three prompts. The compiler evaluates the switch, so the losing arms never reach the model: a French artifact does not contain the German rule, and cannot be talked into it.

§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.

agent.tg - two rules, no complaint✓ A (96/100)
# 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.
Clean, and an A. The two rules are different actions, write a reply and quote a disclaimer, so there is nothing for a prover to compare and no conflict to prove. It is right not to guess. That does not make the overlap go away.

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.

agent.tg - the exception, stated✓ compiles
# 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.
Two rules where there was one rule and a hole. The second is not a nicety: it is the line that converts "the disclaimer stays in English" from a customer-experience regression into a deliberate design, and it is the sort of rule that only gets written when someone is forced to look at the collision.

§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.

agent.tg - copy pasted straight from the CMS✕ 4 errors
# 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.
Two sentences of perfectly ordinary localized copy, four blocking errors.
tg check - output
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
Note what is not in that list. 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.

agent.tg - the wording as data✓ A (92/100)
# 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é.
```
The German line only needed its dashes turned into commas, which it should have been anyway. The French sentence became what it always was: a quotation, not an instruction, sitting in a zone that will not reformat it.

§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).

agent.tg - the finished spec✓ A (90/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
The handoff is a tool call rather than a description of one, which is what turns "the right team" into something a runtime can route: the locale travels with the call, so the German queue gets the German conversation. The $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.

tg build --vars - the French artifact
$ 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.
This is the whole deployable French prompt. No German rule, no English arm, no @@ 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.

tg check - after adding it to the locale domain✕ 1 error
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
A refusal to build, on the day the language was added, naming the place a decision is owed. Compare the alternative: a fourth locale flows through a prose conditional, matches nothing, and Italian customers get answered in English for a quarter before anyone files a ticket about it. Same mechanism as the multichannel bake-off, and it is the reason the domain was closed in §2 rather than left a string.

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.

tg test --dry - output
 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
Three of six rules covered by one case, which is honest rather than flattering: the disclaimer pair and the handoff have no case yet. That is the next hour's work, and it is a better use of it than adding a fourth adjective to the tone section.
Field note

$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 .tg with the locale declared as a typed input and the language-specific rules in a $SWITCH ON @{locale} gives you both, because typeglish 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, de and 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 German Schadensmeldung, 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, two structure/curly-quote and two structure/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.
∿ washed up Jul 27, 2026 ∿