Imperal Docs
Guides

i18n authoring

How to ship multi-language extensions — language detection, translation patterns, the no-ctx.lang reality

The Imperal SDK has no built-in translation helper and no ctx.lang shortcut. Extensions that need to produce user-facing text in the correct language are responsible for their own strategy. This guide explains what the platform does provide, how to detect the user's language from available data, and three translation patterns that cover the full range of extension complexity.

TopicSection
Reality check — no ctx.langReality check
How to detect user languageLanguage detection
Pattern 1 — inline dictInline dict
Pattern 2 — gettextgettext
Pattern 3 — runtime LLM translationLLM translation
Where i18n appliesApplies
Where i18n does NOT applyDoes not apply
PluralizationPluralization
Date / number / currency formattingFormatting
Right-to-left languagesRTL
Testing i18nTesting
Common pitfallsPitfalls
Cross-referencesSee also

Reality check

ctx.lang does not exist

The Context dataclass has no lang, language, or locale attribute. There is also no ctx.translate(...) helper. Any code that reads ctx.lang will raise AttributeError at runtime.

This is intentional. Language detection involves a chain of signal sources — the user's stored preference, the tenant's default locale, what the chat layer inferred from the conversation — and the right source depends on the extension's purpose. The SDK surfaces the raw signals and leaves the choice to you.

The web-kernel's chat layer (ChatExtension) does inject language enforcement into the LLM call via ctx._user_language and ctx._user_language_name private attributes. These are web-kernel-managed metadata fields, not part of the public Context API, and their presence is not guaranteed outside ChatExtension dispatch. Do not read them directly from extension handlers.


How to detect user language

From ctx.user.attributes

The primary source. When a user sets their preferred language in the platform UI, the web-kernel stores it in ctx.user.attributes. The attribute key used by the platform is "language" (the human-readable name, e.g. "Russian", "Spanish") and "lang" (the BCP-47 code, e.g. "ru", "es").

helpers/lang.py
from imperal_sdk.context import Context


def get_user_lang(ctx: Context, default: str = "en") -> str:
    """Return a BCP-47 language code for the current user.

    Reads from ctx.user.attributes in order:
      1. "lang"         — BCP-47 code set by the user (e.g. "ru", "es")
      2. "language"     — human-readable name; mapped to code when recognised
      3. fallback       — the provided default (default: "en")
    """
    attrs: dict = ctx.user.attributes if ctx.user else {}
    code = attrs.get("lang", "")
    if code:
        return str(code).lower()
    name = str(attrs.get("language", "")).lower()
    _name_to_code = {
        "russian": "ru",
        "spanish": "es",
        "french": "fr",
        "german": "de",
        "italian": "it",
        "romanian": "ro",
        "portuguese": "pt",
        "arabic": "ar",
        "hebrew": "he",
        "chinese": "zh",
        "japanese": "ja",
    }
    return _name_to_code.get(name, default)

What the platform sets

SourceKeyExample valueReliability
User profile preferencectx.user.attributes["lang"]"ru"Set by user — present when they chose a language
User profile (name form)ctx.user.attributes["language"]"Russian"Same as above; both keys may coexist
Time / locale datactx.time.timezone"Europe/Chisinau"Always present; coarse locale signal only
Tenant defaultctx.tenant.features.get("default_lang")"es"Present when tenant configured a locale

None of these keys are guaranteed to be populated. Always specify a fallback. The recommended fallback is "en".


Pattern 1 — inline dict

Best for: small extensions with a handful of user-facing strings (under ~20 translatable strings).

Define a nested dict keyed by BCP-47 code. Missing strings fall back to "en". Centralise strings at module scope so they appear in a single place.

i18n.py
from __future__ import annotations

