ServicesI18n

I18n

A runtime registry for localized app copy: projects hold locales and versioned translation strings, translators upload and approve copy through their own workflow, and clients resolve strings - through fallback chains, CLDR plurals, variable interpolation, context variants, and A/B copy experiments - without an application redeploy.

When to reach for it: app and game UI strings, agent-facing copy with per-locale tone metadata, translator workflows where developers keep release control, copy A/B tests, missing-translation tracking.

When not to: typed, environment-scoped configuration values belong to Remote Config; arbitrary JSON key-value data belongs to the KV Store.

Concepts

  • Project - a namespace for one product or surface. Holds the base locale, the fallback policy, translator roles, and the approval switch.
  • Locale - a BCP 47 tag (es-ES, fr-CA) registered per project. Invalid tags are rejected with 400 invalid_locale.
  • Translations are versioned - uploads create drafts; publishing snapshots eligible strings into a new immutable per-locale version and moves the locale's active pointer. Resolution only sees active versions.
  • The fallback policy lives on the project - a locale's chain comes from the project's fallback_policy, not from the locale record. See Resolution and fallbacks.
  • Namespaces are derived - the key segment before the first . (nav.settings -> nav) groups keys in the missing-key report.

Projects and locales

snug i18n project create --name checkout-app --base-locale en-US \
  --approval-required --role studio_translator
snug i18n project list -q checkout
snug i18n locale add -p $PROJECT -l en-US -d "English (US)"
snug i18n locale add -p $PROJECT -l fr-FR -d "French (France)"
snug i18n locale list -p $PROJECT

Two things the CLI flow does not make obvious, both verified live:

  • Add the base locale explicitly. Creating a project does not register it - uploading to en-US before locale add en-US fails with 404 locale_not_found.
  • The fallback policy is set at creation time, over HTTP. The CLI's project create cannot set it and there is no project update endpoint, so a project needing per-locale chains like fr-CA -> fr-FR -> en-US must be created with a raw request:
curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "checkout-app", "base_locale": "en-US", "approval_required": true,
       "fallback_policy": {"mode": "recursive", "default_chain": ["en-US"],
                           "locale_overrides": {"fr-CA": ["fr-FR", "en-US"]}}}' \
  http://localhost:4000/api/v1/i18n/projects
{
  "status": 201,
  "msg": "Created",
  "data": {
    "project_id": "uGGxgswBtPwxmMQHgAmE",
    "name": "checkout-app",
    "base_locale": "en-US",
    "fallback_policy": { "mode": "recursive", "default_chain": ["en-US"],
                         "locale_overrides": { "fr-CA": ["fr-FR", "en-US"] } },
    "approval_required": true,
    "active_version": 0,
    "created_by": "docs4-translator",
    ...
  }
}

Uploading strings

upload takes a JSON file keyed by translation key; each entry can carry a singular value, CLDR plural_forms, declared variables, context_variants, tone metadata, and a rich_text_markdown flag:

{
  "checkout.title": { "value": "Checkout" },
  "greeting.welcome": { "value": "Welcome back, {name}!", "variables": ["name"] },
  "inventory.item_count": { "plural_forms": { "one": "{count} item", "other": "{count} items" },
                            "variables": ["count"] }
}
snug i18n upload -p $PROJECT -l en-US -f strings.json

Three modes via --mode: upsert_draft (default) upserts key by key and reports per-key failures in validation_errors while the valid keys still save; replace deletes the locale's existing strings first; fail_all rejects the whole upload with 400 upload_rejected if any key fails. Values that look like credentials are rejected per key ("value appears to contain a secret") - verified with an api_key=sk_live_... value; the clean key in the same file still saved.

Approve, publish, roll back

Uploads are drafts and invisible to resolution until published - resolving a freshly uploaded key returns 404 key_not_found until a publish.

snug i18n approve -p $PROJECT -l es-ES   # all draft/pending; -k for specific keys
snug i18n publish -p $PROJECT            # all locales, or -l for one
snug i18n rollback -p $PROJECT -l es-ES -v 2
{
  "project_id": "uGGxgswBtPwxmMQHgAmE",
  "active_version": 2,
  "published_locales": [ { "locale": "fr-FR", "version": 2, "key_count": 2 },
                         { "locale": "en-US", "version": 2, "key_count": 3 } ]
}

Publishing reserves a new monotonic version per locale and atomically moves that locale's active pointer; rollback moves the pointer back without deleting anything. Verified: a key published in version 3 stopped resolving after rolling back to version 2.

With --approval-required, only approved strings are eligible - but publishing unapproved drafts does not fail: it succeeds and publishes an empty version (key_count: 0), which becomes the active bundle. Approve before you publish.

Resolving strings

snug i18n resolve -p $PROJECT -l fr-CA -k checkout.title

The response carries the resolved value plus where it came from (source_locale, fallback_path), the bundle version, tone metadata, and the experiment variant if one applied. Fallback chains, plurals, variables, context variants, experiments, and bundle export have their own page: Resolution and fallbacks.

Missing-key telemetry

Clients report keys they could not resolve; the service aggregates counts with first/last seen per key, locale, and caller:

snug i18n telemetry -p $PROJECT -l fr-CA -k nav.settings --caller web -n nav
snug i18n report -p $PROJECT --missing -l fr-CA
{
  "events": [
    { "key": "nav.settings", "namespace": "nav", "locale": "fr-CA",
      "caller": "web", "fallback_used": "en-US", "count": 2,
      "first_seen_at": "2026-08-28T04:37:20.015204Z", ... }
  ]
}

Coverage stats ride along on locale list: translated keys, missing keys, and fallback_hits_24h, which ticks up whenever a resolve for that locale was served by a fallback locale (verified live).

Access control

Verified live with three differently-privileged tokens:

  • Create a project: the translator capability or a platform admin token; a plain user gets 403 insufficient_permissions.
  • Upload, locale management, missing report: platform admin, the project creator, or a capability named in the project's translator_roles.
  • Approve, publish, rollback, experiments: platform admin or project creator only - a translator_roles token that uploads fine gets 403 admin or maintainer role required to publish. Translators ship copy; developers keep release control.
  • Resolve, batch resolve, bundles, telemetry: any authenticated user.

Limits and configuration

Translation values up to 8 KiB, 10000 keys per upload, 250 keys per batch resolve, fallback depth 5, and 10 variants per key by default - all tunable via the I18N_* variables in the I18n CONFIG reference.

Reference

On this page