Localization — architecture & procedures
SkillCommunicationThe locale/i18n architecture of bagofwords — catalogs, resolution order, RTL, backend contracts — and the procedures for adding strings, adding a locale, or translating UI. Use when adding/changing user-facing strings, working on RTL, emails, localized errors, or anything locale-related.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Localization — architecture & procedures skill
What this skill tells your AI
The instructions your AI receives, as published by bagofwords1/bagofwords in .agents/skills/localization/SKILL.md and read by ahel’s review.
Authoritative deep-dive: docs/design/i18n.md. This skill is the working map.
Locales
Catalogs live at the repo root: locales/{en,es,he,fr,sv,ar,ru,de,pt,it}.json
(10 locales). en is the authoritative catalog — its key set and ordering
define the shape; the target is identical key structure in every catalog.
es and he are the actively maintained translations; the other seven are
known to lag behind en (run the sync check below to see current drift).
he is the RTL reference — anything RTL must work for Hebrew; ar is also
RTL (plugin whitelists he/ar/fa/ur).
Hebrew vocabulary conventions: "הנחיות" (never "הוראות"), "גלריה" (never "לוח מחוונים"), "בדיקות איכות" (never "הערכות").
Resolution order (highest wins)
X-Localerequest header — test-only override; must be inenabled_locales.- Per-user choice —
localStorage["bow.locale"]on the client. - Organization locale —
OrganizationSettings.config["locale"], set via/settings/general, served byGET/PUT /api/organization/locale(PUT gated bymanage_settings). - System default —
bow_config.i18n.default_locale(en). Enabled set:settings.bow_config.i18n.enabled_locales(backend/app/settings/bow_config.py).
Architecture map
Frontend (vue-i18n@9, composition mode):
frontend/plugins/i18n.ts— creates the global instance, imports catalogs, applies persistedbow.locale, exposes$setLocale, sets<html lang>and<html dir>.frontend/layouts/default.vue— after session ready, fetchesGET /api/organization/localeand applieseffective_localeonly when no personal override exists.frontend/composables/useErrorMessage.ts— maps backenderror_code+paramstot('errors.<code>', params), falling back to serverdetail.- RTL: Tailwind logical properties only (
ms-*,me-*,ps-*,pe-*,start-*,end-*— neverml-*/pl-*/left-*). Global icon flips and third-party overrides infrontend/assets/css/rtl.css; opt out per element withrtl-no-flip. Neverdir="auto"on empty contenteditable — bind:dirto the active locale.
Backend:
- Dependencies:
get_current_locale(request)(unauthed-safe, header→default),get_org_locale(request, organization)(header→org→default),_locale_from_org(organization)for services holding an org object. - Typed errors (
backend/app/errors/): raiseAppError.*(ErrorCode.X, ...), never bareHTTPException. New code = enum entry inapp/errors/codes.py+ a matchingerrors.<code>key in every locale catalog. - Emails:
app/services/email_renderer.py+email_strings.py, shared Jinja templates inapp/templates/emails/*.jinja2honoringlang/dir. Keep substitutions HTML-escaped —descriptioninshare.html.jinja2is| safe. - LLM prompts:
app/ai/prompt_language.pyinjects a "mirror the user's language" directive for conversational agents only (planner/answer/judge/ reporter/suggest_instructions) — reply in the language of the user's latest message, overriding tool/page/schema/instruction languages; org locale is the ambiguous-message fallback. Always emitted, including theendefault. Code/artifact agents stay English so SQL, identifiers, and JSON fields never get translated. - Public boot config:
GET /api/config/i18n.
Procedures
Adding a string: add the key to locales/en.json under the right
namespace, then add the same key path to all other catalogs in one pass.
Use named interpolation (t('key', { name })) — never concatenation; word
order varies by language. Locale-reactive label/option arrays in
<script setup> must be wrapped in computed(() => [...]).
Run the sync check before and after your change — the catalogs have known
pre-existing drift, so the rule is: your diff must not increase any
missing/extra count (and should reduce them where cheap):
python3 - <<'EOF'
import json, pathlib
def flatten(d, p=''):
out = set()
for k, v in d.items():
f = f'{p}.{k}' if p else k
out |= flatten(v, f) if isinstance(v, dict) else {f}
return out
cats = {f.stem: flatten(json.loads(f.read_text())) for f in pathlib.Path('locales').glob('*.json')}
en = cats.pop('en')
for loc, keys in sorted(cats.items()):
missing, extra = en - keys, keys - en
print(f'{loc}: missing={len(missing)} extra={len(extra)}')
for k in sorted(missing)[:10]: print(f' - {k}')
print(f'en: {len(en)} keys')
EOF
Adding a locale: create locales/<code>.json mirroring en.json's full
shape; add the code to enabled_locales in backend/app/settings/bow_config.py
and the import in frontend/plugins/i18n.ts; if RTL, add it to the plugin's
RTL whitelist; extend frontend/tests/i18n/locale-sweep.spec.ts.
Testing: cd frontend && npx playwright test --config=playwright.i18n.config.ts
runs the locale sweep (asserts html[lang]/html[dir] flip, strings render,
no {{…}}/unresolved key paths leak, no [intlify] console warnings). Keep it
green for any catalog or locale change. For RTL-visible changes, capture a
Hebrew screenshot per the ui-evidence skill.
Pitfalls that recur
- Adding a key to
en.jsononly → sync check fails for the other 9 catalogs. - Hard-coded literals in toasts/
confirm()/script-side strings — every user-facing string comes from the catalog. - Keying CSS-class maps on localized labels — key on canonical identifiers.
- Hand-formatted dates/numbers — always
Intl.*withlocale.value. - Physical CSS properties sneaking in via copy-paste — logical only.
Signals
- GitHub stars
- 455
- Forks
- 83
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
localization- Source
- github.com/bagofwords1/bagofwords