STRINGS: dict[str, dict[str, str]] = {
    "en": {
        "task_created": "Task created: {title}",
        "task_not_found": "No task found with that name.",
        "tasks_none": "You have no tasks yet.",
        "tasks_header": "Your tasks",
        "task_deleted": "Task deleted.",
        "task_retrieved": "Task found.",
        "quota_exceeded": "Your task quota is full. Delete a task first.",
    },
    "ru": {
        "task_created": "Задача создана: {title}",
        "task_not_found": "Задача с таким именем не найдена.",
        "tasks_none": "У вас пока нет задач.",
        "tasks_header": "Ваши задачи",
        "task_deleted": "Задача удалена.",
        "task_retrieved": "Задача найдена.",
        "quota_exceeded": "Квота задач заполнена. Сначала удалите одну задачу.",
    },
    "es": {
        "task_created": "Tarea creada: {title}",
        "task_not_found": "No se encontró ninguna tarea con ese nombre.",
        "tasks_none": "Aún no tienes tareas.",
        "tasks_header": "Tus tareas",
        "task_deleted": "Tarea eliminada.",
        "task_retrieved": "Tarea encontrada.",
        "quota_exceeded": "Tu cuota de tareas está llena. Elimina una tarea primero.",
    },
}


def t(lang: str, key: str, **kwargs: object) -> str:
    """Translate key to lang, falling back to 'en'.

    Positional format args passed as kwargs are applied via str.format().
    """
    bucket = STRINGS.get(lang) or STRINGS["en"]
    template = bucket.get(key) or STRINGS["en"].get(key) or key
    return template.format(**kwargs) if kwargs else template

Usage inside a handler — the translation dict is imported from i18n.py and the language helper from helpers/lang.py. In a real extension these live in separate files; the snippet below shows the inline equivalent for clarity:

handlers_tasks.py
from __future__ import annotations
from pydantic import BaseModel
from imperal_sdk import ActionResult
from imperal_sdk.context import Context

# ---- inline helpers (normally imported from i18n.py / helpers/lang.py) ------

_STRINGS: dict[str, dict[str, str]] = {
    "en": {
        "task_created": "Task created: {title}",
        "task_not_found": "No task found with that name.",
    },
    "ru": {
        "task_created": "Задача создана: {title}",
        "task_not_found": "Задача с таким именем не найдена.",
    },
}


def _t(lang: str, key: str, **kwargs: object) -> str:
    bucket = _STRINGS.get(lang) or _STRINGS["en"]
    template = bucket.get(key) or _STRINGS["en"].get(key) or key
    return template.format(**kwargs) if kwargs else template


def _lang(ctx: Context) -> str:
    attrs: dict = ctx.user.attributes if ctx.user else {}
    return str(attrs.get("lang", "en")).lower() or "en"


# ---- handler ------------------------------------------------------------------

class CreateTaskParams(BaseModel):
    title: str


async def fn_create_task(ctx: Context, params: CreateTaskParams) -> ActionResult:
    lang = _lang(ctx)

    if not params.title.strip():
        return ActionResult.error(_t(lang, "task_not_found"))

    store = ctx.store
    assert store is not None, "ctx.store must be available"
    doc = await store.create("tasks", {"title": params.title, "done": False})
    return ActionResult.success(
        data={"task_id": doc.id},
        summary=_t(lang, "task_created", title=params.title),
    )

Pattern 2 — gettext

Best for: larger extensions where strings are authored by translators and stored in .po / .mo files, or where string count exceeds ~30.

Python's standard gettext module loads compiled .mo files at startup. The extension ships a locale/ directory inside its package:

my-extension/
├── app.py
├── handlers_*.py
├── locale/
│   ├── en/LC_MESSAGES/messages.po
│   ├── en/LC_MESSAGES/messages.mo
│   ├── ru/LC_MESSAGES/messages.po
│   ├── ru/LC_MESSAGES/messages.mo
│   └── es/LC_MESSAGES/messages.po
│       └── es/LC_MESSAGES/messages.mo
└── i18n.py

Compile .po to .mo before deploy:

msgfmt locale/ru/LC_MESSAGES/messages.po -o locale/ru/LC_MESSAGES/messages.mo

Load translations at module scope:

i18n.py
from __future__ import annotations
import gettext
from pathlib import Path

_LOCALE_DIR = Path(__file__).parent / "locale"
_CACHE: dict[str, gettext.NullTranslations] = {}


