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
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.
| Key | Type | Default | Since | Effect |
|---|---|---|---|---|
$schema | string | none | 0.9.0 | Points an editor at the JSON Schema; ignored at runtime. |
sourceLocale | locale code | required | 0.1.0 | The locale your source strings are written in. |
targetLocales | locale code array | required | 0.1.0 | At least one; never sourceLocale, never two equal ignoring case. |
format | format id | required | 0.1.0 | The format of your locale files, or a custom: id for an outside adapter. |
files.pattern | string | required | 0.1.0 | The path to each locale file, with a {locale} token. |
files.localeStyle | "literal", "posix", "android" | "literal" | 0.7.0 | How {locale} is spelled in the path. |
provider.id | provider id | required | 0.1.0 | The translation provider, or none for no machine translation. |
provider.options | object | required, except for none | 0.1.0 | The provider's own options. |
glossary | object or file path | none | 0.1.0 | Fixed translations, forbidden renderings and do-not-translate terms. |
tone | "formal", "informal", "neutral" | none | 0.1.0 | The register to translate in, applied per provider. |
prune | boolean | false | 0.3.0 | Remove orphaned keys from target files and the lock; translate --prune overrides it. |
generatePlurals | boolean | false | 0.3.0 | Synthesize the CLDR plural forms a target language needs. |
fuzzyCache.enabled | boolean | required in the block | 0.11.0 | Reuse a slightly changed source's translation. |
fuzzyCache.threshold | number, 0.5 to 1 | 0.9 | 0.11.0 | How alike old and new source must be. |
humanEdits | "protect", "suggest", "overwrite" | "protect" | 0.12.0 | What a run does with a stale key a person wrote. |
pinnedKeys | string array | none | 0.12.0 | Key patterns (* wildcard) no machine write touches. |
network.policy | "any", "local-only", "allowlist" | "any" | 0.12.0 | Which hosts a provider may connect to. |
network.allowedHosts | string array | none; required for "allowlist" | 0.12.0 | Extra permitted hosts: names, *.suffix wildcards, IPs or CIDR ranges. |
sensitiveData.mode | "off", "warn", "block", "redact" | required in the block; "off" without the block | 0.12.0 | What a run does with sensitive content before sending. |
sensitiveData.detectors | detector id array | secret, email, iban, credit-card | 0.12.0 | Detectors to run; phone, ip, private-host are opt-in. |
sensitiveData.patterns | string array | none | 0.12.0 | Regular expressions for your own terms, such as codenames. |
sensitiveData.allow | string array | none | 0.12.0 | Matches never reported; * is a wildcard, and case is ignored. |
maxBatchSize | positive integer | 50 | 0.3.0 | The most entries per provider request. |
maxLength | map of key to positive integer | none | 0.11.0 | Per-key length budgets in grapheme clusters. |
maxTokens | positive integer | none | 0.5.0 | A whole-run token budget. |
budgetBehavior | "warn", "stop" | "warn" | 0.5.0 | Whether reaching maxTokens warns or stops sending. |
rates.asOf | YYYY-MM-DD date | required in the block | 0.11.0 | The date you read the rates. |
rates.currency | three uppercase letters | required in the block | 0.11.0 | The currency of every rate, such as USD. |
rates.table | map of rate key to rate | required in the block | 0.11.0 | Rates per rate key. |
extract.framework | "i18next" | required in the block | 0.11.0 | Which call sites the source scan looks for. |
extract.roots | string array | required in the block | 0.11.0 | The directories to scan, at least one. |
extract.exclude | string array | none | 0.11.0 | Extra directory names to skip. |
extract.literals.ignore | string array | none | 0.11.0 | Exact texts doctor --literals holds back. |
extract.unused.ignore | string array | none | 0.11.0 | Keys or * patterns diff --unused never reports. |
Locales and files
-
files.patternmust contain{locale}, its only token, and resolves against the project root:locales/{locale}.jsonbecomeslocales/de.json. It is a literal path, never a glob, so one config addresses one file per locale (Namespace layouts);apple-xcstringsresolves every locale to one catalogue (Xcode String Catalogs). -
files.localeStyledecides what the token expands to:literalwrites the tag as configured, as most layouts need.posixreplaces-with_for gettext and Javamessages_({locale}.properties pt-BRbecomespt_); a variant such asBR de-1996has no posix spelling.gettext-pospells scripts the gettext way (next section).androidexpands to a resource directory, written asres/{locale}/strings.xml:valuesfor the source, thenvalues-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_) while the config keeps BCP 47 codes:
| Config code | File name |
|---|---|
pt-BR | pt_ |
es-419 | es_ |
sr-Latn | sr@latin |
sr-Latn-RS | sr_ |
uz-Cyrl | uz@cyrillic |
ks-Deva-IN | ks_ |
zh-Hant-TW | zh_ |
sr-Cyrl-RS | sr_ |
A script the language and region imply is left out, so zh-TW and zh-Hant-TW together fail with LOCALE_; a script without a gettext modifier, such as zh-Hant alone, fails with LOCALE_. verbatra init reads these names back. Other formats keep the ICU spelling (sr_).
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_: pt_ (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_.
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:
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_, naming the path and, for version 2, the field. Put a _ 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:
{
"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 }]
}| Field | Meaning |
|---|---|
version | Always 2; any other number fails. |
terms[].source | The source term; each term appears once. |
terms[].target | The translation for every locale without its own targets entry. |
terms[].targets | Translations 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[].forbidden | Renderings a locale must never use; a regional locale also obeys its base language's list. |
terms[].caseSensitive | Match the term, translations and forbidden renderings with case. Default false. |
terms[].note, terms[].partOfSpeech | Free-text context, sent to an LLM provider as data. |
doNotTranslate | Terms 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>_) 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_ (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
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:
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.setKeyLengthLimittakes 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, andsetKeyLengthLimitwrites 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
translateproduces for the key, cache reuses included; an up-to-date key is measured only once its source changes, or byexport --include-unchanged. - Advisory. An overrun is still written and locked, and flagged
MAX_for review.LENGTH_ EXCEEDED
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 passmaxTokens. 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'sbudget). 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
maxTokensbelow one batch's projection refuses it every run; the notice says to lowermaxBatchSizeor raisemaxTokens. - A locale with withheld keys is
partial, orfailedif no key landed, and both exit1. 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.
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.
extract: {
framework: "i18next",
roots: ["src", "app"],
exclude: ["generated"],
literals: { ignore: ["Acme Inc."] },
unused: { ignore: ["emails.*"] },
}frameworkmust name one with an extractor (call shapes); any other is rejected.rootsresolve against the project root;excludeadds directory names (not paths), matched anywhere under a root, to the always-skipped set.literals.ignorematches a literal's whole text, whitespace collapsed and references decoded (Untranslated literals).unused.ignorematches 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:
package.json(a"verbatra"property).verbatrarc.verbatrarc.json.verbatrarc.yaml.verbatrarc.yml.verbatrarc.js.verbatrarc.cjs.verbatrarc.tsverbatra.config.jsverbatra.config.cjsverbatra.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.jsonOffline, or to pin the installed version, use the package copy, ./node_ (all schemas).
.verbatrarc.json and .verbatrarc take a $schema key for VS Code:
{
"$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:
{
"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:
# 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: 4096package.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_: no config in any searched place, or theNOT_ FOUND --configpath does not exist.CONFIG_: 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.INVALID
The CLI exits 2 for both.