> ## Documentation Index
> Fetch the complete documentation index at: https://botmesh.afaya.com.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Internationalization (i18n)

> @botmesh/core ships a small i18n layer: register a message tree per locale with bot.locale(), then translate with ctx.t() in handlers and c.t() in conversations. The language is resolved for every message, so each user gets replies in their own language.

```ts theme={null}
bot.locale('en', {
  greeting: 'Hello, {name}!',
  menu: { title: 'Main menu' },
  cart: { zero: 'Your cart is empty', one: '{count} item', other: '{count} items' },
})
bot.locale('es', {
  greeting: '¡Hola, {name}!',
  menu: { title: 'Menú principal' },
  cart: { zero: 'Carrito vacío', one: '{count} producto', other: '{count} productos' },
})
bot.locale({ defaultLocale: 'en' })

bot.when('hello', (ctx) => ctx.reply(ctx.t('greeting', { name: ctx.senderInfo?.displayName ?? '' })))
bot.when('cart', (ctx) => ctx.reply(ctx.t('cart', { count: 3 })))
```

## Messages

* Keys **nest** (`ctx.t('menu.title')`); a key may also contain dots
  literally (`{ 'menu.title': '…' }`). Registering the same locale again
  deep-merges.
* `{param}` placeholders are interpolated; unknown placeholders stay as they are.
* A leaf `{ one, other, zero?, two?, few?, many? }` is a **plural form**,
  picked from `params.count` with `Intl.PluralRules`. `zero` also matches an
  exact `count === 0` in languages without a zero category.
* A missing key falls back to the base language (`pt-BR` → `pt`), then to
  the default locale, then to the key itself (logged at debug level).

## How the language is resolved