def _get_translation(lang: str) -> gettext.NullTranslations:
    if lang not in _CACHE:
        try:
            trans = gettext.translation(
                "messages",
                localedir=str(_LOCALE_DIR),
                languages=[lang],
            )
        except FileNotFoundError:
            trans = gettext.NullTranslations()
        _CACHE[lang] = trans
    return _CACHE[lang]


def t(lang: str, message: str) -> str:
    """Return the translated form of message for lang."""
    return _get_translation(lang).gettext(message)

Usage is the same as Pattern 1 — call t(lang, "Source string") in handlers. The source strings in .po files are the English originals.

Pin Babel in requirements.txt if you need pybabel for string extraction (pybabel extract -F babel.cfg -o locale/messages.pot .). The gettext module itself is part of the Python standard library and needs no extra install.


Pattern 3 — runtime LLM translation

Best for: extensions whose output is dynamic text (reports, summaries, generated content) where static strings are insufficient.

Use ctx.ai.complete() to translate a pre-composed English string at runtime. This pattern is not appropriate for fixed UI labels — it is for body text whose content varies per invocation.

handlers_report.py
from __future__ import annotations
from pydantic import BaseModel
from imperal_sdk import ActionResult
from imperal_sdk.context import Context


def _lang(ctx: Context) -> str:
    attrs: dict = ctx.user.attributes if ctx.user else {}
    return str(attrs.get("lang", "en")).lower() or "en"


class GenerateReportParams(BaseModel):
    topic: str


async def fn_generate_report(ctx: Context, params: GenerateReportParams) -> ActionResult:
    lang = _lang(ctx)

    # Generate the report in English first — always produce grounded content,
    # then translate. Do not ask the LLM to both research and translate in one call.
    report_en = (
        f"Summary report for topic: {params.topic}.\n"
        "Details: this is a placeholder for real data from ctx.store or ctx.http."
    )

    if lang == "en":
        return ActionResult.success(
            data={"report": report_en},
            summary=f"Report generated for '{params.topic}'",
        )

    # Translate only when the language differs from English.
    ai = ctx.ai
    assert ai is not None, "ctx.ai must be available"
    result = await ai.complete(
        prompt=(
            f"Translate the following English text to {lang}. "
            "Return only the translated text, no commentary.\n\n"
            f"{report_en}"
        ),
    )
    translated = result.text.strip() or report_en

    return ActionResult.success(
        data={"report": translated},
        summary=f"Report generated for '{params.topic}'",
    )

LLM translation is billed per token and adds latency (typically 500 ms–2 s). Cache the result with ctx.cache when the same content may be requested again within the TTL window.


Where i18n applies

Panel UI strings

Every string rendered inside a panel component (ui.Text, ui.Header, ui.Empty, button labels, form field placeholders) should be translated when the extension targets a multilingual audience. Translate at the point of panel construction, not in the handler.

panels.py
from __future__ import annotations
from imperal_sdk import ui
from imperal_sdk.context import Context

_PANEL_STRINGS: dict[str, dict[str, str]] = {
    "en": {"tasks_none": "You have no tasks yet.", "tasks_header": "Your tasks"},
    "ru": {"tasks_none": "У вас пока нет задач.", "tasks_header": "Ваши задачи"},
    "es": {"tasks_none": "Aún no tienes tareas.", "tasks_header": "Tus tareas"},
}


def _pt(lang: str, key: str) -> str:
    return (_PANEL_STRINGS.get(lang) or _PANEL_STRINGS["en"]).get(
        key, _PANEL_STRINGS["en"].get(key, key)
    )


def _lang(ctx: Context) -> str:
    attrs: dict = ctx.user.attributes if ctx.user else {}
    return str(attrs.get("lang", "en")).lower() or "en"


async def build_sidebar(ctx: Context) -> list:  # type: ignore[type-arg]
    lang = _lang(ctx)
    store = ctx.store
    assert store is not None, "ctx.store required"
    items = await store.query("tasks", limit=20)

    if not items.data:
        return [ui.Empty(message=_pt(lang, "tasks_none"))]

    rows = [
        ui.Text(content=doc.data.get("title", ""), variant="body")
        for doc in items.data
    ]
    return [ui.Header(text=_pt(lang, "tasks_header")), *rows]

