Skip to content

I18n

Try the interactive example ↓

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, workspace placement, 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
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

locale list is a list too, most recently updated first, filtering on locale, status, created_at, and updated_at.

project list speaks the standard list contract: q over the name, filters on base_locale, approval_required, created_by, created_at, and updated_at, and sorts on created_at (newest first by default), updated_at, name, and active_version.

Two things the CLI flow does not make obvious:

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

Access control

Projects start private to their creator. Locales, translations, experiments, published bundles and telemetry follow the project's access. Share a project with a reader, editor or manager grant; token roles do not grant project access.

  • Reader: read projects, locales, bundles, resolved strings and reports; submit missing-key telemetry.
  • Editor: reader access plus draft translation uploads, locale creation and experiment changes.
  • Manager: editor access plus approval, publication, rollback and sharing.

Creation requires workspace creator access. Personal resources default to private; service accounts create resources under workspace custody. See Permissions for sharing commands and workspace behavior.

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.

Reference

Interactive example

Store translations by locale, with plural forms and fallback rules for missing messages. Publish related translations together when they are ready.

An illustrative example that runs in your browser. No account or API calls required.