Skip to content
Skip to Content

Reference / Configuration

Config file

Every config key with its type, default, and the version that added it, plus where the file can live and how a bad config fails.

verbatra reads one non-secret config object and validates it before any work. No API key is ever read from it (Providers), so the config is safe to commit.

A minimal config

verbatra.config.ts
import { defineConfig } from "@verbatra/cli";

export default defineConfig({
  // Locales and files: required
  sourceLocale: "en",
  targetLocales: ["de", "fr"],
  format: "i18next-json",
  files: {
    // {locale} is the only token
    pattern: "locales/{locale}.json",
  },

  // Provider: required; the API key comes from the environment, never from here
  provider: {
    id: "gemini",
    options: {
      // example model id; use your provider's current one
      model: "gemini-2.5-flash",
      maxOutputTokens: 4096,
    },
  },

  // Optional groups, each off or at its default until you add it:
  // tone: "informal",
  // glossary: "glossary.json",
  // network: { policy: "local-only" },
  // sensitiveData: { mode: "warn" },
  // maxTokens: 200000,
  // budgetBehavior: "stop",
});

See locales and files, provider, glossary, network, sensitive data, budget, rates and extract.

defineConfig only types its argument: options.model completes to the provider's model ids, though any non-empty string is accepted at runtime.

Every key

Unknown keys are rejected at every level except $schema and maps whose keys you name, such as maxLength, rates.table, localeMap and an inline glossary, so an API key cannot live in the config.

KeyTypeDefaultSinceEffect
$schemastringnone0.9.0Points an editor at the JSON Schema; ignored at runtime.
sourceLocalelocale coderequired0.1.0The locale your source strings are written in.
targetLocaleslocale code arrayrequired0.1.0At least one; never sourceLocale, never two equal ignoring case.
formatformat idrequired0.1.0The format of your locale files, or a custom: id for an outside adapter.
files.patternstringrequired0.1.0The path to each locale file, with a {locale} token.
files.localeStyle"literal", "posix", "android""literal"0.7.0How {locale} is spelled in the path.
provider.idprovider idrequired0.1.0The translation provider, or none for no machine translation.
provider.optionsobjectrequired, except for none0.1.0The provider's own options.
glossaryobject or file pathnone0.1.0Fixed translations, forbidden renderings and do-not-translate terms.
tone"formal", "informal", "neutral"none0.1.0The register to translate in, applied per provider.
prunebooleanfalse0.3.0Remove orphaned keys from target files and the lock; translate --prune overrides it.
generatePluralsbooleanfalse0.3.0Synthesize the CLDR plural forms a target language needs.
fuzzyCache.enabledbooleanrequired in the block0.11.0Reuse a slightly changed source's translation.
fuzzyCache.thresholdnumber, 0.5 to 10.90.11.0How alike old and new source must be.
humanEdits"protect", "suggest", "overwrite""protect"0.12.0What a run does with a stale key a person wrote.
pinnedKeysstring arraynone0.12.0Key patterns (* wildcard) no machine write touches.
network.policy"any", "local-only", "allowlist""any"0.12.0Which hosts a provider may connect to.
network.allowedHostsstring arraynone; required for "allowlist"0.12.0Extra permitted hosts: names, *.suffix wildcards, IPs or CIDR ranges.
sensitiveData.mode"off", "warn", "block", "redact"required in the block; "off" without the block0.12.0What a run does with sensitive content before sending.
sensitiveData.detectorsdetector id arraysecret, email, iban, credit-card0.12.0Detectors to run; phone, ip, private-host are opt-in.
sensitiveData.patternsstring arraynone0.12.0Regular expressions for your own terms, such as codenames.
sensitiveData.allowstring arraynone0.12.0Matches never reported; * is a wildcard, and case is ignored.
maxBatchSizepositive integer500.3.0The most entries per provider request.
maxLengthmap of key to positive integernone0.11.0Per-key length budgets in grapheme clusters.
maxTokenspositive integernone0.5.0A whole-run token budget.
budgetBehavior"warn", "stop""warn"0.5.0Whether reaching maxTokens warns or stops sending.
rates.asOfYYYY-MM-DD daterequired in the block0.11.0The date you read the rates.
rates.currencythree uppercase lettersrequired in the block0.11.0The currency of every rate, such as USD.
rates.tablemap of rate key to raterequired in the block0.11.0Rates per rate key.
extract.framework"i18next"required in the block0.11.0Which call sites the source scan looks for.
extract.rootsstring arrayrequired in the block0.11.0The directories to scan, at least one.
extract.excludestring arraynone0.11.0Extra directory names to skip.
extract.literals.ignorestring arraynone0.11.0Exact texts doctor --literals holds back.
extract.unused.ignorestring arraynone0.11.0Keys or * patterns diff --unused never reports.