Chat responses

The summary field of ActionResult.success() becomes the narration input for the chat layer. Write it in the user's language so the LLM narration starts from the right base.

handlers_tasks.py
from __future__ import annotations
from pydantic import BaseModel
from imperal_sdk import ActionResult
from imperal_sdk.context import Context

_STRINGS: dict[str, dict[str, str]] = {
    "en": {"task_not_found": "No task found.", "task_deleted": "Task deleted."},
    "ru": {"task_not_found": "Задача не найдена.", "task_deleted": "Задача удалена."},
    "es": {"task_not_found": "Tarea no encontrada.", "task_deleted": "Tarea eliminada."},
}


def _t(lang: str, key: str) -> str:
    return (_STRINGS.get(lang) or _STRINGS["en"]).get(key, _STRINGS["en"].get(key, key))


def _lang(ctx: Context) -> str:
    attrs: dict = ctx.user.attributes if ctx.user else {}
    return str(attrs.get("lang", "en")).lower() or "en"


class DeleteTaskParams(BaseModel):
    task_id: str


async def fn_delete_task(ctx: Context, params: DeleteTaskParams) -> ActionResult:
    lang = _lang(ctx)
    store = ctx.store
    assert store is not None, "ctx.store required"
    deleted = await store.delete("tasks", params.task_id)
    if not deleted:
        return ActionResult.error(_t(lang, "task_not_found"))
    return ActionResult.success(
        data={"task_id": params.task_id},
        summary=_t(lang, "task_deleted"),
    )

Error messages

The Magic UX rule requires that ActionResult.error(text=...) strings reach the user in a readable form. Write the text argument in the user's language. The web-kernel formats it; you supply the content.

handlers_tasks.py
from __future__ import annotations
from pydantic import BaseModel
from imperal_sdk import ActionResult
from imperal_sdk.context import Context

_STRINGS: dict[str, dict[str, str]] = {
    "en": {"task_not_found": "No task found.", "task_retrieved": "Task found."},
    "ru": {"task_not_found": "Задача не найдена.", "task_retrieved": "Задача найдена."},
    "es": {"task_not_found": "Tarea no encontrada.", "task_retrieved": "Tarea encontrada."},
}


def _t(lang: str, key: str) -> str:
    return (_STRINGS.get(lang) or _STRINGS["en"]).get(key, _STRINGS["en"].get(key, key))


def _lang(ctx: Context) -> str:
    attrs: dict = ctx.user.attributes if ctx.user else {}
    return str(attrs.get("lang", "en")).lower() or "en"


class GetTaskParams(BaseModel):
    task_id: str


async def fn_get_task(ctx: Context, params: GetTaskParams) -> ActionResult:
    lang = _lang(ctx)
    store = ctx.store
    assert store is not None, "ctx.store required"
    doc = await store.get("tasks", params.task_id)
    if doc is None:
        return ActionResult.error(_t(lang, "task_not_found"))
    return ActionResult.success(
        data=doc.data,
        summary=_t(lang, "task_retrieved"),
    )

Where i18n does NOT apply

Tool descriptions for the LLM (V16 / V19)

@chat.function(description=...) and the description fields of Pydantic params are parsed by the intent classifier and the LLM. Write them in English. The LLM understands English across all router models; translation here would degrade classification accuracy.

app.py
from imperal_sdk import Extension
from imperal_sdk.chat import ChatExtension, ActionResult
from pydantic import BaseModel, Field
from imperal_sdk.context import Context

ext = Extension(
    "tasks",
    version="1.0.0",
    display_name="Tasks",
    description="Tasks extension — create, view, and manage your tasks with AI.",
    icon="icon.svg",
    actions_explicit=True,
)
chat = ChatExtension(ext, tool_name="tasks", description="Tasks assistant.")


