ServicesI18n

Resolution and Fallbacks

How a key becomes a string: the resolver walks the locale's fallback chain through the active published bundles, picks plural and context variants, interpolates variables, and applies any live experiment. Every behavior on this page was reproduced against a live server.

Fallback chains

The chain for a request is built from the project's fallback policy: the requested locale first, then fallback_policy.locale_overrides[locale] if present or default_chain otherwise, then the base locale if not already listed - deduplicated and truncated to the configured max depth (5 by default).

With locale_overrides: { "fr-CA": ["fr-FR", "en-US"] } and no fr-CA value for the key:

snug --output json i18n resolve -p $PROJECT -l fr-CA -k checkout.title
{
  "resolved": {
    "key": "checkout.title",
    "value": "Paiement",
    "source_locale": "fr-FR",
    "fallback_path": ["fr-CA", "fr-FR"],
    "version": 2,
    "rich_text_markdown": false,
    "variant": null,
    "experiment_id": null
  }
}

Each fallback-served resolve also increments the requested locale's fallback_hits_24h coverage counter (visible in locale list).

Gotcha, verified live: locale add --fallback stores a chain on the locale record and echoes it back in locale list, but resolution never reads it - only the project fallback policy counts. In a project with a default policy, fr-CA fell back straight to en-US ("Checkout") even though the locale's own chain listed fr-FR first, where "Paiement" was published. Per-locale chains must go in fallback_policy.locale_overrides, set over HTTP at project creation (see the README).

Plurals

plural_forms maps CLDR categories (zero, one, two, few, many, other) to strings. --count picks the category - but it does not fill a {count} placeholder; pass the variable too, or the resolve fails with 400 interpolation_error: missing value for variable 'count':

snug i18n resolve -p $PROJECT -l en-US -k inventory.item_count -c 5 --var count:5
# -> "5 items"     (-c 1 --var count:1 -> "1 item")

Variables

Variables are declared per entry at upload and enforced at resolve time: a declared variable without a supplied value is 400 interpolation_error, not a silent {name} left in the output.

snug i18n resolve -p $PROJECT -l en-US -k greeting.welcome --var name:Ada
# -> "Welcome back, Ada!"

Context variants and tone

An entry can carry conditional variants and tone metadata:

{
  "cta.continue": {
    "value": "Continue",
    "tone": { "formality": "neutral", "energy": "calm" },
    "context_variants": [
      { "id": "urgent-flow", "conditions": { "journey": "checkout" },
        "value": "Continue to payment" }
    ]
  }
}
snug i18n resolve -p $PROJECT -l en-US -k cta.continue --context journey:checkout
# -> "Continue to payment", variant "urgent-flow"; without --context -> "Continue"

tone (formality, energy, warmth, audience, urgency, notes) is returned verbatim with every resolution so agents can adapt phrasing to an approved register instead of inventing copy.

A/B experiments

An experiment attaches weighted copy variants to one key:

{
  "experiment_id": "checkout-cta",
  "key": "checkout.title",
  "variants": [
    { "experiment_id": "checkout-cta", "variant": "control", "weight": 50, "value": "Checkout" },
    { "experiment_id": "checkout-cta", "variant": "urgent", "weight": 50, "value": "Grab your cart!" }
  ]
}
snug i18n experiment create -p $PROJECT -f experiment.json
snug i18n publish -p $PROJECT -l en-US    # required - see below
snug i18n resolve -p $PROJECT -l en-US -k checkout.title -s ada

Bucketing hashes the experiment and subject together, so assignment is deterministic: subject ada resolved variant urgent on every call while linus always got control, with variant and experiment_id set in the response. Without --subject-id the base value is returned, no variant.

Experiments take effect at the next publish. Creating one attaches it to the draft translation records, but resolution reads the published snapshot - until the key's locales are republished, resolve keeps returning the base value with no variant (verified live).

Batch resolution

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

Unresolvable keys land in a missing array instead of failing the request (single-key resolve of a missing key is 404 key_not_found). Batches are capped at 250 keys by default. The HTTP body also accepts shared variables and context maps; the CLI currently exposes only --count and --subject-id for batches, so keys that require variables need the single-key command or raw HTTP.

Bundles

Export a locale's entire active bundle, for client-side caching keyed on the ETag:

snug --output json i18n bundle -p $PROJECT -l fr-CA --include-fallback
{
  "project_id": "uGGxgswBtPwxmMQHgAmE",
  "locale": "fr-CA",
  "version": 2,
  "etag": "\"uGGxgswBtPwxmMQHgAmE:fr-CA:v2\"",
  "published_at": "2026-08-28T04:36:01.643269Z",
  "strings": {
    "checkout.title": "Paiement",
    "greeting.welcome": "Welcome back, {name}!",
    "inventory.item_count": ""
  }
}

Plain export returns only the locale's own strings (empty for this fr-CA, which publishes nothing of its own); --include-fallback merges values down the fallback chain. Two caveats, both reproduced live: bundles are flat key -> string maps, so a plural-only key exports as an empty string (resolve plural keys at runtime instead), and drafts never appear in a bundle until published.

On this page