Locales and files

  • files.pattern must contain {locale}, its only token, and resolves against the project root: locales/{locale}.json becomes locales/de.json. It is a literal path, never a glob, so one config addresses one file per locale (Namespace layouts); apple-xcstrings resolves every locale to one catalogue (Xcode String Catalogs).

  • files.localeStyle decides what the token expands to:

    • literal writes the tag as configured, as most layouts need.
    • posix replaces - with _ for gettext and Java messages_{locale}.properties (pt-BR becomes pt_BR); a variant such as de-1996 has no posix spelling. gettext-po spells scripts the gettext way (next section).
    • android expands to a resource directory, written as res/{locale}/strings.xml: values for the source, then values-de, values-pt-rBR, values-b+zh+Hans.

    A style refuses a locale it cannot spell.

gettext locale names

With format: "gettext-po" and localeStyle: "posix", files take gettext names (language_REGION@modifier) while the config keeps BCP 47 codes:

Config codeFile name
pt-BRpt_BR
es-419es_419
sr-Latnsr@latin
sr-Latn-RSsr_RS@latin
uz-Cyrluz@cyrillic
ks-Deva-INks_IN@devanagari
zh-Hant-TWzh_TW
sr-Cyrl-RSsr_RS

A script the language and region imply is left out, so zh-TW and zh-Hant-TW together fail with LOCALE_PATH_COLLISION; a script without a gettext modifier, such as zh-Hant alone, fails with LOCALE_LAYOUT_INVALID. verbatra init reads these names back. Other formats keep the ICU spelling (sr_Latn).

Locale codes

Every locale code must be a tag Intl.getCanonicalLocales accepts, with a two- or three-letter language subtag, or loading fails with CONFIG_INVALID: pt_BR (write pt-BR with localeStyle: "posix") and german are rejected. A valid non-canonical code such as zh-hant-tw or the deprecated iw works as written.

provider

provider.id selects one of the providers, or none, whose options may be omitted and must otherwise be {} (Run without machine translation). Every other provider accepts options.localeMap and options.requestTimeoutMs (Providers); from 0.12.0 a misplaced key under provider, such as localeMap, fails with CONFIG_INVALID.

glossary

Terms translated a fixed way, inline or as a path to a JSON file of the same shape (never merged). Version 1 maps each source term to one translation for every locale:

verbatra.config.ts
export default defineConfig({
  // ...
  glossary: {
    "Sign in": "Anmelden",
  },
  // or: glossary: "glossary.json",
});

A relative path resolves against the config file's directory; the file must be UTF-8 and at most 1 MiB, and is read once at load. A missing file, invalid JSON or an invalid shape fails with CONFIG_INVALID, naming the path and, for version 2, the field. Put a __proto__ term in a file; a JavaScript literal makes it the prototype.

Per-locale glossary (version 2)

Version 2 adds per-locale translations, forbidden renderings and terms to keep as written:

glossary.json
{
  "version": 2,
  "terms": [
    {
      "source": "Dashboard",
      "targets": { "de": "Übersicht", "fr": "Tableau de bord" },
      "forbidden": { "de": ["Instrumententafel"] },
      "note": "The start page after sign-in",
      "partOfSpeech": "noun"
    },
    { "source": "Sign in", "target": "Anmelden", "caseSensitive": true }
  ],
  "doNotTranslate": ["verbatra", { "term": "Acme", "caseSensitive": false }]
}
FieldMeaning
versionAlways 2; any other number fails.
terms[].sourceThe source term; each term appears once.
terms[].targetThe translation for every locale without its own targets entry.
terms[].targetsTranslations per locale. A locale uses its own entry, then its base language's (de covers de-AT), then target; codes compare canonically, ignoring case.
terms[].forbiddenRenderings a locale must never use; a regional locale also obeys its base language's list.
terms[].caseSensitiveMatch the term, translations and forbidden renderings with case. Default false.
terms[].note, terms[].partOfSpeechFree-text context, sent to an LLM provider as data.
doNotTranslateTerms copied untranslated. A bare string keeps its case; { "term", "caseSensitive": false } may change it.