class CreateTaskParams(BaseModel):
    # Keep all LLM-facing strings in English — do not translate.
    title: str = Field(description="The title of the new task to create.")
    due_date: str = Field(default="", description="Optional ISO 8601 due date.")


@chat.function(  # type: ignore[attr-defined]
    "fn_create_task",
    # This description is read by the LLM classifier. English only.
    description="Create a new task in the user's task list.",
    action_type="write",
    chain_callable=True,
    effects=["create:task"],
)
async def fn_create_task(ctx: Context, params: CreateTaskParams) -> ActionResult:
    store = ctx.store
    assert store is not None
    doc = await store.create("tasks", {"title": params.title})
    return ActionResult.success(
        data={"task_id": doc.id},
        summary=f"Task created: {params.title}",
    )

Audit log fields

The effects list, summary passed to the audit ledger, and event names are system records. Write them in English. They are read by platform administrators, not end users.

Manifest fields

Extension(display_name=..., description=...) are authored once by the extension developer. The platform displays them in the Marketplace and Developer Portal in their original form. Do not attempt to localise manifest fields — there is no manifest-level i18n mechanism.

Skeleton data

Skeleton sections contain facts the LLM uses for intent classification. The LLM processes them in English regardless of user language. Structure skeleton data as neutral facts in English.


Pluralization

Python's standard library has no pluralization helper. For simple cases (two forms: singular / plural), use an inline conditional:

i18n.py
from __future__ import annotations


def pluralize(lang: str, count: int, singular: str, plural: str) -> str:
    """Return singular or plural form based on count.

    For Slavic languages (Russian, Ukrainian, etc.) that have three plural
    forms, extend this function with a dedicated rule. For Arabic (six forms),
    use the Babel library's plural support instead.
    """
    if lang in ("ru", "uk", "be"):
        # Russian-style rules: 1 → singular, 2–4 → few (use plural here),
        # 5+ and 11–19 → plural. Simplified two-form version:
        mod10 = count % 10
        mod100 = count % 100
        if mod10 == 1 and mod100 != 11:
            return singular
        return plural
    # Default: English-style (1 = singular, everything else = plural)
    return singular if count == 1 else plural


def format_count(lang: str, count: int, item_singular: str, item_plural: str) -> str:
    form = pluralize(lang, count, item_singular, item_plural)
    return f"{count} {form}"

For full pluralization support across all Unicode CLDR locales (Arabic, Polish, Slovenian, etc.), use the Babel library:

pip install Babel
i18n_babel.py
from babel.plural import format_unit  # type: ignore[import-untyped]


def format_item_count(lang: str, count: int, item: str) -> str:
    """Return a localised count string using Babel's plural rules."""
    return format_unit(count, item, locale=lang)  # type: ignore[no-any-return]

Date / number / currency formatting

Use Babel for all locale-sensitive formatting. Do not roll your own date or number formatters — they break on edge cases (12h vs 24h clocks, comma vs period decimal separator, right-to-left digit grouping).

formatting.py
from __future__ import annotations
from datetime import datetime
from babel.dates import format_datetime  # type: ignore[import-untyped]
from babel.numbers import format_currency, format_decimal  # type: ignore[import-untyped]


def fmt_date(dt: datetime, lang: str) -> str:
    """Format a datetime in the user's locale."""
    return format_datetime(dt, format="medium", locale=lang)  # type: ignore[no-any-return]


def fmt_currency(amount: float, currency: str, lang: str) -> str:
    """Format a monetary amount with the correct locale symbol placement."""
    return format_currency(amount, currency, locale=lang)  # type: ignore[no-any-return]


def fmt_number(value: float, lang: str, decimal_quantization: bool = True) -> str:
    """Format a decimal number with locale-correct separators."""
    return format_decimal(  # type: ignore[no-any-return]
        value, locale=lang, decimal_quantization=decimal_quantization
    )

Add Babel to requirements.txt:

imperal-sdk
Babel>=2.14,<3

Right-to-left languages

Arabic (ar) and Hebrew (he) require right-to-left layout in panel UI. The Imperal panel renderer reads a dir property on top-level layout nodes when present.