Inside the per-chat queue, before the `before('message')` hooks (see
[the inbound pipeline](https://botmesh.afaya.com.co/guide/concepts.md#the-inbound-pipeline)), `ctx.lang` is set
to the first candidate a **registered locale** serves:

1. The user's stored preference: the `ctx.user` key `'lang'` (written by `ctx.setLang()`; change the key with `userKey`).
2. The language the provider reports for the sender: `SenderInfo.language`,
   or `platformData.languageCode` / `language` / `locale` (e.g. Telegram's
   `language_code`). Turn it off with `providerLanguage: false`. An update
   that reports no language (Telegram's `language_code` is optional) reuses
   the last one reported for the same sender, kept in memory, so the chat does
   not flip to the default between turns.
3. Your `detect(ctx)` function, if configured.
4. `defaultLocale` (default `'en'`, or `BotConfig.lang`).

`ctx.langSource` tells which step won (`'user'`, `'provider'`, `'detector'`
or `'default'`; `undefined` while i18n is off). When a `before('message')`
hook rewrites `ctx.body` — the `@botmesh/voice` transcript of a voice note,
for instance — a `detect` is configured and the language came from it or
from the default, it is resolved again after the hooks, so `detect` sees the spoken text. A
`ctx.lang` the hook assigned itself is kept.

`ctx.defaultLang` is the bot's `defaultLocale`, whatever `ctx.lang` resolved
to: plugins that ship texts for only a few languages (like `@botmesh/ai`) use
it as the fallback for a language they do not cover.

Resolution is turned on by any `bot.locale()` call or by `BotConfig.i18n`.
Without either, the bot answers in its default locale (English built-in
messages, or the language set with `lang`) and reads nothing from the store.

### A single-language bot: `lang`

The built-in texts are in English by default. `lang` picks another bundled
language for them (and for `ctx.lang`) **without** turning on resolution: no
`ctx.user` read, no provider language, no detector. It is all a bot written in
one language needs:

```ts theme={null}
const bot = new Bot({ provider, lang: 'es' })   // Spanish built-in texts
```

`lang` and `i18n.defaultLocale` must agree when both are set: the same tag, or
the same language with one tag more specific (`'es'` and `'es-MX'`, where the
more specific one becomes the default). Different languages or regions throw
`ConfigValidationError`. Use `i18n` instead when the bot serves several
languages.

```ts theme={null}
const bot = new Bot({
  provider,
  i18n: { defaultLocale: 'en', detect: (ctx) => (/^\s*hola\b/i.test(ctx.body ?? '') ? 'es' : undefined) },
})
```

### Pinning the bot's language

The provider-reported language is the user's **client** language, not
necessarily the language of your bot. If your content (persona, flows,
button titles) exists in one language only, a client in another language
would get built-in texts ("About to: … Do you confirm?", slash command
replies, timeouts) in its language wrapped around content in yours — and a
user with two clients in different languages could see them switch from one
turn to the next. Pin the languages the bot serves with `locales`:

```ts theme={null}
const bot = new Bot({ provider, i18n: { defaultLocale: 'en', locales: ['en'] } })
```

A stored preference, provider-reported or detected language outside
`locales` is ignored and resolution falls back to `defaultLocale` (always
served); `ctx.setLang()` refuses it. Regional variants follow their base
(`'en'` serves `'en-GB'`). Precedence, in short: **the languages the bot
serves (`locales`) bound everything; within them, the user's explicit choice
→ their client language → `detect` → `defaultLocale`.** Without `locales`
every registered locale is served (the built-in `es` and `en` included). To
make a bot bilingual, list both (`locales: ['en', 'es']`) and translate your
own content too, e.g. by building the persona from `ctx.lang`.

## Switching language

```ts theme={null}
bot.command('lang', async (ctx, [code]) => {
  if (!code || !bot.locales().includes(code.toLowerCase())) return ctx.reply(bot.locales().join(', '))
  await ctx.setLang(code)               // persisted in ctx.user; ctx.lang changes right away
  await ctx.reply(ctx.t('menu.title'))
})
```

`ctx.setLang(code)` stores the registered locale that serves `code`
(`'en-GB'` → `'en'` when only `en` exists). It rejects with `TypeError` for an
empty code or one no locale serves, and with `Error` while i18n is off.

## Conversations

`c.t()` and `c.lang` follow the **latest** message of the conversation, so a
language switched mid-conversation applies to the next step, and also to the
target of `c.goto()`:

```ts theme={null}
bot.conversation('menu', async (c) => {
  await c.say(c.t('menu.title'))
})
bot.use(flow('menu').say((_ctx, c) => c.t('menu.title')))
```

## Outside handlers

```ts theme={null}
const lang = await bot.langOf(userId)          // stored preference or the default
await bot.send(chatId, bot.t('greeting', { name: 'Ana' }, lang))
bot.locales()                                  // ['es', 'en', ...]
```

## Built-in messages

The core's own messages (conversation timeout, retries and error, wait
validation — including a `schema` rejection without its own message —,
`slashCommandsPlugin` replies, and the `flow().form()` prompts, hints, skip
word and validation errors under `core.form.*`) live under `core.*` with `es`
and `en` defaults. Their keys are exported as `CORE_MESSAGES`. Override them, or
translate them for a new locale, with `bot.locale()`:

```ts theme={null}
bot.locale('en', { core: { conversation: { timeout: '⏰ Time is up.' } } })
bot.locale('pt', { core: { commands: { ping: 'pong 🏓' } } })
bot.locale('en', { core: { form: { skipWord: 'none', min: '❌ {label} must be at least {min}.' } } })
```

A `conversation.timeoutMessage` / `retriesMessage` / `errorMessage` set in
`BotConfig` still wins over the locale.

`@botmesh/ai` does the same under `ai.*` (keys exported as `AI_MESSAGES`, `es`
and `en` defaults in `AI_LOCALES`, both frozen): the autoreply's fallback replies when the
provider fails or answers nothing (`ai.error`) or refuses (`ai.refusal`), unless
`errorMessage` / `refusalMessage` are set, the tool confirmation prompt
(`ai.tools.confirm`, `{tool}` / `{args}`; `ai.tools.confirmButtons` and the
`ai.tools.button.yes` / `ai.tools.button.no` labels on channels with buttons,
`ai.tools.confirmAnswered` / `ai.tools.confirmExpired` notices where the buttons
cannot be edited away) and its pieces (`ai.tools.confirmRepeat`,
`ai.tools.ago.*`, `ai.tools.value.*`, `ai.tools.more`), the system prompt notes
(`ai.tools.confirmNote`, `ai.tools.doneNote`), the `maxSteps` reply
(`ai.tools.maxSteps`), the reply when the last step's tool calls were cut off by
the output-token cap (`ai.tools.truncated`) and the `appendSources` line (`ai.rag.sources`,
`{sources}`). A key your locales do not define falls back to the built-in text
in `ctx.lang`; for a language without built-in texts (anything but `es` and
`en`), in the bot's default locale (`ctx.defaultLang`) when it has them, else
in English:

```ts theme={null}
bot.locale('en', { ai: { rag: { sources: '📚 {sources}' } } })
```

## Next steps

* [Configuration reference](https://botmesh.afaya.com.co/guide/configuration.md): `lang`, `i18n` and the other `new Bot()` options
* [Persistent user data](https://botmesh.afaya.com.co/guide/user-data.md): where `ctx.setLang()` stores the choice
* [AI plugin](https://botmesh.afaya.com.co/guide/ai.md): replies in the user's language


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.