Each term needs a target, a targets entry or a forbidden list; it may not forbid its own translation, name a locale twice, or appear in doNotTranslate. No text may be blank; a term is at most 200 characters, a translation or forbidden rendering 2000, a note 500 and a partOfSpeech 50. A locale's cache fingerprint covers only its own terms. A version 1 file keeps its fingerprint; updateGlossaryTerm rewrites it as version 2 only for an edit version 1 cannot express.

Studio edits only the file form, one term at a time. Providers says which providers apply it; every output is checked afterwards.

generatePlurals

When true, verbatra synthesizes the CLDR plural forms a target needs but the source lacks, such as Polish few and many, and ordinal keys (<key>_ordinal_<category>) by the CLDR ordinal rules. Only i18next-json with an LLM provider supports it; anything else, and a locale without CLDR rules, gets the plural warning (ordinals assume other). There is no CLI flag; the SDK's translate() takes a per-run override.

fuzzyCache

fuzzyCache: { enabled: true } lets a changed source reuse its earlier form's translation when the two are at least threshold alike, always flagged FUZZY_CACHE_REUSE (Fuzzy reuse); --no-cache bypasses it.

humanEdits and pinnedKeys

humanEdits covers a stale key whose value a person wrote (human, import or external provenance); a key matching pinnedKeys (such as ["legal.*", "brand.name"]) is never machine-written, whatever humanEdits says. * matches any run of characters, dots included, and the match is case-sensitive. What each mode does is under Keep human translations.

network

See Restrict network access.

verbatra.config.ts
network: { policy: "local-only", allowedHosts: ["gpu.lan"] }

maxBatchSize

Missing and changed entries go out in sequential sub-batches of at most this size. No CLI flag overrides it.

maxLength

Per-key length limits, for strings under a layout constraint:

verbatra.config.ts
maxLength: {
  "nav.dashboard": 18,
  "cta.submit": 12,
}
  • Here or in verbatra.limits.json. new Keys match literally, never as globs; a key set in neither is never measured. setKeyLengthLimit takes only source keys, so Studio cannot limit a generated plural form; an entry written by hand for its own key applies. The file beside the lock file takes { "version": 1, "maxLength": { ... } }, the config wins per key, and setKeyLengthLimit writes it.
  • Grapheme clusters. Length counts perceived characters, whitespace included: an emoji, an accented letter or a CJK character is one. The limit is inclusive.
  • When. Every value translate produces for the key, cache reuses included; an up-to-date key is measured only once its source changes, or by export --include-unchanged.
  • Advisory. An overrun is still written and locked, and flagged MAX_LENGTH_EXCEEDED for review.

maxTokens and budgetBehavior

maxTokens is a whole-run budget: input plus output tokens over every provider call (translation and plural generation) and every target locale. translate --max-tokens can tighten it for one run, never loosen it, and always as a hard "stop".

budgetBehavior decides what happens then:

  • "warn", the default, counts the spend and flags the overrun but continues.
  • "stop" makes it a ceiling for every provider. Before each request the run projects its cost like the pre-run estimate and refuses one that would pass maxTokens. That request's keys and every key not yet attempted (the rest of the locale and every later locale, whose diff and orphan reporting still run) are withheld, keep their prior lock hash and are retried next run.

Under "stop":

  • After each request the total is reconciled to the provider's reported usage, so a run can end slightly above maxTokens (the run summary's budget). Machine-translation providers report no tokens and are budgeted from the projection.
  • A failed or truncated request is charged in full.
  • The projection adds a fixed LLM prompt allowance, for machine-translation providers too, and covers a whole batch, so a maxTokens below one batch's projection refuses it every run; the notice says to lower maxBatchSize or raise maxTokens.
  • A locale with withheld keys is partial, or failed if no key landed, and both exit 1. Under "warn" the exit code never changes.

The budget bounds one translate run, never a single-entry retranslation; watch starts a fresh one per run. A budget rules out --concurrency above 1 on a live run; a dry run is exempt. budgetBehavior without maxTokens has no effect.

Two different maxTokens

The top-level maxTokens is this whole-run budget; Anthropic's unrelated provider.options.maxTokens caps one response (Providers).

rates

Read only by translate --estimate, which turns rates into money; a live run ignores it.

verbatra.config.ts
rates: {
  asOf: "2026-01-15",
  currency: "USD",
  table: {
    // placeholders: copy the current rates from each provider's pricing page
    "gemini/gemini-2.5-flash": { inputPerMillionTokens: 1, outputPerMillionTokens: 2 },
    deepl: { perMillionCharacters: 1 },
  },
}