RTL layout support in panel primitives is best-effort. Complex multi-column layouts may require separate panel builders for LTR and RTL locales. Test with a real RTL user account before shipping.

Detect RTL at panel build time and adjust accordingly:

helpers/lang.py
RTL_LANGS = frozenset({"ar", "he", "fa", "ur"})


def is_rtl(lang: str) -> bool:
    return lang in RTL_LANGS

For text content in RTL languages, ui.Text and ui.Header pass through the string unchanged — the browser renderer applies directionality based on the Unicode bidi algorithm. Explicit dir="rtl" is only needed when the panel container itself must flip its layout direction.


Testing i18n

Unit tests should set the language attribute on MockContext and assert the rendered text. Because MockContext creates a UserContext with an empty attributes dict by default, pass the attributes you need:

tests/test_i18n.py
import pytest  # type: ignore[import-untyped]
from imperal_sdk.testing import MockContext
from imperal_sdk.types.identity import UserContext
from imperal_sdk.context import Context, TimeContext
from imperal_sdk.testing.mock_context import (
    MockStore, MockAI, MockSkeleton,
    MockBilling, MockNotify, MockStorage,
    MockHTTP, MockConfig, MockExtensions,
)


def _ctx_with_lang(lang_code: str) -> Context:
    """Build a MockContext with a specific language attribute."""
    user = UserContext(
        imperal_id="u_test",
        email="test@test.com",
        role="user",
        scopes=["*"],
        tenant_id="default",
        attributes={"lang": lang_code},
    )
    return Context(
        user=user,
        store=MockStore(),
        ai=MockAI(),
        skeleton=MockSkeleton(),
        billing=MockBilling(),
        notify=MockNotify(),
        storage=MockStorage(),
        http=MockHTTP(),
        config=MockConfig(),
        extensions=MockExtensions(),
        time=TimeContext(
            timezone="UTC",
            now_utc="2026-01-01T00:00:00Z",
            now_local="2026-01-01T00:00:00",
            hour_local=0,
        ),
        _extension_id="test-ext",
        _tool_type="tool",
    )

Call your handlers directly with the context and assert on the summary or error field:

tests/test_i18n.py
from pydantic import BaseModel
from imperal_sdk import ActionResult
from imperal_sdk.context import Context

_STRINGS: dict[str, dict[str, str]] = {
    "en": {"task_created": "Task created: {title}", "task_not_found": "No task found."},
    "ru": {"task_created": "Задача создана: {title}", "task_not_found": "Задача не найдена."},
    "es": {"task_created": "Tarea creada: {title}", "task_not_found": "Tarea no encontrada."},
}


def _t(lang: str, key: str, **kwargs: object) -> str:
    bucket = _STRINGS.get(lang) or _STRINGS["en"]
    template = bucket.get(key) or _STRINGS["en"].get(key) or key
    return template.format(**kwargs) if kwargs else template


def _lang(ctx: Context) -> str:
    attrs: dict = ctx.user.attributes if ctx.user else {}
    return str(attrs.get("lang", "en")).lower() or "en"


class CreateTaskParams(BaseModel):
    title: str


async def fn_create_task(ctx: Context, params: CreateTaskParams) -> ActionResult:
    lang = _lang(ctx)
    if not params.title.strip():
        return ActionResult.error(_t(lang, "task_not_found"))
    store = ctx.store
    assert store is not None
    doc = await store.create("tasks", {"title": params.title, "done": False})
    return ActionResult.success(
        data={"task_id": doc.id},
        summary=_t(lang, "task_created", title=params.title),
    )

Key conventions:

  • Test every language you explicitly support, including the English fallback.
  • For error path tests, assert on a substring that is only present in the expected language.
  • Do not assert on the exact string — translations change. Assert on a stable substring.
  • If get_user_lang falls back to "en" when the attribute is absent, write a test that verifies the fallback.

Common pitfalls

Assuming ctx.lang exists