A table key is provider/model, or the bare id for a provider without a model (deepl, google-translate). An entry is token-billed (inputPerMillionTokens, outputPerMillionTokens) or character-billed (perMillionCharacters), never both, and a negative rate, a rate above 1,000,000,000 per million, or the wrong unit is refused. openai-compatible and libretranslate send no invoice, so their quantity is reported without a rate.

extract

Read only by extract, doctor --literals and diff --unused.

verbatra.config.ts
extract: {
  framework: "i18next",
  roots: ["src", "app"],
  exclude: ["generated"],
  literals: { ignore: ["Acme Inc."] },
  unused: { ignore: ["emails.*"] },
}
  • framework must name one with an extractor (call shapes); any other is rejected.
  • roots resolve against the project root; exclude adds directory names (not paths), matched anywhere under a root, to the always-skipped set.
  • literals.ignore matches a literal's whole text, whitespace collapsed and references decoded (Untranslated literals).
  • unused.ignore matches the decoded key (diff --unused).

Discovery order

verbatra searches upward from the working directory and takes the first match, stopping at the nearest directory containing .git, else at your home directory (or the working directory when home is not above it), so it never picks up a config above your repository:

  1. package.json (a "verbatra" property)
  2. .verbatrarc
  3. .verbatrarc.json
  4. .verbatrarc.yaml
  5. .verbatrarc.yml
  6. .verbatrarc.js
  7. .verbatrarc.cjs
  8. .verbatrarc.ts
  9. verbatra.config.js
  10. verbatra.config.cjs
  11. verbatra.config.ts

--config <path> loads one file instead, relative to the working directory; the SDK's precedence is under loadConfig.

Editor validation

@verbatra/sdk ships a JSON Schema generated from its zod schema, also served at a stable URL for any install, npx, a hoisted monorepo or a remote agent:

https://verbatra.kreitz-webdev.de/schema/v1/config.json

Offline, or to pin the installed version, use the package copy, ./node_modules/@verbatra/sdk/dist/config-schema.json (all schemas).

.verbatrarc.json and .verbatrarc take a $schema key for VS Code:

.verbatrarc.json
{
  "$schema": "https://verbatra.kreitz-webdev.de/schema/v1/config.json",
  "sourceLocale": "en",
  "targetLocales": ["de", "fr"],
  "format": "i18next-json",
  "files": { "pattern": "locales/{locale}.json" },
  "provider": { "id": "gemini", "options": { "model": "gemini-2.5-flash", "maxOutputTokens": 4096 } }
}

The extensionless .verbatrarc also needs a workspace file match:

.vscode/settings.json
{
  "json.schemas": [
    {
      "fileMatch": [".verbatrarc", ".verbatrarc.json"],
      "url": "https://verbatra.kreitz-webdev.de/schema/v1/config.json"
    }
  ]
}

A YAML config takes a YAML Language Server comment:

.verbatrarc.yaml
# yaml-language-server: $schema=https://verbatra.kreitz-webdev.de/schema/v1/config.json
sourceLocale: en
targetLocales: [de, fr]
format: i18next-json
files:
  pattern: "locales/{locale}.json"
provider:
  id: gemini
  options:
    model: gemini-2.5-flash
    maxOutputTokens: 4096

package.json takes no schema; executable forms are typed by defineConfig.

What the document cannot check

Only loading the config checks these rules: the targetLocales rules and locale codes, localeMap keys and the openai-compatible baseUrl and apiKeyEnvVar rules, valid network.allowedHosts addresses and ranges, sensitiveData.patterns within the safety rules, and an inline version 2 glossary's term rules.

The working directory and .env

The working directory (where you run verbatra, or --cwd) starts the config search and holds the environment files. Locale paths, verbatra.lock.json, the cache, the provenance file and .verbatra-local/ resolve against the project root, the found config's directory, so a run from a subdirectory uses the same files. With --config, the working directory is the root, and a config elsewhere gets a stderr note, since its glossary path resolves against its own directory. A path you pass, such as --out or check --file, resolves against the working directory.

When loading fails

  • CONFIG_NOT_FOUND: no config in any searched place, or the --config path does not exist.
  • CONFIG_INVALID: a config was found but is unusable. The message lists every issue with its key path; an unknown key adds the hint that API keys come from the environment. A parse failure names the file (with line and column for YAML) but never quotes its content, and every message passes through key redaction.

The CLI exits 2 for both.

Edit on GitHub

On this page