ctx.lang does not exist. Any code like lang = ctx.lang will raise AttributeError at runtime. Always use ctx.user.attributes.get("lang", "en") or a dedicated helper function as shown above.

Hardcoded English strings in handlers

Centralise all user-facing strings in a translation dict, even when you only ship English today. Extracting strings costs almost nothing and makes adding a second language trivial later. The contrast:

handlers_tasks.py
from pydantic import BaseModel
from imperal_sdk import ActionResult
from imperal_sdk.context import Context

_STRINGS: dict[str, dict[str, str]] = {
    "en": {"task_not_found": "No task found.", "task_retrieved": "Task found."},
    "ru": {"task_not_found": "Задача не найдена.", "task_retrieved": "Задача найдена."},
}


def _t(lang: str, key: str) -> str:
    return (_STRINGS.get(lang) or _STRINGS["en"]).get(key, _STRINGS["en"].get(key, key))


def _lang(ctx: Context) -> str:
    attrs: dict = ctx.user.attributes if ctx.user else {}
    return str(attrs.get("lang", "en")).lower() or "en"


class LookupTaskParams(BaseModel):
    task_id: str


# WRONG — English strings hardcoded, second language impossible to add cleanly
async def fn_lookup_task_wrong(ctx: Context, params: LookupTaskParams) -> ActionResult:
    store = ctx.store
    assert store is not None
    doc = await store.get("tasks", params.task_id)
    if doc is None:
        return ActionResult.error("Task not found")  # always English
    return ActionResult.success(data=doc.data, summary="Task found.")


# RIGHT — use the translation layer, even for English-only extensions
async def fn_lookup_task_right(ctx: Context, params: LookupTaskParams) -> ActionResult:
    lang = _lang(ctx)
    store = ctx.store
    assert store is not None
    doc = await store.get("tasks", params.task_id)
    if doc is None:
        return ActionResult.error(_t(lang, "task_not_found"))
    return ActionResult.success(data=doc.data, summary=_t(lang, "task_retrieved"))

Missing pluralization

Russian, Arabic, Polish, and many other languages have more than two plural forms. Using a bare "1 task" / "N tasks" conditional breaks for those locales. Use the pluralize / format_count helpers from the Pluralization section or the Babel library for full CLDR coverage.

Concatenating translated strings

Many languages change word order depending on context. String concatenation that works in English produces ungrammatical output in other languages.

i18n.py
from __future__ import annotations

_STRINGS: dict[str, dict[str, str]] = {
    "en": {"task_created": "Task created: {title}"},
    "de": {"task_created": "Aufgabe erstellt: {title}"},
    "ja": {"task_created": "{title}が作成されました"},
}


def _t(lang: str, key: str, **kwargs: object) -> str:
    bucket = _STRINGS.get(lang) or _STRINGS["en"]
    template = bucket.get(key) or _STRINGS["en"].get(key) or key
    return template.format(**kwargs) if kwargs else template


# WRONG — breaks German, Russian, Arabic, Japanese word order
def bad_summary(lang: str, title: str) -> str:
    task_word = "Task" if lang == "en" else "Aufgabe" if lang == "de" else "タスク"
    return task_word + " created: " + title  # concatenation breaks word order


# RIGHT — translate the entire template as a unit
def good_summary(lang: str, title: str) -> str:
    return _t(lang, "task_created", title=title)
    # Translation strings carry the word order for each language:
    # en: "Task created: {title}"
    # de: "Aufgabe erstellt: {title}"
    # ja: "{title}が作成されました"

Translating LLM-facing strings

Tool descriptions, Pydantic field descriptions, and skeleton data must stay in English. Translating them degrades intent classification accuracy because the routing models and classifier are calibrated on English prompts.

Forgetting the fallback chain

ctx.user.attributes may be empty, or the lang key may be absent. Always define a fallback:

helpers/lang.py
from imperal_sdk.context import Context


def get_user_lang(ctx: Context, default: str = "en") -> str:
    attrs: dict = ctx.user.attributes if ctx.user else {}
    return (
        attrs.get("lang")
        or attrs.get("language", "")
        or default
    )

See also

On this page