# verbatra (full documentation)
> This is the complete verbatra documentation in a single file, for AI agents that ingest
> content directly. For a curated index of links instead, see https://verbatra.kreitz-webdev.de/llms.txt.
# Introduction (https://verbatra.kreitz-webdev.de/docs)
## The problem [#the-problem]
You hand-maintain one source locale. Every other locale file drifts as that source evolves: new keys are missing from the targets, edited source strings leave stale translations behind, and deleted keys linger as orphans. Catching up by hand does not scale, and retranslating everything on every change is slow, costs provider tokens for strings that were already fine, and churns text your team has reviewed.
## How verbatra solves it [#how-verbatra-solves-it]
A committed lock file, `verbatra.lock.json`, records for every translated key the hash of the source string its translation came from. Each run reads the source locale, diffs every target against it and against that baseline, and sends only the missing and changed keys to the provider. Keys that are still current are left alone: run `translate` twice in a row and the second run makes no provider call at all.
Every string the provider returns is checked before it is written: placeholder integrity and ICU integrity are enforced, and a translation that breaks either is withheld instead of shipped, then retried on the next run. The read-only `check` and `diff` commands report drift without writing anything, so CI can gate on their exit codes.
verbatra reads and writes eight formats (i18next, vue-i18n, next-intl, and ngx-translate JSON, XLIFF, YAML, Flutter ARB, and Java/Spring properties) and translates through five providers (Anthropic, OpenAI, Gemini, DeepL, and any OpenAI-compatible server, including local ones) behind one interface. See [Formats](/docs/formats) and [Providers](/docs/providers).
## The packages [#the-packages]
verbatra ships as three npm packages:
* **@verbatra/cli** provides the `verbatra` binary with eight commands: `init`, `translate`, `watch`, `check`, `diff`, `export`, `import`, and `studio`. It is a thin wrapper over the SDK, so everything it does is available programmatically too. Start here.
* **@verbatra/sdk** is the engine underneath: one-shot `translate()`, long-running `watch()`, read-only `check()` and `diff()`, the Excel workbook round-trip for human translators, and config loading. Use it directly from scripts, build tools, or CI. See [the SDK](/docs/sdk).
* **@verbatra/studio** is Verbatra Studio, a local dashboard over your project with built-in editing and a review queue. The CLI starts it with `verbatra studio`; provider spend from Studio stays off unless you pass `--allow-spend`. See [Review in Studio](/docs/review-in-studio).
## Where to go next [#where-to-go-next]
---
# How it works (https://verbatra.kreitz-webdev.de/docs/how-it-works)
A run is a pipeline: read the source locale, diff each target against the lock file, send only what
needs work to the provider, check what comes back, write, and record the result. This page walks
that pipeline so you know exactly what happens when you run `verbatra translate`.
A run starts from your validated config; see [Config file](/docs/config-file) for where it is found
and what it contains. Everything below is the same whether the run came from the CLI, the SDK, or
`watch`.
## Read the source locale [#read-the-source-locale]
The format adapter reads the source locale file into a neutral set of keyed entries, so the rest of
the pipeline works the same for every format. A missing source file fails the run with
`SOURCE_UNREADABLE`; a file the adapter cannot parse fails with `SOURCE_INVALID`. While reading, the
adapter also flags source keys whose values are invalid for the format's message syntax (invalid
ICU): those keys ride along but are set aside instead of being sent to the provider in a broken
state.
## Diff against the lock baseline [#diff-against-the-lock-baseline]
This diff exists so a run is incremental: the provider is called only for strings that actually
need work. verbatra reads the existing target locale file (an absent file counts as empty) and
diffs the source against it, using [the lock file](/docs/the-lock-file) as the baseline of what was
translated before. Every source key lands in one of four buckets:
* missing: the target does not have the key yet.
* changed: the source value no longer matches the hash recorded in the lock.
* unchanged: the source value still matches the recorded hash.
* orphaned: the key is in the target but no longer in the source.
Only missing and changed keys become translation candidates. Unchanged keys are left alone.
Orphaned keys are reported and, by default, left in place; with `--prune` (or `prune: true` in the
config) exactly those keys are removed from the target file and the lock, and nothing else.
## Batch to the provider [#batch-to-the-provider]
Candidates go to your configured provider in sequential sub-batches of at most `maxBatchSize`
(default 50), so one oversized request cannot take down a whole locale. Each sub-batch carries the
entries plus your optional `glossary` and `tone`. If a sub-batch call fails (a rate limit, a
timeout, a revoked key), its keys are withheld for this run and picked up again next run while the
other sub-batches keep making progress. See [Providers](/docs/providers) for how each provider
applies the inputs.
## Validate the response [#validate-the-response]
Provider output is never trusted as free text. For the LLM providers (Anthropic, OpenAI, Gemini,
and openai-compatible), the response is schema-bound data, validated before anything else happens:
a response that is malformed, or that contains a key that was never requested, fails the
sub-batch, and a requested key that comes back missing or duplicated is re-requested once; if it
is still unresolved after that, the key is treated as not translated this run and retried on the
next one. DeepL is a machine-translation API without this response layer, but its results go
through the same integrity checks below.
## Check placeholder and ICU integrity [#check-placeholder-and-icu-integrity]
A translation that passed schema validation can still be broken, so verbatra recomputes its own
accept-or-reject decision from the candidate value itself: it never trusts the provider's report.
Each accepted candidate must carry the same placeholders as its source, and for the ICU formats it
must still be a valid message. A candidate that fails either check is withheld instead of written,
surfaces on the run summary, and is retried next run. See
[Translation safety](/docs/translation-safety) for the full safety model.
## Write through the adapter [#write-through-the-adapter]
Accepted translations are merged into the target and written by the format adapter, atomically, in
document key order: a key already in the target keeps its position, and a new key appends where the
source puts it. An interrupted write never leaves a half-finished locale file, and a formatter or
code reviewer sees stable, minimal diffs.
## Update the lock [#update-the-lock]
After a locale is written, its lock entries are refreshed with the current source hashes, with one
deliberate exception: a key withheld this run keeps its prior hash, so the next run still sees it
as needing work. Each locale's write happens under a cross-process write lock, so two overlapping
runs never diff against a stale baseline or pay for the same provider call twice. The details live
in [The lock file](/docs/the-lock-file).
## One locale at a time, failures as data [#one-locale-at-a-time-failures-as-data]
Each target locale runs on its own: one locale failing does not stop the others. The run returns a
summary per locale (translated, unchanged, orphaned, withheld, flagged for review, notices), and a
dry run (`--dry-run`) produces the same summary shape after the diff step, without constructing a
provider or writing anything.
---
# The cache (https://verbatra.kreitz-webdev.de/docs/the-cache)
verbatra keeps a local translation-memory cache, `verbatra.cache.json`, so a string it has already translated is never sent to a provider twice, even when its key changes. Where [the lock file](/docs/the-lock-file) tracks whether a key is up to date, the cache is keyed by content: it remembers the translation for a piece of source text, independent of which key holds it.
## What it reuses [#what-it-reuses]
On a run, before a key goes to the provider, verbatra looks it up in the cache by the hash of its current source content. A hit is served for free, so you never pay for it again. Because the lookup is by content, reuse survives cases the lock file cannot:
* **A renamed key.** Move a string from `home.title` to `landing.title` and its translation is reused, not re-translated.
* **Duplicate content.** Two keys with byte-identical source text share one translation; the second is a cache hit.
A reused value is not trusted blindly. It still passes the same [placeholder and ICU integrity checks](/docs/translation-safety) against the current source before it is written, so a cached value that no longer fits its key is discarded and the key is translated fresh. Cache hits are reported per locale in the run summary, separate from the keys translated this run.
## The fingerprint [#the-fingerprint]
Every cached value is scoped to the translation context it was produced in: a short fingerprint over the provider, the model, the tone, and the glossary. Change any of them and prior entries stop matching, so a new tone or an edited glossary re-translates rather than serving a stale result. The format is deliberately not part of the fingerprint; a format change is caught by the integrity check on each hit instead.
## The file [#the-file]
`verbatra.cache.json` is a local, regenerable sidecar next to your lock file. `verbatra init` adds it to `.gitignore`, and you should keep it out of version control: unlike the lock file it is a cost optimization, not a shared baseline. It never fails a run. A missing, corrupt, oversized, or unrecognized-version file degrades to an empty cache and is simply rebuilt on the next run. To rebuild it from scratch, delete the file.
## Bypassing it [#bypassing-it]
Pass `--no-cache` to [`translate`](/docs/cli/translate) or [`watch`](/docs/cli/watch) to skip the cache for a run: verbatra makes exactly the provider calls it would with no cache present, and leaves any existing cache file untouched. From the SDK, set `cache: false` on the `translate` or `watch` input. A dry run never reads or writes the cache regardless.
## Limitation [#limitation]
Synthesized plural forms are not cached. When `generatePlurals` fills in a CLDR plural category the source lacks, that generated value is not recorded for reuse, so whenever a run does synthesize it the value comes from the provider, never the cache.
---
# The lock file (https://verbatra.kreitz-webdev.de/docs/the-lock-file)
The lock file, `verbatra.lock.json`, exists so a run can tell what already got translated and skip
it: it is the baseline every run diffs against, and it is what makes verbatra incremental. This
page covers what it stores, how it changes, and what happens when it is contended, missing, or
corrupt.
## What it records [#what-it-records]
For every target locale, the lock file stores one content hash per translated key, computed from
the source value that produced the existing translation. It contains no translations and no
secrets: just a `version` field and hashes. The hash normalizes Unicode to NFC and line endings to
LF, so re-saving a source file with different normalization or CRLF endings does not mark anything
as changed.
## How drift is detected [#how-drift-is-detected]
When a run diffs a target locale, a source key absent from the target is missing. For a key the
target does have, the current source hash is compared against the hash the lock recorded: a
difference means the source drifted since the key was last translated, so it is changed and gets
retranslated; a match means it is unchanged and never sent to the provider. That is why a second
run with no source edits calls the provider for nothing, and why stale translations cannot hide
behind a key that merely exists. The full pipeline is on [How it works](/docs/how-it-works).
## How it is updated [#how-it-is-updated]
The lock is updated per locale, and how depends on what ran:
* A full run (translate, watch, or a workbook import) replaces that locale's entries wholesale with
the run's authoritative result.
* A single-key action (such as a retranslate from Studio) merges only that key's entry, leaving
every other recorded key untouched.
In both cases one exception applies: a key withheld this run (a failed integrity check, a failed
provider call, or an invalid-ICU source) keeps its prior hash, so the next run still sees it as
needing work and retries it instead of recording it as done. Orphaned keys get no entry. The file
is serialized with sorted keys, so its diffs stay stable and reviewable.
## The write lock [#the-write-lock]
Two runs touching the same locale at the same time (a second terminal, a CI job, a Studio action)
could otherwise both diff a stale baseline and both pay for the same provider call. To prevent
that, every write to a locale and its lock entries happens under a cross-process write lock scoped
to that one locale; a second writer for the same locale waits, then re-reads the lock file fresh
and diffs against a baseline that already includes the first writer's result. Different locales do
not block each other.
If a lock cannot be acquired, the run fails with `LOCK_CONTENDED` and names the lock file's path
(under the gitignored `.verbatra-local/` directory). If no verbatra process is running, that file
was left behind by a killed one: delete it and retry.
## Missing or corrupt [#missing-or-corrupt]
A missing lock file is not an error: it counts as a first run, where every source key is missing.
A lock file that is present but unparseable, structurally wrong, oversized, or at an unsupported
version fails the run with `LOCK_FILE_INVALID` instead of being silently overwritten, so you can
inspect or restore it rather than losing the baseline.
## Commit it [#commit-it]
Commit `verbatra.lock.json` alongside your locale files. It is the shared baseline your teammates
and your CI diff against; keeping it in version control is what makes incremental runs reproducible
across machines. Never hand-edit it.
---
# Translation safety (https://verbatra.kreitz-webdev.de/docs/translation-safety)
verbatra does not trust machine output, and it does not trust the strings it translates either.
Every result passes an integrity gate before it can be written, everything the gate rejects
surfaces as data instead of failing the run, and secrets are kept out of every error path. This
page covers each layer of that model.
## Untrusted strings and the prompt-injection boundary [#untrusted-strings-and-the-prompt-injection-boundary]
A translatable string can contain text that looks like an instruction (a UI string that literally
reads "ignore your instructions"), so verbatra treats every one as untrusted input. The system
rules sent to an LLM provider are compile-time constants. All variable input, the entries to
translate along with your glossary and tone, travels only in the user-turn JSON data payload,
never spliced into the instruction channel. Provider output is constrained to a schema and
validated as data: a malformed response fails, and a response containing a key that was never
requested fails the whole sub-batch, because there is no safe partial accept around invented
content.
## The integrity gate [#the-integrity-gate]
A schema-valid translation can still be broken, so one gate recomputes the accept-or-reject
decision directly from the candidate value; the provider's own integrity report is never trusted.
The same gate runs on every write path: a provider-sourced translation, a workbook import row, and
a human-typed edit in Studio.
* Placeholder integrity: the translated value must carry the same placeholders as its source,
compared as a multiset (counts matter, order does not). Drop, add, or alter one and the candidate
is rejected. What counts as a placeholder is defined per format; see [Formats](/docs/formats).
* ICU integrity: for the ICU formats (next-intl and ARB), the translated value must still parse as
a valid message. A candidate that keeps every placeholder but breaks the plural or select
structure is rejected too. For these formats the placeholder comparison is also branch-aware: a
placeholder fabricated inside one plural branch is caught even if it legitimately exists in
another.
A rejected candidate is withheld as data, never written: it appears on the run summary as an
integrity mismatch, keeps its prior hash in [the lock file](/docs/the-lock-file), and is retried
next run. Withheld is also kept distinct from failed: a key nothing was translated for (the
provider call threw, or the key was still missing after the bounded repair round) is reported as a
provider failure, not an integrity mismatch. Both retry the same way.
On the source side, a source key whose own value is invalid ICU is set aside before translation
and reported, instead of being sent to the provider in a broken state.
## Review flags [#review-flags]
Passing the gate proves a translation is structurally safe, not that it is good, so a second,
advisory layer flags suspicious results for a human. A review flag never withholds a key and
carries one or more reason codes:
* `LENGTH_RATIO_OUTLIER`: the translation is far shorter or longer than its source (below 0.35x or
above 3.0x trimmed length; very short sources are skipped). A nudge to look, not a verdict.
* `EQUALS_SOURCE`: the translation equals the source verbatim in a different target locale, and
the source actually had translatable text.
* `GLOSSARY_TERM_MISSED`: a configured glossary term occurs in the source but its mapped target
term is absent from the translation.
* `INTEGRITY_REORDERED`: the placeholder set matched exactly but landed in a different order than
the source. Only ever fires on a translation the gate already accepted.
* `PROVIDER_DEGRADED`: the batch this key came from was gracefully degraded by the provider (a
formality downgrade or an ignored glossary; in the current provider set, DeepL only).
Flagged keys surface per locale as `needsReview` on the run summary, and they feed the
needs-review queue: the CLI prints the count, the Excel workbook carries the same signal in its
review columns, and Studio's Review page lists the flagged keys for working through; see
[Review in Studio](/docs/review-in-studio). The only way to clear a flag is to fix the underlying
translation.
## The token budget [#the-token-budget]
Provider spend should have a ceiling you set, not one you discover on the invoice. With
`maxTokens` configured, verbatra tracks cumulative token usage across the whole run and checks it
after each completed sub-batch. What happens at the ceiling is `budgetBehavior`:
* `warn` (the default): the run continues and the summary flags that the budget was exceeded.
* `stop`: no further provider calls are made; every remaining candidate is withheld as
`budgetWithheld`, keeps its prior lock hash, and is retried next run.
The sub-batch that crosses the ceiling is never undone, and the budget never changes the command's
exit code. With a provider that does not report token usage (DeepL), the guardrail is honestly
inert: the summary marks it unsupported instead of faking a count.
## Errors never leak [#errors-never-leak]
API keys come only from environment variables, never from config or arguments, so there is no key
in any value verbatra handles. When a provider SDK call fails, the raw error (which can carry
request headers or credentials) is discarded, never bound, logged, or re-thrown; in its place
verbatra raises a structured error with a stable code (such as `RATE_LIMITED`, `TIMEOUT`, or
`AUTH_FAILED`) and a static, secret-free message. The same rule holds across the run: notices and
summaries carry codes and counts, not secrets.
## Where you see it [#where-you-see-it]
All of this is data on the run summary, per locale: translated keys, integrity mismatches,
provider failures, budget-withheld keys, invalid-ICU source keys, review flags, and notices.
Nothing is hidden, and one locale's failure never stops the others. See
[How it works](/docs/how-it-works) for where each check sits in the pipeline.
---
# Config file (https://verbatra.kreitz-webdev.de/docs/config-file)
verbatra reads one small, non-secret config object and validates it before it does any work. This page is the full reference: every key, where the file can live, and how a bad config fails.
No API key is ever read from this file. Each provider reads its key from an environment variable, so the config stays safe to commit. See [Providers](/docs/providers).
## A minimal config [#a-minimal-config]
```ts
import { defineConfig } from "@verbatra/sdk";
export default defineConfig({
sourceLocale: "en",
targetLocales: ["de", "fr"],
format: "i18next-json",
files: {
pattern: "locales/{locale}.json",
},
provider: {
id: "gemini",
options: {
model: "gemini-2.5-flash", // example model id; use your provider's current one
maxOutputTokens: 4096,
},
},
});
```
`defineConfig` is an identity helper from `@verbatra/sdk`. It returns its argument unchanged and exists only to give you type inference and editor autocomplete.
When you author the config in TypeScript, your editor offers the selected provider's known model IDs for `options.model`, narrowed by the provider `id` you chose. The list comes from that provider's own SDK, so it stays current with the SDK version you have installed, and a model from another provider (for example a Claude model under `id: "gemini"`) is a type error. This is an authoring aid only: at runtime `model` is validated as a non-empty string, so a brand-new model the SDK has not listed yet still works even though the editor flags it. DeepL has no `model` field, and openai-compatible's `model` is whatever your server exposes, so neither is restricted.
## Every key [#every-key]
| Key | Type | Default | Purpose |
| ----------------- | ---------------------------------------- | -------- | ---------------------------------------------------------------------- |
| `sourceLocale` | string | required | The locale your source strings are written in. |
| `targetLocales` | string array | required | The locales to translate into. |
| `format` | string | required | The format adapter for your locale files. |
| `files.pattern` | string | required | The path to each locale file, with a `{locale}` token. |
| `provider` | object | required | The translation provider, selected by `id`. |
| `glossary` | object or string | none | Source terms to preferred target terms, inline or as a JSON file path. |
| `tone` | `"formal"`, `"informal"`, or `"neutral"` | none | The register translations should use. |
| `prune` | boolean | `false` | Remove orphaned keys from target files and the lock. |
| `generatePlurals` | boolean | `false` | Synthesize the CLDR plural forms a target language needs. |
| `maxBatchSize` | positive integer | `50` | The maximum entries sent in a single provider request. |
| `maxTokens` | positive integer | none | A whole-run token budget across every provider call. |
| `budgetBehavior` | `"warn"` or `"stop"` | `"warn"` | What happens once `maxTokens` is reached. |
Unknown top-level keys are rejected. This is also why an API key cannot live in the config: keys come from the environment, not the file.
### Locales and files [#locales-and-files]
* **`sourceLocale`** must be a non-empty string.
* **`targetLocales`** must contain at least one locale, must not include `sourceLocale`, and no two entries may be case-insensitively equal (for example `["de", "DE"]` is rejected), since each locale becomes its own Excel worksheet name on export.
* **`format`** is one of `i18next-json`, `vue-i18n-json`, `next-intl-json`, `ngx-translate-json`, `xliff`, `yaml`, `arb`, or `properties`. See [Formats](/docs/formats).
* **`files.pattern`** must contain the `{locale}` token, which verbatra replaces with each locale: `locales/{locale}.json` resolves to `locales/de.json`. Paths resolve against the working directory.
### provider [#provider]
An object selected by `id`: `anthropic`, `openai`, `gemini`, `deepl`, or `openai-compatible`. Each provider takes its own `options` (model, token limit, and provider-specific fields), and unknown option keys are rejected. The per-provider options are documented on [Providers](/docs/providers).
### glossary [#glossary]
A map of source terms to preferred target terms, either inline or as a path to a JSON file holding the same shape (a flat object of string keys to string values):
```ts
export default defineConfig({
// ...
glossary: {
"Sign in": "Anmelden",
},
// or: glossary: "glossary.json",
});
```
The two forms are mutually exclusive; there is no merge between them. A relative file path resolves against the directory of the loaded config file, or against the working directory when the config is passed as an in-memory override. The file must be UTF-8 encoded and no larger than 1 MiB, and it is read once when the config loads: verbatra does not watch it for changes, so edit it and restart to pick up an update. A missing file, invalid JSON, or a value that is not a flat string map fails config loading with `CONFIG_INVALID`, naming the resolved path.
How the glossary is applied depends on the provider: the LLM providers receive the resolved map with the request and are instructed to treat its terms as binding, while DeepL ignores a term map and applies a glossary configured by id instead. See [Providers](/docs/providers).
### tone [#tone]
One of `"formal"`, `"informal"`, or `"neutral"`. The LLM providers receive it with the request and are instructed to honor it; DeepL maps it to its formality setting. See [Providers](/docs/providers) for the per-provider details, including DeepL's free-tier degradation.
### prune [#prune]
Off by default. When true, keys present in a target file but absent from the source (the diff's orphaned keys) are removed from the written file and [the lock file](/docs/the-lock-file). Only orphaned keys are ever removed. A per-run `prune` option on `translate` (the CLI `--prune` flag) overrides the config value.
### generatePlurals [#generateplurals]
Off by default. When true, verbatra synthesizes the CLDR plural forms a target language requires but the source does not supply (for example Polish few and many). Supported only for i18next-JSON projects translated by an LLM provider; DeepL, non-i18next formats, and unknown target languages fall back to the per-locale plural warning. There is no CLI flag, so set this in the config; the SDK `translate()` input accepts a per-run `generatePlurals` override that takes precedence.
### maxBatchSize [#maxbatchsize]
Default `50`. A locale's missing-plus-changed entries are split into sequential sub-batches no larger than this, so one oversized provider request cannot fail the whole locale. A failed sub-batch is withheld and retried next run while the others still make progress. Must be a positive integer; zero, negative, or fractional values are rejected. Config-only: there is no CLI flag and no per-run override.
### maxTokens and budgetBehavior [#maxtokens-and-budgetbehavior]
`maxTokens` is a whole-run token budget: input plus output tokens, summed across every provider call (main translation and plural generation alike) and across every target locale. It is checked after each completed sub-batch, never inside one, so the sub-batch whose completion crosses the ceiling has already been sent, is accepted normally, and its tokens still count: a post-hoc soft cap, not a hard pre-check. Config-only, no CLI flag.
`budgetBehavior` decides what happens once the ceiling is reached:
* `"warn"` (the default) flags the overrun and lets the run continue exactly as if no budget were configured.
* `"stop"` withholds every not-yet-attempted key for the rest of the run: the current locale's remaining candidates, and every later target locale's candidates entirely (their diff and orphan reporting still runs; only the provider call is skipped). Withheld keys keep their prior lock hash and are retried automatically next run, the same way a failed provider call already is.
A budget trip never changes the command's exit code, in either behavior. `budgetBehavior` without `maxTokens` is accepted and has no effect. Against a token-less provider (DeepL, which reports no usage to measure) the guardrail reports itself as inert (`supported: false` on the run summary's `budget`) rather than producing a false trip.
The top-level `maxTokens` is the whole-run budget described here. The Anthropic provider also has its own `options.maxTokens`, which caps a single response. They are unrelated; see [Providers](/docs/providers).
## Discovery order [#discovery-order]
verbatra searches upward from the working directory and uses the first source it finds. Multiple present sources are not an error; the first match wins. The search order is:
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`
To load one explicit file instead of searching, pass `--config `; a relative path resolves against the working directory. In the SDK, an in-memory `configOverride` takes precedence over an explicit `configPath`, which takes precedence over the search. See [Programmatic API](/docs/programmatic-api).
## The working directory and .env [#the-working-directory-and-env]
Run verbatra from your project root, or pass `--cwd` pointing at it. The working directory is the single anchor for everything verbatra resolves: the config search starts there and walks upward, `.env.local` and `.env` are loaded from there, and each locale file path is resolved against it. Keeping all three in the same place is what makes a plain `verbatra translate` work.
Environment precedence is, highest first: a variable already set in your real environment, then `.env.local`, then `.env`. A variable already present is never overwritten.
## When loading fails [#when-loading-fails]
Config loading fails with one of two structured error codes:
* **`CONFIG_NOT_FOUND`**: the search found no config in any of the places above, or the explicit `--config` path does not exist.
* **`CONFIG_INVALID`**: a config was found but could not be used. This covers a file that fails to parse, a config that fails schema validation, and a glossary file path that could not be resolved. The message lists every validation issue with its key path, and an unrecognized top-level key gains the hint that API keys are read from the environment, not the config. A raw parser or file-system error never escapes.
The CLI reports either failure and exits with code `2`, the boundary-error exit code. See [CI and exit codes](/docs/ci-and-exit-codes).
---
# Formats (https://verbatra.kreitz-webdev.de/docs/formats)
verbatra works on your locale files through a format adapter. Each adapter reads a file into one format-neutral shape that the diff, hashing, and integrity checks run on, then writes it back in the file's original shape. You pick the format with the `format` field in your [config](/docs/config-file). Eight are supported: four JSON flavors, plus XLIFF, YAML, ARB, and Java/Spring properties.
## The four JSON formats [#the-four-json-formats]
| `format` | For | Placeholders | Plurals | ICU |
| -------------------- | ------------- | ------------------------------------------- | ----------------------------- | --- |
| `i18next-json` | i18next | `{{name}}` and `$t(...)` nesting references | CLDR plural suffix on the key | no |
| `vue-i18n-json` | vue-i18n | `{name}`, `{0}` | pipe in the value | no |
| `next-intl-json` | next-intl | ICU argument and tag names | ICU plural or selectordinal | yes |
| `ngx-translate-json` | ngx-translate | `{{name}}` | none | no |
All four read nested JSON objects of string leaves. Their differences are the message syntax:
* **i18next** uses `{{double-brace}}` interpolation and decides plural from the CLDR suffix on the key (`_zero`, `_one`, `_two`, `_few`, `_many`, `_other`). It additionally extracts and guards `$t()` nesting references (which splice another key's content into the value), for example `$t(common.foo)` and `$t(common.foo, { options })`, as placeholders, so a translation that drops or alters one fails the integrity check. Two limitations: nested parentheses inside the `$t()` options are not supported, and only the default `$t(` prefix is recognized.
* **vue-i18n** uses single-brace `{name}` and `{0}` tokens and decides plural from a pipe in the value.
* **next-intl** values are ICU MessageFormat: the placeholders are the ICU argument and rich-text tag names, plural follows an ICU plural or selectordinal argument, and the ICU body is kept verbatim through the pipeline. A value that fails to parse as ICU is reported as invalid and skipped, never thrown.
* **ngx-translate** shares i18next's `{{double-brace}}` interpolation but has no `$t()` nesting and no built-in plural or ICU. Its files may be flat (dotted keys) or nested, and verbatra preserves whichever style the file uses on write; a new target file is written nested. Uniquely, a file that mixes flat dotted keys with nested objects fails the read with `MIXED_STRUCTURE`, since such a file is ambiguous rather than guessable.
## XLIFF [#xliff]
The `xliff` format covers `.xlf` and `.xliff` files, XLIFF 1.2 (`file/body/trans-unit`) and 2.0 (`file/unit/segment`). Unlike the tree formats, an XLIFF document is a flat list of trans-units.
* **Keys.** Each entry is keyed by its trans-unit `id`, falling back to `resname`. A unit with neither falls back to a positional key, which shifts when earlier units are added or removed, so give every unit a stable `id`. Two units resolving to the same key (typically a duplicate `id`) fail the read with `INVALID_STRUCTURE` rather than silently keeping one.
* **Values.** The value comes from `` when present, otherwise ``.
* **Writes update targets in place.** verbatra writes each value into its `` and leaves ``, attributes, and `` elements untouched, so the document round-trips. Because a flat key/value map cannot reconstruct an XLIFF document, the destination file must already exist: verbatra updates targets in a pre-seeded target file (the standard XLIFF workflow) and does not create a missing one; a missing destination fails with `INVALID_STRUCTURE`.
* **Inline markup.** Inline placeholder elements (`x`, `g`, `bx`, `ex`, `ph`, `it`, `mrk`) and single-brace `{name}` interpolation are extracted as placeholders and guarded across translation. On write, only those allow-listed elements (with their own minimal, non-executable attributes) survive as live markup in ``; anything else in a translated value, including an unexpected element or a namespace mismatch, is written as plain text instead.
* **Notes as context.** A trans-unit's `` (in 2.0, the unit's ``, shared by every segment in the unit) is read as developer context: it reaches the provider as disambiguation context and appears in the [`Context` column](/docs/manual-translation) of an exported workbook. It is read-only and never written back.
## YAML [#yaml]
The `yaml` format covers `.yml` and `.yaml` files: a nested tree in YAML syntax, the same shape as a nested JSON file, handled by the same tree pipeline. It assumes i18next-compatible `{{double-brace}}` interpolation and detects by extension only.
* YAML comments are not carried across a write, the same way JSON has no comment concept.
* Scalar non-string keys keep their string form (`1:` reads as `"1"`, `true:` as `"true"`).
* A composite key (a map or sequence used as a mapping key) has no faithful string form, so the read fails with `INVALID_STRUCTURE` instead of silently collapsing it to text.
* Malformed syntax is reported as `INVALID_YAML`, and anchor-alias expansion is bounded, so a hostile document cannot blow up the parse.
## ARB [#arb]
The `arb` format covers Flutter's `.arb` files: JSON with a flat object of message keys alongside `@`-prefixed metadata (`@key` per-message metadata and `@@`-prefixed globals such as `@@locale`).
* **Metadata is preserved, never translated.** `@`-prefixed keys are stripped before translation and merged back on write in their document position. A destination file that exists but is corrupt fails the write rather than silently erasing its metadata.
* **Messages are ICU.** Placeholders, plural handling, and ICU validation work exactly as they do for `next-intl-json`.
* **Descriptions become context.** Each `@key.description` is read as developer context for that message: it reaches the provider as disambiguation context (never as text to translate) and appears in the [`Context` column](/docs/manual-translation) of an exported workbook. It is read-only and never written back.
## Properties [#properties]
The `properties` format covers Java and Spring `.properties` files, detected by the `.properties` extension: a flat list of key/value lines. Keys are kept verbatim as flat keys, never split into a tree.
* **Separators and comments.** A key is separated from its value by `=`, `:`, or whitespace, with any spaces around the separator ignored, matching `java.util.Properties`. A line whose first non-blank character is `#` or `!` is a comment.
* **Continuations and escapes.** A line ending in a backslash continues onto the next. The standard escapes (`\t`, `\n`, `\r`, `\f`, `\\`, an escaped separator or comment character) and `\uXXXX` are decoded on read. Input is read as UTF-8; on write, every non-ASCII character is emitted as an ASCII-safe `\uXXXX` escape, so the file still loads under a legacy ISO-8859-1 reader.
* **Order, comments, and blank lines are preserved.** A write re-reads the destination and keeps its key order, comments, and blank lines: each existing key line is rewritten in place with its new value, and a key the file does not yet have is appended in source order. A duplicate key keeps its first position and takes the last value, matching `Properties.load`.
* **Placeholders are MessageFormat.** Values are read as `java.text.MessageFormat`, so `{0}`, the typed form `{0,number}`, the styled form `{0,number,integer}`, and named arguments such as `{count}` are extracted and guarded across translation. Sub-message arguments (`plural`, `select`, `selectordinal`, `choice`) are recognized, so translating the branch text stays a match while dropping or renaming an argument does not.
* **Limitation: single quotes are not interpreted.** MessageFormat single-quote quoting is not honored, so a quoted literal such as `'{0}'` is still read as a placeholder. This is deliberate, so an ordinary apostrophe in translated text never swallows a following placeholder.
## Document key order [#document-key-order]
Writes preserve your document's key order. The JSON family, YAML, and ARB round-trip keys exactly in document order:
* An integer-like key such as `"2"`, `"10"`, or `"404"` keeps its position instead of being hoisted to the front and re-sorted, so a file keyed by numeric ids, HTTP status codes, or years stays in its own order.
* A key that a `translate` run adds to a target file appends after the target's existing keys, following the source document's order, rather than being inserted alphabetically.
* ARB metadata blocks round-trip in their document position too.
XLIFF is unaffected: it updates existing `` elements in place, so the document's order was never rebuilt in the first place.
## Dotted keys and collisions [#dotted-keys-and-collisions]
For `i18next-json`, `vue-i18n-json`, and `next-intl-json`, a literal dotted leaf (a key such as `"foo.bar"` used as a single leaf) round-trips losslessly: verbatra reads it and writes it back with its on-disk shape preserved, not re-nested into `foo` then `bar`. Real nested paths stay nested.
The one case that fails by design is a genuine collision, where one file expresses the same effective path both as a literal dotted leaf and as a real nested path (for example `"foo.bar"` alongside `"foo": { "bar": ... }`). That read fails with `INVALID_STRUCTURE` rather than guessing or corrupting data.
`ngx-translate-json` treats a dotted key as a nested path rather than a literal leaf, so there is no dotted-vs-literal ambiguity to preserve, but it gets the same collision safety: a dotted key whose path collides with a real nested path, including one being an ancestor of the other, fails with `INVALID_STRUCTURE` instead of silently overwriting a value.
## How files are read and written [#how-files-are-read-and-written]
verbatra caps input size and nesting depth on read, and resists a file changing underneath it. It writes atomically: it writes to a temporary file, then renames it into place, so an interrupted write never leaves a half-finished locale file. When verbatra cannot handle a file, it raises a structured, secret-free error with a stable code rather than a raw one: `INVALID_JSON`, `INVALID_YAML`, or `INVALID_XML` for malformed syntax, `INVALID_STRUCTURE` for a parseable file of the wrong shape, `MAX_DEPTH_EXCEEDED` and `INPUT_TOO_LARGE` for the caps, and `MIXED_STRUCTURE` for ngx-translate's mixed-style case.
A tree-based locale file (the JSON family, YAML, and ARB) can carry a leaf that is not a string, for example a `count: 5` or `enabled: true` value sitting next to the translatable keys. verbatra accepts these: a leaf can be a string, a number, a boolean, or null, and only a leaf of another type, such as an array, fails with `INVALID_STRUCTURE`. A non-string leaf is excluded from the translatable set: it is never translated, hashed, diffed, or checked for placeholders or ICU. Exclusion is not preservation: if verbatra later writes that same locale file, the write is rebuilt from the strings it manages, so a non-string leaf present at read time is not carried into the output. If such a value needs to survive a rewrite, keep it out of the files verbatra writes to. XLIFF trans-unit values and `.properties` values are always strings, so this does not apply there.
## Why the format matters [#why-the-format-matters]
The format tells verbatra two things it needs for a safe run. First, the placeholder syntax, so the [placeholder integrity check](/docs/translation-safety) knows what to compare before and after translation. Second, for the ICU formats, which values to validate, so an invalid ICU source key is skipped rather than sent in a broken state. The wrong format compares the wrong tokens, so match it to your i18n library: `i18next-json` for i18next, `vue-i18n-json` for vue-i18n, `next-intl-json` for next-intl, `ngx-translate-json` for ngx-translate, `xliff` for XLIFF files, `yaml` for YAML-based i18n, `arb` for Flutter, and `properties` for Java or Spring `.properties` files.
---
# Providers (https://verbatra.kreitz-webdev.de/docs/providers)
verbatra ships five providers behind one interface: Anthropic, OpenAI, Gemini, and openai-compatible (a local or self-hosted OpenAI-compatible server) are large language models, and DeepL is a machine-translation service. You select one in the `provider` block of your [config](/docs/config-file), by `id`. Every model id on this page is an example, not a recommendation: model catalogs change, so check your provider's current list.
## Which one should I pick? [#which-one-should-i-pick]
Any provider works with any format and command; the choice is about cost, quality, privacy, and how you get a key.
* **Small or side project, want it free?** Use **Gemini**. It has a real free API tier, so you can translate a whole app at zero cost. Grab a key from [Google AI Studio](https://aistudio.google.com/apikey) and set `GEMINI_API_KEY`. The free tier is rate-limited (see the note below), which only matters on a large first-time run.
* **Best translation quality for a production app?** Use **Anthropic** or **OpenAI**. Both are paid LLMs and read `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`.
* **High volume, glossary-driven, or no LLM in the loop?** Use **DeepL**. It is a dedicated machine-translation API with a free and a paid tier, and supports a native glossary by id. It does not translate placeholder-bearing strings (see the note below); for those, use an LLM provider.
* **Offline, cost-free, or your strings must never leave your network?** Use **openai-compatible**. It points verbatra at a local or self-hosted inference server (LM Studio, Ollama, vLLM) instead of a hosted API. No account, no API key, and no network egress beyond your own machine or LAN.
Not sure? Start on Gemini's free tier, then switch providers later by editing one `id` in your config. Nothing else changes.
## Keys come from the environment [#keys-come-from-the-environment]
verbatra never reads an API key from the config. Each hosted provider reads exactly one environment variable:
| Provider id | Environment variable |
| ----------- | -------------------- |
| `anthropic` | `ANTHROPIC_API_KEY` |
| `openai` | `OPENAI_API_KEY` |
| `gemini` | `GEMINI_API_KEY` |
| `deepl` | `DEEPL_API_KEY` |
Set the variable in `.env` (which `verbatra init` gitignores) or export it in your shell or CI secret store. When the variable is unset or empty, the provider fails with a structured `MISSING_API_KEY` error whose message names the variable but never includes a key value. Never commit a real key.
`openai-compatible` is different: most local servers need no key at all, so it is not in this table. See its own section below for how its key resolution works.
## Anthropic [#anthropic]
```ts
provider: {
id: "anthropic",
options: {
model: "claude-sonnet-4-6", // example model id
maxTokens: 4096,
},
}
```
Reads `ANTHROPIC_API_KEY`. `model` and `maxTokens` are both required. `maxTokens` caps the tokens a single response may produce; it is this provider's output-token limit option (the others call it `maxOutputTokens`).
## OpenAI [#openai]
```ts
provider: {
id: "openai",
options: {
model: "gpt-5.4-mini", // example model id
maxOutputTokens: 4096,
},
}
```
Reads `OPENAI_API_KEY`. `model` and `maxOutputTokens` are both required.
## Gemini [#gemini]
```ts
provider: {
id: "gemini",
options: {
model: "gemini-2.5-flash", // example model id
maxOutputTokens: 4096,
},
}
```
Reads `GEMINI_API_KEY`. `model` and `maxOutputTokens` are both required.
Gemini is the recommended starting point for small projects because its API has a free tier. Create a key at [Google AI Studio](https://aistudio.google.com/apikey), no billing required. The free tier caps requests per minute and per day (the exact numbers depend on the model and change over time, so check Google's current limits), and verbatra splits work into sequential sub-batches, so a first-time run over many locales can hit those caps. A brief rate-limit or server error is retried automatically with a short backoff, but that only smooths over momentary hiccups, not a sustained cap. If you do hit the cap, translate one locale at a time or lower `maxBatchSize`, then re-run: verbatra only picks up what is still missing, so runs are safe to repeat.
## DeepL [#deepl]
```ts
provider: {
id: "deepl",
options: {},
}
```
Reads `DEEPL_API_KEY`. DeepL is a machine-translation API, not an LLM: it has no `model` field and no output-token limit. The only option is an optional `glossaryId` naming a glossary you have already created in DeepL:
```ts
provider: {
id: "deepl",
options: {
glossaryId: "",
},
}
```
DeepL degrades gracefully instead of failing when it cannot honor a setting, and reports each degradation as a notice on the run:
* A configured `glossary` term map is not applied (DeepL only uses a pre-created glossary id), reported as `GLOSSARY_IGNORED`.
* `tone` maps to DeepL's formality: `formal` becomes more formal, `informal` becomes less formal, `neutral` or an absent tone is omitted. On a free-tier key, formality is not supported, so a non-default tone degrades to the default, reported as `FORMALITY_DOWNGRADED`.
DeepL cannot preserve placeholders or ICU tokens, so it does not send strings that contain them: those entries are withheld (left untranslated) and reported with a `PLACEHOLDER_UNSUPPORTED` notice. Placeholder-free strings translate normally. To translate placeholder-bearing strings, use an LLM provider (Anthropic, OpenAI, Gemini, or openai-compatible).
## openai-compatible [#openai-compatible]
Points verbatra at a server that speaks the OpenAI Chat Completions API: LM Studio, Ollama, vLLM, and similar all work. The id names the wire protocol, not where the server runs, so a hosted API that speaks the same protocol fits here too.
```ts
provider: {
id: "openai-compatible",
options: {
baseUrl: "http://192.168.178.74:1234/v1",
model: "qwen2.5-14b-instruct", // example: whatever your server exposes
maxOutputTokens: 1024,
},
}
```
`baseUrl`, `model`, and `maxOutputTokens` are required. Unlike every other provider, `baseUrl` lives in config rather than the environment: it is a network address you already know (a LAN IP or `localhost`), not a secret. It must be a valid absolute URL using the `http` or `https` scheme; anything else, including a missing scheme, fails config validation immediately.
`baseUrl` must include your server's API path segment, typically `/v1`: LM Studio, Ollama, and vLLM all serve their OpenAI-compatible routes under `/v1`. A `baseUrl` without it is still a syntactically valid URL, so it passes config validation and instead fails at request time by reaching the wrong path.
A hosted API that speaks the same protocol works the same way. Mistral's chat completions API is one example:
```ts
provider: {
id: "openai-compatible",
options: {
baseUrl: "https://api.mistral.ai/v1",
model: "mistral-large-latest", // example: check Mistral's current model list
maxOutputTokens: 4096,
apiKeyEnvVar: "MISTRAL_API_KEY",
},
}
```
`apiKeyEnvVar` names the environment variable holding your key for that server. This is the same `openai-compatible` provider pointed at a different endpoint, not a dedicated Mistral provider.
### Keys are optional [#keys-are-optional]
Most local inference servers need no API key at all, so `openai-compatible` resolves its key in three tiers, in order:
1. **`apiKeyEnvVar`**, an optional config field naming an environment variable to read a real key from. If you set this field and its named variable is unset or empty, verbatra fails with `MISSING_API_KEY` instead of silently falling back, since you explicitly said a key is required.
2. **`OPENAI_COMPATIBLE_API_KEY`**, a convention environment variable, read when `apiKeyEnvVar` is not set. If it is set and non-empty, verbatra uses it; if not, verbatra moves on without an error.
3. **The placeholder `"local"`**, sent when neither of the above resolves anything. This is a fixed, non-secret string, not a real key, and is exactly what makes a keyless local server work with zero configuration.
`apiKeyEnvVar` can never name `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, or `DEEPL_API_KEY`: config validation rejects that outright, so a config cannot point this provider at a hosted provider's key by name. The client also never reads `OPENAI_API_KEY` itself and always passes an explicit key value to the underlying SDK, so a hosted key can never reach a custom `baseUrl`.
verbatra allows plaintext `http://` to any host, including a LAN address, with no restriction beyond the http or https scheme. This is intentional and safe in the common keyless case: nothing secret is on the wire.
It is a real risk only when you also configure a key (via `apiKeyEnvVar` or `OPENAI_COMPATIBLE_API_KEY`) **and** `baseUrl` is plaintext `http://` to a non-loopback host: that key then travels over your network unencrypted. If your server requires a real key and is reachable beyond localhost, use `https://` for it. verbatra does not detect or warn about this combination today; treat it as your own responsibility.
### Choosing a local model [#choosing-a-local-model]
Prefer an instruction-tuned model without reasoning and with strong multilingual coverage. verbatra asks for a single strict JSON object and validates it, so a model that reliably follows format instructions is what you want. `qwen2.5-14b-instruct` is a reasonable default; pick the largest such model your hardware runs well.
Reasoning ("thinking") models and heavily quantized (qat or low-bit) builds are the usual sources of failure here: they tend to emit reasoning prose around the answer or return truncated JSON, and either one fails verbatra's schema and integrity checks. If a reasoning-capable model is unavoidable, give it room to both think and answer: see [Output-token limits](#output-token-limits) for the `maxOutputTokens` and `maxBatchSize` tuning.
A bad local model fails loudly, not silently: verbatra validates every provider response against a canonical schema plus placeholder and ICU integrity, so a model that emits reasoning prose or truncated JSON is rejected with an `INVALID_RESPONSE` or `OUTPUT_TRUNCATED` error instead of writing a corrupt translation.
### Tolerant parsing for local and smaller models [#tolerant-parsing-for-local-and-smaller-models]
`openai-compatible` requests the same strict, schema-constrained response format as the hosted `openai` provider. The one difference is on the way back: local and smaller models are more likely to wrap an otherwise-correct answer in surrounding prose or a Markdown code fence despite being asked not to, so `openai-compatible` extracts the first balanced JSON object anywhere in the response before parsing, where the hosted provider does not. Its output still runs through the exact same schema validation and placeholder and ICU integrity checks as every other provider: a local model's output is untrusted input, and a malformed or placeholder-mismatched response is rejected the same way.
`verbatra init` does not offer `openai-compatible` as a scaffold option, since it has no single required environment variable to prompt for. Add the provider block above to your config by hand; everything else on this page (glossary, tone, output-token limits) applies to it like any LLM provider.
## Tone and glossary across providers [#tone-and-glossary-across-providers]
The optional `tone` and `glossary` config fields (see [Config file](/docs/config-file)) are applied per provider:
* **Tone.** The LLM providers (Anthropic, OpenAI, Gemini, and openai-compatible) receive the tone with the request and are instructed to honor it. DeepL maps it to formality, with the free-tier degradation described above.
* **Glossary.** The LLM providers receive the term map and are instructed to treat its terms as binding. DeepL ignores a term map (with a `GLOSSARY_IGNORED` notice) and applies only its native `glossaryId`.
## Behavior every provider shares [#behavior-every-provider-shares]
### Batching [#batching]
A locale's work is split into sequential sub-batches of at most `maxBatchSize` entries (default 50; see [Config file](/docs/config-file)), so one oversized request cannot fail the whole locale. A failed sub-batch is withheld and retried automatically on the next run while the other sub-batches still make progress. DeepL additionally splits a sub-batch across as many requests as its own per-request caps require, transparently.
### Retries [#retries]
Transient failures (a rate-limit response or a server error) are retried automatically with a short backoff: the OpenAI, Anthropic, and DeepL SDK clients retry by default, and verbatra adds an equivalent retry of its own for Gemini, whose SDK does not. Retries smooth over momentary hiccups; a sustained failure still surfaces as a structured error.
### Structured errors [#structured-errors]
A provider failure never surfaces as a raw SDK error, which could carry request headers or a key. verbatra classifies each failure by its HTTP status or SDK error class and raises a structured error with a stable code and a fixed, secret-free message:
| Code | Meaning |
| ------------------ | --------------------------------------------------------------------------------------------------- |
| `MISSING_API_KEY` | The required environment variable is unset or empty. The message names the variable, never a value. |
| `RATE_LIMITED` | HTTP 429 or an SDK rate-limit error. Back off and retry later. |
| `TIMEOUT` | A network or request timeout; no response arrived in time. Retrying may help. |
| `AUTH_FAILED` | HTTP 401 or 403: the key is invalid, revoked, or lacks permission. Retrying will not help. |
| `OUTPUT_TRUNCATED` | The model hit its output-token limit; see below. |
| `INVALID_RESPONSE` | The output was malformed, incomplete, or failed reconciliation against the request. |
| `PROVIDER_REFUSED` | The model declined to answer. |
| `PROVIDER_BLOCKED` | The request or response was safety-blocked or filtered. |
| `PROVIDER_ERROR` | Anything unclassifiable, mapped to a static, secret-free message. |
A failure affects only its own sub-batch: the failed keys are withheld and picked up again on the next run.
### Output-token limits [#output-token-limits]
The four LLM providers cap how many tokens a single response may produce. When a model stops because it hit that cap, verbatra raises `OUTPUT_TRUNCATED` instead of a generic malformed-response failure, with a fixed message:
> The provider stopped because the output-token limit was reached. Reduce the batch size or raise the configured max output tokens.
Either lever resolves it: lower `maxBatchSize` in your config so each request produces a smaller response, or raise the per-response cap on the provider (`maxTokens` for Anthropic, `maxOutputTokens` for OpenAI, Gemini, and openai-compatible). DeepL is not affected: it has no output-token truncation case. verbatra also recovers on its own: when a sub-batch is truncated it re-splits that sub-batch into halves and retries, down toward a single entry, so the keys that fit a smaller request still get translated and only a lone entry that still overflows is withheld for the next run.
A reasoning (thinking) model spends its hidden reasoning tokens from the same output-token budget it uses to emit the JSON translations. It can exhaust that budget on reasoning alone and stop before writing any output, which surfaces as `OUTPUT_TRUNCATED`. If you use a reasoning model, give it plenty of headroom: prefer a higher `maxOutputTokens` (`maxTokens` for Anthropic) or a smaller `maxBatchSize` so each request has room for both the reasoning and the response.
### Review flags [#review-flags]
Every provider's accepted translations run through the same post-translation checks, and a suspicious result (for example one identical to its source, or one that missed a glossary term) is flagged into the needs-review queue rather than rejected. The reason codes and how to work the queue are covered in [Translation safety](/docs/translation-safety) and [Review in Studio](/docs/review-in-studio).
---
# Operate Studio with a browser agent (https://verbatra.kreitz-webdev.de/docs/agent-tools-in-studio)
Verbatra Studio is normally a human surface: you open the loopback dashboard and click through project state, drift, the needs-review queue, and edits. With the opt-in `--expose-agent-tools` flag, Studio also registers those same review actions as WebMCP tools, so a browser AI agent (for example Gemini or Claude running inside Chrome) sitting on the same open, authenticated tab can operate them in a structured way instead of guessing at the DOM.
This is for operating an existing verbatra project conversationally: inspecting state and drift, working the needs-review queue, editing entries, and (only when you also allow spend) retranslating and translating pending keys. It is not for project setup and not for remote access.
WebMCP is a new, evolving browser capability. Today it requires a specific Chrome build with an experimental flag, and the surface may change as the standard matures. Treat this as a preview feature.
## What WebMCP is [#what-webmcp-is]
WebMCP lets a page register tools on `document.modelContext` that a browser-resident AI agent can then call. Studio uses this to advertise its review actions as named tools. Every tool call still travels through the exact same authenticated RPC as the dashboard, over the same loopback origin, with the same input validation and the same capability gates. Registering the tools grants an agent nothing the open, authenticated tab did not already have: an agent driving the DOM could already reach every one of these actions. The surface is co-extensive with that one open tab and adds no new network exposure.
### Browser requirements [#browser-requirements]
* A WebMCP-capable browser. Today that means Chrome 149 or later with `chrome://flags/#enable-webmcp-testing` enabled, or a later browser with native support.
* `document.modelContext` must be present. When it is absent, Studio registers nothing and the dashboard is unchanged.
* A real browsing context. This does not work headless; the agent operates the live, open tab.
* No configuration or CSP change on Studio's side. The WebMCP `tools` Permissions-Policy defaults to `self`, which is exactly Studio's same-origin case, so nothing needs to be granted.
## Enable it [#enable-it]
Agent tools are off by default. Turn them on with the flag on the `studio` command:
```bash
verbatra studio --expose-agent-tools
```
Or set the environment fallback:
```bash
VERBATRA_STUDIO_AGENT_TOOLS=1 verbatra studio
```
The flag and the environment variable resolve exactly like `--allow-spend`: `1`, `true`, `yes`, or `on` (case-insensitive) counts as on, and the CLI flag wins over the variable. With neither set, no tools are registered. See [`verbatra studio`](/docs/cli/studio) for every flag.
Then open the printed loopback URL in your WebMCP-capable browser. The agent on that tab discovers the registered tools and can call them.
## The tool catalog [#the-tool-catalog]
With the opt-in on, Studio registers thirteen tools, one per dashboard action. Without `--allow-spend` it registers eleven: the two spend tools are omitted entirely. Tool names are prefixed and use underscores (for example `verbatra_project_snapshot`).
### Read tools (ten) [#read-tools-ten]
Inspect project state without changing anything. These are marked read-only.
| Tool | What it returns |
| --------------------------- | --------------------------------------------------- |
| `verbatra_project_snapshot` | resolved project state and capabilities |
| `verbatra_status_check` | per-locale coverage summary |
| `verbatra_status_diff` | per-locale drift (missing, changed, orphaned keys) |
| `verbatra_glossary_get` | the resolved glossary |
| `verbatra_lock_state` | the lock file's state |
| `verbatra_history_list` | recent locale file commit history |
| `verbatra_key_integrity` | placeholder and ICU integrity for a key |
| `verbatra_review_queue` | the needs-review queue |
| `verbatra_usage_summary` | the last run's token usage and budget |
| `verbatra_key_value` | the source and target values for one key and locale |
### Write tool (one) [#write-tool-one]
`verbatra_translation_editEntry` edits one locale's value in place. It goes through the same integrity gate as every other verbatra write: a value that drops or invents a placeholder, or breaks an ICU message's structure, is rejected and nothing is written. An accepted edit writes the locale file and advances the key's lock entry, exactly as a dashboard edit does. Editing needs no spend flag; it touches only your local files and never calls a provider.
### Spend tools (two) [#spend-tools-two]
These call a translation provider and are registered only when Studio also runs with `--allow-spend`:
* `verbatra_translation_retranslateEntry`: retranslate one key and locale.
* `verbatra_translation_translatePending`: translate everything currently pending.
Both run their results through the integrity gate before anything reaches disk, and both are subject to Studio's existing rate limits (retranslate at 20 per rolling minute, translate-pending at 5 per rolling minute plus a single-concurrent guard). Without `--allow-spend`, they are absent from the tool set and the server does not register the underlying endpoints at all, so spend you did not grant at startup cannot be reached from an agent.
## Security model [#security-model]
Keep these points in mind before you enable agent tools.
* **Off by default.** You opt in explicitly with `--expose-agent-tools` (or `VERBATRA_STUDIO_AGENT_TOOLS`).
* **Spend requires both flags.** The two spend tools appear only when you pass both `--expose-agent-tools` and `--allow-spend`. Neither flag implies the other. Read and write tools do not spend.
* **No new network exposure.** Every tool call uses the same authenticated loopback RPC as the dashboard. Studio still binds only to `127.0.0.1`, and the browser session token still authenticates every request.
* **Translatable strings are untrusted.** Tools that can return project-derived text (locale strings, key names, glossary terms, commit subjects, placeholder tokens) mark their content as untrusted, so a consuming agent treats those values as data, not as instructions.
### The accepted spend risk [#the-accepted-spend-risk]
State this one plainly. With both `--allow-spend` and `--expose-agent-tools` on, an autonomous agent can trigger provider spend without a per-click human confirmation. Studio does not add a per-call approval dialog: the server cannot tell an agent-originated call apart from a human click on the same session, so such a dialog would give false assurance and would defeat the point of the feature. The rate limits above still bound the cost, but an agent can loop up to that ceiling on its own.
Enable agent-driven spend deliberately. If you only want an agent to inspect state and fix entries by hand, run Studio without `--allow-spend`: agent tools work fully in that read-and-edit mode, and no tool can reach a provider.
## Next [#next]
* [Review translations in Studio](/docs/review-in-studio): the same workflow, driven by a human.
* [`verbatra studio`](/docs/cli/studio): every flag and the startup contract.
* [Translation safety](/docs/translation-safety): the integrity gate that every edit and retranslate passes through.
---
# CI and exit codes (https://verbatra.kreitz-webdev.de/docs/ci-and-exit-codes)
This guide shows how to make CI fail when translations drift out of sync, and how to read what verbatra reports when it does. The tools are the exit-code contract every command follows and the `--json` output your scripts can parse.
verbatra is a dev dependency, so the commands you run locally run the same in CI. The usual split:
* **Gate a pull request** with [`verbatra check`](/docs/cli/check) or [`verbatra diff`](/docs/cli/diff). Both are read-only: no provider call, no API key needed, exit `1` on drift.
* **Translate on a push** with [`verbatra translate`](/docs/cli/translate), either directly (this page) or through the [GitHub Action](/docs/github-action).
## The exit codes [#the-exit-codes]
The exit code is the contract your CI step branches on:
| Code | Meaning |
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | success: `translate` or `import` succeeded for every locale, `check` found every locale in sync, `diff` found no pending changes |
| `1` | `translate` or `import` finished but some locales failed, `check` found a locale out of sync, or `diff` found pending changes |
| `2` | could not run: a whole-run error (bad config, unreadable source) or a usage error (an empty or unknown `--locales` value, an invalid `--debounce` or `--port`) |
| `130` | `watch` or `studio` was force-stopped by a second interrupt |
Two edge cases worth knowing:
* `watch` treats a single interrupt as a clean stop and exits `0`; a failed run during watch shows up as a record on the output stream, never as a non-zero exit code.
* `export` has no per-locale failure mode: it exits `0` or `2`, never `1`.
## check or diff: picking the gate [#check-or-diff-picking-the-gate]
`check` and `diff` run the same read-only computation over your source, target files, and [the lock file](/docs/the-lock-file). The difference is what they report:
```bash
# counts per locale: exit 1 if any locale is missing or stale
verbatra check
# key lists per locale: exit 1 if any locale has keys to add or re-translate
verbatra diff
```
Use `check` when the exit code is all you need. Use `diff` when you want the exact keys behind the drift, say, to post them in a pull request comment. Orphaned keys (in a target file but gone from source) appear in `diff` output but never set exit code `1` on their own.
Both take `--locales de,fr` to gate a subset. Passing `--locales` with no valid locale is a usage error and exits `2`, so a typo can never turn the gate green.
## JSON output [#json-output]
Six commands accept `--json` for machine-readable output on stdout: `translate`, `watch`, `check`, `diff`, `export`, and `import`. Errors always go to stderr as one structured line (`verbatra: error [CODE] message`), so stdout stays parseable.
`verbatra translate --json` and `verbatra import --json` print one `RunSummary` object:
```ts
interface RunSummary {
dryRun: boolean; // whether this was a dry run (no provider calls, no writes)
locales: LocaleSummary[]; // one entry per target locale, in config order
succeeded: string[]; // locales whose run succeeded
failed: string[]; // locales whose run failed
usage?: UsageSummary; // summed input/output tokens; absent when no call reported usage
budget?: RunBudget; // the token-budget outcome; present only when maxTokens is configured
}
```
Each `LocaleSummary` carries the per-locale key lists (translated, unchanged, orphaned, withheld, flagged for review, and more); see [the SDK reference](/docs/sdk) for the full anatomy.
`verbatra watch --json` prints one record per run as NDJSON (one JSON object per line):
```ts
type WatchRunResult =
| { status: "succeeded"; summary: RunSummary }
| { status: "failed"; error: { code: string; message: string } };
```
`verbatra check --json` prints one status document. The top-level `inSync` is true exactly when the command exits `0`:
```ts
interface CheckSummary {
inSync: boolean; // true exactly when the command exits 0
locales: LocaleCheckSummary[];
}
interface LocaleCheckSummary {
locale: string;
missing: number; // in source, absent from target
stale: number; // source changed since last translated
upToDate: number; // target matches the recorded baseline
inSync: boolean; // missing === 0 && stale === 0
}
```
`verbatra diff --json` gives you key lists instead of counts. The top-level `hasPendingChanges` is true exactly when the command exits `1`:
```ts
interface DiffSummary {
hasPendingChanges: boolean; // true exactly when the command exits 1
locales: LocaleDiff[];
}
interface LocaleDiff {
locale: string;
missing: string[]; // in source, absent from target: would be added
changed: string[]; // source changed since last translated: would be re-translated
orphaned: string[]; // in target, absent from source: reported only
hasPendingChanges: boolean; // missing.length > 0 || changed.length > 0
}
```
`verbatra export --json` prints where the workbook went and the per-locale row counts:
```ts
{
path: string; // absolute path of the written workbook
locales: { locale: string; rows: number }[];
}
```
## A GitHub Actions job with the CLI [#a-github-actions-job-with-the-cli]
A drift gate on pull requests, running the CLI directly. `check` never calls a provider, so this job needs no API key at all:
```yaml
name: i18n
on: pull_request
permissions:
contents: read
jobs:
check-translations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@
- uses: pnpm/action-setup@
- uses: actions/setup-node@
with:
node-version: 22
- run: pnpm install --frozen-lockfile
- run: pnpm exec verbatra check
```
To translate in CI instead, swap the last step for `translate` and pass the provider key from your secret store as the environment variable your provider expects (see [Providers](/docs/providers)):
```yaml
- run: pnpm exec verbatra translate --json
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
```
If you would rather not write this job yourself, the [GitHub Action](/docs/github-action) wraps the translate variant with annotations and a job summary.
## Frozen installs and keys [#frozen-installs-and-keys]
* **Install from the lockfile.** `pnpm install --frozen-lockfile` (or `npm ci`) pins the exact `@verbatra/cli` release your lockfile records, so a CI run is reproducible and cannot silently pull a newer release. The CLI requires Node `>=22.14.0`.
* **Keys are environment variables, never flags.** The CLI takes no key argument and reads no key from config; providers read only their environment variable (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPL_API_KEY`). Store the key in your CI secret store and map it into `env`. Error messages name the variable but never contain a key value.
* **Read-only gates need no key.** `check`, `diff`, and `export` never call a provider, so keep secrets out of those jobs entirely.
verbatra also loads `.env.local` and `.env` from the working directory before running, with real environment variables always winning; in CI you will normally rely on `env:` alone.
---
# GitHub Action (https://verbatra.kreitz-webdev.de/docs/github-action)
verbatra ships a composite GitHub Action that runs `verbatra translate --json` in CI, turns failures into annotations on the run, writes a job summary table, and exits with the CLI's exit code. This page covers wiring it in and what it shows you.
## When to use it over a raw CLI step [#when-to-use-it-over-a-raw-cli-step]
The action is the translate step with the reporting already built: per-locale error annotations, a summary table on the run page, and exit-code propagation that never swallows a failure. Prefer it when your job is "translate on push and show me what happened".
Run the [CLI directly](/docs/ci-and-exit-codes) instead when you want anything else: a read-only `check` or `diff` gate, custom flags such as `--prune`, or your own handling of the JSON output. The action always runs `translate --json` and nothing else; it never runs `init`, `watch`, or `check`.
## Availability [#availability]
The action lives in the verbatra repository and is referenced by path, pinned to a commit SHA:
```text
uses: mariokreitz/verbatra/packages/github-action@
```
There is no npm package, no Marketplace listing, and no versioned action tag. The SHA-pinned path reference is the only supported way to consume it.
## Usage [#usage]
```yaml
name: translate
on:
push:
branches: [main]
permissions:
contents: read
jobs:
verbatra:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@
- uses: mariokreitz/verbatra/packages/github-action@
with:
version: 0.5.0 # pin @verbatra/cli to an exact version
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
```
The action fetches and runs `@verbatra/cli` at exactly the version you pin, so the workflow needs no install step for verbatra itself.
### Secret wiring [#secret-wiring]
The action runs the CLI, and the CLI reads the provider's API key only from the environment. Pass the key from your repository secrets under the variable your provider expects (see [Providers](/docs/providers)): `ANTHROPIC_API_KEY` above, `OPENAI_API_KEY`, `GEMINI_API_KEY`, or `DEEPL_API_KEY`. There is no key input; a key never travels as an action input or CLI argument.
## Inputs [#inputs]
| Input | Required | Default | Description |
| ------------------- | -------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | yes | - | the `@verbatra/cli` version to run. Must be an exact semver version such as `0.5.0` or `0.5.1-next.0`; the step fails immediately on a dist-tag such as `latest`, a range, or a `^`/`~` prefix |
| `config-path` | no | `""` | explicit config file to load (maps to `--config`); empty uses the normal config search |
| `working-directory` | no | `""` | directory to resolve config and locale files against (maps to `--cwd`) |
| `dry-run` | no | `"false"` | set to `"true"` to report what would change without calling a provider or writing (maps to `--dry-run`) |
| `node-version` | no | `"24"` | Node.js version to set up for running the CLI |
The action defines no outputs. Its results are the annotations, the job summary, and the exit code.
## What a run shows you [#what-a-run-shows-you]
**Annotations.** When the CLI exits `1` (some locales failed), each failed locale becomes one error annotation titled `verbatra: `, carrying that locale's structured `[CODE] message`. When the whole run fails before producing a summary (exit `2`), a single `verbatra` annotation carries the CLI's error line instead.
**Job summary.** Every run appends a Markdown summary to the job page: a table with one row per locale (status, translated, unchanged, orphaned, invalid ICU, integrity withheld, provider failures, notices), an aggregate line, and a list of failed locales with their error codes. A dry run is labeled as such. A whole-run failure gets a short failure summary with the exit code and error detail.
**Exit behavior.** The action captures the CLI's stdout and exit code without bailing early, emits the annotations and summary, and only then exits with the CLI's own code. So the step fails on `1` or `2`, but never before you can see why. The [exit codes](/docs/ci-and-exit-codes) page spells out what each code means. If the internal exit-code wiring ever breaks, the action fails with `2` rather than reporting a false success.
## Persisting translations [#persisting-translations]
Without `dry-run`, `translate` writes updated locale files into the runner's checkout, and the action stops there: it does not commit. To keep the changes, add your own step that commits and pushes or opens a pull request. When you only want CI to flag missing or stale translations without writing anything, set `dry-run: "true"` (or gate with `verbatra check` and skip the action entirely).
## Security [#security]
Pin both references exactly: the `uses:` line to a commit SHA and the `version` input to an exact `@verbatra/cli` release. The action enforces the second one itself by rejecting anything that is not an exact semver version, so a run can never silently resolve `latest`. Give the workflow only the privilege it needs: `contents: read` for a report, plus `contents: write` or `pull-requests: write` only when a later step commits or opens a pull request. Inputs reach the CLI through the environment as data and are expanded into a quoted argument array, never spliced into shell text, so a crafted input value stays an argument and never becomes executable code.
---
# Human translation (https://verbatra.kreitz-webdev.de/docs/manual-translation)
Not every string belongs to a provider. Marketing copy, legal text, and the languages that matter most often want a human translator. verbatra handles this with a workbook round-trip: **export** the strings that need translating into a styled Excel file, hand it off, then **import** the filled file back into your locale files.
The same diff drives both directions. Export picks exactly the keys an automated run would translate (the new and changed ones), and import runs the same placeholder and ICU checks as `translate`, so a hand-typed value that breaks a placeholder is withheld and reported instead of written.
## The round-trip [#the-round-trip]
```bash
# 1. Export the strings that need translating
verbatra export
# 2. A translator fills the Translation column and sends the file back
# 3. Import the filled workbook
verbatra import verbatra-translations.xlsx
```
By default, export writes `verbatra-translations.xlsx` in the working directory. Import reads it back, validates every filled row, writes the values that pass into your locale files, and advances [the lock file](/docs/the-lock-file) baseline for exactly the keys it accepted, the same way a `translate` run would.
## What lands in the workbook [#what-lands-in-the-workbook]
The file opens with one **Instructions** sheet, then one data sheet per target locale, named for its locale (`de`, `fr`, ...). Because the locale round-trips through the sheet name, a locale that cannot be an Excel worksheet name is rejected before the workbook is built: longer than 31 characters, containing any of `: \ / ? * [ ]`, equal to `Instructions` in any case, or case-insensitively equal to another target locale.
Every data sheet shares the same columns, in this order:
| Column | Editable | Purpose |
| --------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Key` | no | The dotted key path. The sole identity that maps a row back to a string. |
| `Source` | no | The source-locale value, for reference. |
| `Current translation` | no | The existing target value, if any. |
| `Status` | no | `new` (no translation yet), `changed` (the source changed since last translated), or `unchanged` (already up to date, included only with `--include-unchanged`). |
| `Translation` | **yes** | The only cell the translator fills. |
| `Source hash` | no | Hidden. The source content hash captured at export, used to detect a source that changed after export. |
| `Context` | no | Developer context, when the source format carries any (Flutter ARB's `@key.description`, XLIFF's ``). Reference only, never imported. |
| `Review status` | no | `ok` or `review`: whether verbatra's review heuristics flagged the current translation for a second look. Advisory, never a gate. |
| `Review reasons` | no | Comma-separated reason labels explaining a `review` status (for example `length-ratio-outlier, equals-source`). |
Rows are sorted by key, the header row is frozen, the `Source hash` column is hidden, and every column except `Translation` is locked behind sheet protection with a shaded background. The `Translation` column uses Excel's text format, so a value like `007` or one starting with `=` stays literal text instead of being coerced into a number or formula.
## Handing off [#handing-off]
What a translator needs to know fits in a few lines (and is repeated on the workbook's own Instructions sheet):
* Fill the `Translation` column, nothing else. The sheet protection enforces this.
* Do not rename, delete, or reorder the language tabs. verbatra matches each tab to a locale by its exact name, so a renamed or missing tab is reported and that locale is not imported.
* Sorting and filtering rows is fine: import maps rows by `Key`, never by position. Columns must stay where they are; import reads them by position and verifies the `Key` and `Source hash` headers.
* An empty `Translation` cell means "not translated yet". Import skips it and never writes an empty string, so a half-filled workbook can come back now and the rest later. A cell holding only spaces counts as empty. To deliberately clear an existing value (set it to empty), type exactly `[[CLEAR]]` in the cell.
## What import accepts [#what-import-accepts]
Import is not a blind paste. Every filled row is judged against the live project before anything is written:
* **Source drift**: the hidden `Source hash` is compared to the current source. If the source string changed after export, the row is withheld, so you never overwrite a current source with a translation of an old one.
* **Placeholder integrity**: the translation must carry exactly the same placeholders as its source. Drop `{name}` or invent `{total}` and the row is withheld.
* **ICU validity**: an ICU message must stay structurally valid with the same argument names; only the human-readable text may change.
* **Unknown keys**: a filled row whose key exists in neither the current source nor the current target file (say, one typed in by hand) fails that locale's whole sheet, since the round trip is broken. A filled row whose key exists only in the target (an orphaned key) is silently left unwritten.
* **Context is never a translation source**: import ignores the `Context` column entirely, and a workbook exported before that column existed still imports normally.
Withheld rows are reported in the run summary under the locale they belong to; accepted rows are written and their lock baseline advances. A withheld or blank row keeps its prior baseline, so the key keeps re-exporting until it is genuinely resolved. A blank row whose source drifted since export is additionally called out with a `BLANK_ROW_BASELINE_RETAINED` notice, so the drift stays visible.
Import also surfaces structural findings per locale instead of aborting on them: a `changed` row the translator left blank (reported as unfilled, still pending), a workbook row the reader could not parse (reported by row and column), and a duplicated key (the first occurrence wins and is imported, each later one is reported). None of these fail the sheet on their own.
Each data sheet is one locale in the summary: a sheet failure (an invented key, a sheet for a locale not in your config, or a configured locale whose tab is missing entirely because it was renamed, deleted, or reordered out) fails that locale and leaves the others untouched. Import shares the `translate` exit-code contract: `0` when every sheet succeeds, `1` when one fails, `2` when the run could not start. A manual handoff therefore drops into the same [CI checks](/docs/ci-and-exit-codes) as an automated run.
### Dry-run first [#dry-run-first]
Validate a returned workbook and preview what would be written without touching a single file:
```bash
verbatra import verbatra-translations.xlsx --dry-run
```
## Selecting what to export [#selecting-what-to-export]
By default, export includes only the missing and changed strings, the work that actually needs doing:
```bash
# Only the German and French sheets
verbatra export --locales de,fr
# Also include strings that are already up to date
verbatra export --include-unchanged
# Write to a specific path
verbatra export --out handoff/round-2.xlsx
```
Reach for `--include-unchanged` when a translator wants the full context of a locale, not just the delta. See [`verbatra export`](/docs/cli/export) for every flag.
## The review columns [#the-review-columns]
`Review status` and `Review reasons` are a non-gating layer on top of the checks above: verbatra's heuristics for spotting a translation that is structurally fine but worth a second look. They never withhold a row and import never reads them; the only way to clear a flag is to fix the translation. Both columns are computed fresh at export time from the source and current target values:
* `length-ratio-outlier`: the translation is far shorter or longer than its source.
* `equals-source`: the translation is identical to the source.
* `glossary-term-missed`: a configured glossary term did not make it into the translation.
* `integrity-reordered`: the placeholders match but landed in a different order than the source.
A fifth reason, `provider-degraded`, exists only at translate time (a DeepL degradation notice on the batch a key came from); since export never calls a provider, it never appears on an exported row. A row with no translation yet always exports `ok`; there is nothing to review. See [Translation safety](/docs/translation-safety) for how these flags relate to the hard placeholder and ICU checks.
## From the SDK [#from-the-sdk]
The CLI commands wrap two SDK functions, `exportWorkbook` and `importWorkbook`:
```ts
import { exportWorkbook, importWorkbook, loadConfig } from "@verbatra/sdk";
const config = await loadConfig();
// Write a workbook of the strings that need translating
await exportWorkbook({ config });
// ...later, import the filled file back
const summary = await importWorkbook({ config, workbook: "verbatra-translations.xlsx" });
```
Both take the validated config from [`loadConfig`](/docs/sdk), and `importWorkbook` returns the same `RunSummary` as `translate`. The workbook itself is built and parsed by `@verbatra/exchange`, an internal package you reach only through these two functions.
## Next [#next]
* [`verbatra export`](/docs/cli/export): every export flag and the report it prints.
* [`verbatra import`](/docs/cli/import): every import flag, the run summary, and exit codes.
* [Translation safety](/docs/translation-safety): the placeholder and ICU checks that guard every write, manual or automated.
---
# Review translations in Studio (https://verbatra.kreitz-webdev.de/docs/review-in-studio)
Verbatra Studio is a local dashboard over your verbatra project: it shows every locale's state live, lets you fix translations by hand, and can retranslate single keys when you allow it. This guide walks the review workflow end to end.
The posture to keep in mind throughout: **local editing is always on**, gated only by the same placeholder and ICU checks as every other write. **Provider spend is never on by default**; the retranslate and translate-pending actions exist only when you start Studio with `--allow-spend`.
## Start Studio [#start-studio]
Studio ships as its own package, reached through the CLI:
```bash
pnpm add -D @verbatra/studio
verbatra studio
```
The command prints one line and keeps running:
```text
Verbatra Studio running at http://127.0.0.1:5849/?token=...
```
Open that URL, token included; the token is generated fresh per start and is how the browser authenticates. The server binds only to `127.0.0.1` (never your network), defaults to port `5849` with no fallback if it is busy, and takes `--port` to move. Stop it with Ctrl+C; a second interrupt forces it down with exit code `130`. See [`verbatra studio`](/docs/cli/studio) for every flag.
The sidebar has four pages: **Translations**, **Review**, **Activity**, and **Settings**.
## Read the Translations page [#read-the-translations-page]
Translations is the working view: everything pending across your target locales, and how far along each locale is.
* **The stat strip** answers the first question at a glance: how many keys need attention, average coverage, how many locales are in sync, and the last run's token usage. When nothing is pending, an all-clear banner replaces the key explorer.
* **The key explorer** lists every missing, changed, and orphaned key, as a key-by-locale grid or as per-locale lists with a key filter.
* **The locales table** shows per-locale coverage (missing, stale, up to date) merged with [the lock file](/docs/the-lock-file)'s state, so you can see whether each locale's lock entry agrees with its files.
Click any key to open its detail view: the per-locale values, each locale's sync status, and an integrity indicator per translation (placeholders match, placeholder mismatch with the missing and extra tokens named, or invalid message syntax). From there, **Edit** opens the editor for one locale's value.
### Edit with integrity gating [#edit-with-integrity-gating]
The editor shows the source string and a text area pre-filled with the current translation. Saving submits the value through the same gate as every other verbatra write: a value that drops or invents a placeholder, or breaks an ICU message's structure, is rejected with the reason named and **nothing is written**. An accepted edit writes the locale file and advances the key's lock entry, exactly like an accepted import row.
Editing needs no flag. It touches only your local files and never calls a provider.
## Work the Review queue [#work-the-review-queue]
The Review page lists every entry the review heuristics flagged in the most recent `translate` or `watch` run: one row per locale and key, with an indicator per reason (translation far shorter or longer than the source, identical to the source, a missed glossary term, reordered placeholders, or a degraded provider batch). Until a run has been recorded, the page says so and stays empty; run `verbatra translate` or `verbatra watch` to populate it.
Filter by locale or key substring, then work the rows:
* **Edit** opens the same integrity-gated editor as the Translations page. An accepted edit clears the row from the queue.
* **Approve** dismisses the row as "looked at, fine as is".
* **Reject** dismisses the row too; pair it with an edit or a retranslate when the value actually needs to change.
Approve and Reject are session-only: they hide the row for the rest of this browser session (including across live refreshes) but write nothing to disk. The durable ways to clear a flag are fixing the value or re-running a translation.
Review flags are advisory, distinct from the hard integrity gate; see [Translation safety](/docs/translation-safety) for what each reason means.
## Retranslate a flagged key [#retranslate-a-flagged-key]
Start Studio with spend allowed:
```bash
verbatra studio --allow-spend
```
(Or set `VERBATRA_STUDIO_ALLOW_SPEND=1`; the flag wins when both are given. The provider's API key comes from the environment as always, never from Studio.)
Now a key whose integrity indicator shows a real defect (a placeholder mismatch or invalid message syntax) gets a **Retranslate** button in its detail view on the Translations page. It makes one provider call for exactly that locale and key, runs the result through the integrity gate before anything reaches disk, and reports the outcome next to the button. The refreshed value then flows into the UI through the live-refresh loop.
Without `--allow-spend`, the button is absent entirely, and the server never even registers the retranslate endpoint: spend you did not grant at startup cannot be switched on from the browser.
## Translate pending changes [#translate-pending-changes]
Studio watches your source file, every target locale file, and the lock file while it runs. When one changes, every open page refreshes itself and a toast names what changed, with an added/changed/removed key delta.
When the **source** file changes and Studio runs with `--allow-spend`, that toast also offers **Translate pending changes across all locales**: a full translate run over everything currently pending, the same work `verbatra translate` would do. The button reports success, per-locale failures, or the error inline.
This live loop also means Studio pairs well with an editor session: change a source string, watch the toast appear, translate, and watch the grids go green without touching the terminal.
## Check the damage on Activity [#check-the-damage-on-activity]
The Activity page puts two reference views side by side:
* **Locale file history**: the git commit feed for your source and target locale files (shown as unavailable when the project has no git history).
* **Last run**: the most recent run's input and output token counts and, when a token budget is configured, the budget meter and whether the ceiling was reached. This is a snapshot as of the last recorded run, timestamped, never a live counter; a provider that reports no usage (DeepL) is shown as "not reported" rather than a fabricated zero.
Settings, the fourth page, shows the resolved configuration and glossary this session was started with; restart Studio after changing the config file.
## Next [#next]
* [`verbatra studio`](/docs/cli/studio): every flag and the startup contract.
* [Operate Studio with a browser agent](/docs/agent-tools-in-studio): drive this same workflow from a browser AI agent with `--expose-agent-tools`.
* [Translation safety](/docs/translation-safety): the integrity gate and the review reasons.
* [CI and exit codes](/docs/ci-and-exit-codes): gate the same drift in CI that Studio shows you locally.
---
# SDK recipes (https://verbatra.kreitz-webdev.de/docs/programmatic-api)
The [SDK reference](/docs/sdk) catalogs every entry point. This page is the hands-on companion:
end-to-end recipes you can copy into a script and run. One thing to set up first: the SDK does not
load `.env` files (the CLI does that), so make sure the provider's environment variable is set
before your script runs, for example:
```bash
node --env-file=.env translate.mjs
```
## One-shot translate in a script [#one-shot-translate-in-a-script]
Load the config, run the flow, then read the summary: the headline from `succeeded` and `failed`,
token spend from `usage`, and the keys worth a human look from each locale's `needsReview`.
Whole-run problems throw an `SdkError`; per-locale outcomes are data on the summary.
```ts
import { loadConfig, translate } from "@verbatra/sdk";
const config = await loadConfig();
const summary = await translate({ config });
console.log(`${summary.succeeded.length} locales ok, ${summary.failed.length} failed`);
if (summary.usage !== undefined) {
console.log(`tokens: ${summary.usage.inputTokens} in, ${summary.usage.outputTokens} out`);
}
for (const locale of summary.locales) {
if (locale.status === "failed") {
console.error(`${locale.locale}: ${locale.error?.code} ${locale.error?.message}`);
continue;
}
for (const entry of locale.needsReview) {
console.warn(`review ${locale.locale}/${entry.key}: ${entry.reasons.join(", ")}`);
}
}
if (summary.failed.length > 0) {
process.exitCode = 1;
}
```
Pass `dryRun: true` to preview without calling a provider or writing anything, and `prune: true`
or `generatePlurals: true` to override those config options for one run. Set `concurrency` above 1
to translate locales in parallel (not allowed with a `maxTokens` budget on a live run) and
`cache: false` to bypass the [translation-memory cache](/docs/the-cache).
## Check in CI without writing [#check-in-ci-without-writing]
`check` reads and diffs without calling a provider or touching any file, and its `inSync` flag is
true exactly when nothing is missing or stale. That makes it the natural CI gate.
```ts
import { check, loadConfig } from "@verbatra/sdk";
const summary = await check({ config: await loadConfig() });
if (!summary.inSync) {
for (const locale of summary.locales) {
if (!locale.inSync) {
console.error(`${locale.locale}: ${locale.missing} missing, ${locale.stale} stale`);
}
}
process.exitCode = 1;
}
```
Need the key names instead of counts? Swap in `diff`, which returns `missing`, `changed`, and
`orphaned` key lists per locale with the same `hasPendingChanges` gate. Running the CLI in CI
instead? See [CI and exit codes](/docs/ci-and-exit-codes).
## Watch in a long-running process [#watch-in-a-long-running-process]
`watch` fires one run immediately at startup, then one per debounced source change, reporting each
through `onRun`. For a clean shutdown, await `controller.stop()`: it closes the watcher and waits
for the in-flight run to finish before your process exits.
```ts
import { loadConfig, watch } from "@verbatra/sdk";
const config = await loadConfig();
const controller = await watch({
config,
onRun: (result) => {
if (result.status === "succeeded") {
console.log(`ran: ${result.summary.succeeded.length} ok, ${result.summary.failed.length} failed`);
} else {
console.error(`run failed: ${result.error.code} ${result.error.message}`);
}
},
});
process.on("SIGINT", () => {
void controller.stop().then(() => {
process.exit(0);
});
});
```
A run failure after startup never throws; it arrives as `{ status: "failed" }` and watching
continues.
## Edit and retranslate a single key [#edit-and-retranslate-a-single-key]
These are the seams Verbatra Studio drives; use them to build your own review flow. Read the
current values with `keyValue`, save a human correction with `editEntry`, or re-run the provider
for one key with `retranslateEntry`. Both writers gate the candidate value through the same
placeholder and ICU checks as a full run and return a two-armed result instead of throwing on a
rejected value.
```ts
import { editEntry, keyValue, loadConfig, retranslateEntry } from "@verbatra/sdk";
const config = await loadConfig();
// Read the live values feeding your edit UI.
const current = await keyValue({ config, locale: "de", key: "checkout.title" });
console.log(`source: ${current.source}, target: ${current.target ?? "(not yet translated)"}`);
// Save a human-typed correction. No provider call.
const edit = await editEntry({
config,
locale: "de",
key: "checkout.title",
value: "Zur Kasse",
});
if (!edit.accepted) {
console.error(`rejected (${edit.reason} check failed), nothing written`);
}
// Or ask the provider for a fresh translation of just this key.
const retry = await retranslateEntry({ config, locale: "de", key: "checkout.title" });
if (retry.accepted) {
console.log(`wrote: ${retry.value}`);
if (retry.reviewReasons.length > 0) {
console.warn(`flagged for review: ${retry.reviewReasons.join(", ")}`);
}
}
```
An unknown locale or key throws `UNKNOWN_LOCALE` or `UNKNOWN_KEY`, and `retranslateEntry` throws
the provider's own `ProviderError` (for example `RATE_LIMITED`) when the call itself fails. See
[Review in Studio](/docs/review-in-studio) for the same seams behind a UI.
## The workbook round-trip [#the-workbook-round-trip]
Export the strings that need translating into an Excel workbook, hand it to a translator, then
import the filled file back. `importWorkbook` runs the same drift, placeholder, and ICU checks as
`translate` and returns the same `RunSummary` shape, so a manual handoff slots into the exact
reporting you use for an automated run.
```ts
import { exportWorkbook, importWorkbook, loadConfig } from "@verbatra/sdk";
const config = await loadConfig();
// Write a workbook of the missing and changed strings.
const exported = await exportWorkbook({ config });
for (const sheet of exported.locales) {
console.log(`${sheet.locale}: ${sheet.rows} rows`);
}
console.log(`wrote ${exported.path}`);
// ...later, after the translator returns the file, import it back.
const summary = await importWorkbook({ config, workbook: exported.path });
console.log(`${summary.succeeded.length} locales ok, ${summary.failed.length} failed`);
```
Pass `dryRun: true` to `importWorkbook` to validate a returned file without writing anything. See
[Manual translation](/docs/manual-translation) for what translators may edit and how rows are
validated.
## A config without a file [#a-config-without-a-file]
`loadConfig` accepts an in-memory `configOverride`, validated exactly like a loaded file, so you
can drive verbatra entirely from code with no config file on disk.
```ts
import { loadConfig, translate } from "@verbatra/sdk";
const config = await loadConfig({
configOverride: {
sourceLocale: "en",
targetLocales: ["de", "fr"],
format: "i18next-json",
files: { pattern: "locales/{locale}.json" },
provider: { id: "gemini", options: { model: "gemini-2.5-flash", maxOutputTokens: 4096 } },
},
});
await translate({ config });
```
For every input shape, the full `RunSummary` anatomy, and the `SdkError` code table, see the
[SDK reference](/docs/sdk).
---
# SDK reference (https://verbatra.kreitz-webdev.de/docs/sdk)
`@verbatra/sdk` is the engine the `verbatra` command runs on. The CLI is a thin wrapper, so whatever
the command line does, you can do in code. This page catalogs the whole public surface, grouped by
the job each entry point does. For end-to-end examples, see [SDK recipes](/docs/programmatic-api).
## Install [#install]
```bash
pnpm add -D @verbatra/sdk
# npm
npm install -D @verbatra/sdk
# yarn
yarn add -D @verbatra/sdk
```
Requires Node.js `>=22.14.0`.
The SDK never reads, holds, or accepts an API key. The provider reads its key from the environment
(for example `ANTHROPIC_API_KEY`) when it is constructed. Unlike the CLI, the SDK does not load
`.env` files: in your own script, set the variable yourself, for example with
`node --env-file=.env script.js`.
Every entry point takes one input object whose first field is the validated `config` (from
`loadConfig`), except where noted. Most also accept an optional second `deps` argument that injects
a registry, provider builder, or file system for tests; you can ignore it in normal use.
## Run translations [#run-translations]
### translate [#translate]
The one-shot flow: read the source, diff each target locale against the lock baseline, send the
missing and changed keys to the provider, run the placeholder and ICU integrity checks, write the
locale files, and update the lock. Use it for scripts, build steps, and CI jobs.
Input: `{ config, cwd?, dryRun?, prune?, generatePlurals?, concurrency?, cache?, onProgress?, onLockWait?, lockAcquireTimeoutMs? }`.
* `dryRun` reads, diffs, and reports without constructing or calling the provider and without
writing anything.
* `prune` removes orphaned keys (target keys absent from source) from the written file and the
lock. `generatePlurals` synthesizes missing CLDR plural forms (i18next-JSON with an LLM provider
only). Both default to off; when set, each overrides the matching config option for that run.
* `concurrency` runs up to that many target locales at once (default 1, strictly serial). A value
below 1 throws `CONCURRENCY_INVALID`; on a live run a value above 1 with `maxTokens` configured
throws `CONCURRENCY_BUDGET_CONFLICT` (a dry run is exempt).
* `cache` defaults to true and reuses the local [translation-memory cache](/docs/the-cache); set it
to false to bypass the cache for the run (the CLI's `--no-cache`).
* `onProgress` is called as the run advances (per locale start and finish, and per provider
sub-batch), and `onLockWait` fires while a locale's write lock is contended. Both are
notification callbacks; the SDK writes no output itself. `lockAcquireTimeoutMs` overrides how long
a contended write lock retries before failing with `LOCK_CONTENDED`.
* `maxBatchSize`, `maxTokens`, and `budgetBehavior` are config-only; there is no per-run override.
Returns a `RunSummary` (see [its anatomy below](#the-runsummary-anatomy)). Whole-run problems throw
an `SdkError`; a single locale failing never throws and lands as `status: "failed"` on that
locale's entry while the run continues.
### watch [#watch]
Watches the source locale file and re-runs `translate` on every debounced change. It fires one
initial run immediately at startup, then one run per settled change. Runs are serialized: changes
during a run collapse into a single follow-up.
Input: `{ config, cwd?, debounceMs?, onRun, concurrency?, cache?, onProgress?, onLockWait?, lockAcquireTimeoutMs? }`.
`debounceMs` defaults to 300. `onRun` is called once per run with a `WatchRunResult`:
`{ status: "succeeded", summary }` or `{ status: "failed", error: { code, message } }`. The SDK does
no logging; `onRun` is the only output. `concurrency`, `cache`, `onProgress`, `onLockWait`, and
`lockAcquireTimeoutMs` are passed through to every run and behave exactly as on `translate` (a
budgeted watch with `concurrency` above 1 fails each run with `CONCURRENCY_BUDGET_CONFLICT`).
Returns a `WatchController` with one method, `stop()`, which closes the watcher and awaits the
in-flight run. A missing source file throws `SOURCE_UNREADABLE` at startup; every failure after
that is surfaced through `onRun` and watching continues.
## Inspect state without writing [#inspect-state-without-writing]
None of these call a provider, write a file, or mutate the lock.
### check [#check]
Reports per-locale drift as counts: `missing` (in source, absent from target), `stale` (source
changed since last translated), and `upToDate`. Use it as a CI gate.
Input: `{ config, cwd?, locales? }`. Returns a `CheckSummary` whose `inSync` is true exactly when
every checked locale has nothing missing and nothing stale.
### diff [#diff]
The detailed sibling of `check`: the same computation, but it returns the key lists per locale
(`missing`, `changed`, `orphaned`) instead of counts. Orphaned keys are report-only and never flip
`hasPendingChanges`, since a default run does not prune.
Input: `{ config, cwd?, locales? }`. Returns a `DiffSummary`.
### keyIntegrity [#keyintegrity]
Reports, for each locale's changed keys, whether the current target value still matches the
source's placeholders and whether it is valid ICU. Each entry carries `key`, `hasPlaceholders`,
`matches`, the `missing` and `extra` placeholder tokens on a mismatch, and `icuValid`. Entries
never carry a source or target string value. This is the per-key integrity report Studio surfaces.
Input: `{ config, cwd?, locales?, keys? }`. `keys` narrows the check; only keys that are "changed"
for a locale are ever checked. Returns one `LocaleKeyIntegrity` per checked locale.
### lockState [#lockstate]
Reports the lock file's existence, version, and per-locale drift (baseline key count plus missing,
stale, and up-to-date counts). `exists` comes from an explicit probe, so "no lock file yet" and "an
empty but present lock file" stay distinguishable. When the file is absent the result is
`{ exists: false }` and nothing else is read.
Input: `{ config, cwd?, locales? }`. Returns a `LockStateResult`.
### loadLockFile [#loadlockfile]
Reads the lock file itself (`verbatra.lock.json`, also exported as `LOCK_FILE_NAME`) and returns
its parsed shape: `{ version, locales }`, where each locale maps keys to the source content hash
they were last translated from. A missing file degrades to an empty lock, the same first-run
behavior `translate` relies on; use `lockState` when you need to distinguish absence.
Input: `{ cwd? }`. No config needed. Returns a `LockFile`.
### runStatus [#runstatus]
Reads the review-flag and token-usage snapshot the most recent non-dry-run `translate` or `watch`
run persisted to `.verbatra-local/run-status.json`. Never throws: a missing, corrupt, or
unrecognized file degrades to `{ available: false }`. This file is best-effort telemetry, not a
correctness baseline.
Input: `{ cwd? }`. No config needed. Returns `{ available: false }` or
`{ available: true, version, generatedAt, usage?, budget?, locales }`.
## Single-key operations [#single-key-operations]
These are the seams Verbatra Studio drives; use them to build your own review tooling. All three
resolve `locale` and `key` fresh on every call and throw `UNKNOWN_LOCALE` or `UNKNOWN_KEY` when
either does not exist.
### keyValue [#keyvalue]
Reads one key's current source value and, when present, its current target value for one locale.
Read-only.
Input: `{ config, cwd?, locale, key }`. Returns `{ source, target? }`; `target` is absent exactly
when the key does not yet exist in that locale.
### editEntry [#editentry]
Writes one human-typed correction for one key and locale. The candidate value passes the same
placeholder and ICU gate as a provider translation before anything reaches disk; on acceptance the
locale file and the lock entry for that key are updated inside the locale's write lock. Never
calls a provider.
Input: `{ config, cwd?, locale, key, value }`. Returns a two-armed result:
`{ accepted: true, value }`, or `{ accepted: false, reason: "placeholder" | "icu", value }` with
nothing written.
### retranslateEntry [#retranslateentry]
Re-runs the provider for exactly one key and locale: a single-entry call through the same provider
registry `translate` uses, gated through the same integrity checks. On acceptance it writes the
locale file and lock entry for that key only.
Input: `{ config, cwd?, locale, key }`. Returns `{ accepted: true, value, reviewReasons }` (the
review reason codes, if any, that apply to the new value) or
`{ accepted: false, reason: "placeholder" | "icu", value }`. Unlike `translate`, a provider failure
here throws: a `ProviderError` from `@verbatra/ai-providers` with a stable code such as
`RATE_LIMITED` or `AUTH_FAILED`.
## Locale-file snapshots [#locale-file-snapshots]
The building blocks of a live-refresh watcher such as Studio's: capture a locale file's state, then
count what changed since.
### readLocaleFileSnapshot [#readlocalefilesnapshot]
Reads one locale file (the source locale or any target locale) and reduces it to a content hash per
key. A file that does not exist yet reads as an empty snapshot rather than throwing.
Input: `{ config, locale, cwd? }`. Returns `{ locale, hashes }`.
### diffLocaleSnapshots [#difflocalesnapshots]
Compares two snapshots of the same file, taken at different times, and counts the keys added,
changed, and removed between them. A plain synchronous function: `diffLocaleSnapshots(previous,
current)` returns `{ added, changed, removed }`. Counts only, never key names.
## The workbook pair [#the-workbook-pair]
The Excel handoff for human translators; see [Manual translation](/docs/manual-translation).
### exportWorkbook [#exportworkbook]
Writes the strings that still need translating (missing and changed keys per locale; add unchanged
ones with `includeUnchanged`) into a styled `.xlsx` workbook. Rows carry the same review-flag
signal a translate run computes. No provider call, no lock write.
Input: `{ config, cwd?, out?, locales?, includeUnchanged? }`. `out` defaults to
`DEFAULT_WORKBOOK_PATH` (`verbatra-translations.xlsx`), exported as a constant. Returns
`{ path, locales }`: the absolute path written and a per-locale row count.
### importWorkbook [#importworkbook]
Reads a filled workbook back into the locale files, running the same source-drift, placeholder,
and ICU checks as `translate`. Only accepted rows advance their lock baseline; a blank or rejected
row keeps re-exporting until it is genuinely resolved.
Input: `{ config, workbook, cwd?, dryRun? }`. Returns the same `RunSummary` shape as `translate`
(with `needsReview` always empty, since no provider is involved). A sheet for a locale that is not
a configured target fails that locale with `CONFIG_INVALID` as data on the summary, not a throw.
## Config [#config]
### loadConfig [#loadconfig]
Finds, loads, and validates the project config, returning a `VerbatraConfig`. Options:
`{ cwd?, configPath?, configOverride? }`, with precedence `configOverride` (validate an in-memory
object) over `configPath` (load one explicit file) over search. The search starts at `cwd` and
covers `verbatra.config.ts` (also `.js`/`.cjs`), the `.verbatrarc` family (`.json`, `.yaml`,
`.yml`, `.js`, `.cjs`, `.ts`), and a `"verbatra"` property in `package.json`. A `glossary` given as
a file path is read and validated here, so downstream code always sees a plain record.
Throws `CONFIG_NOT_FOUND` when nothing is found, `CONFIG_INVALID` when a config is found but
invalid. See [The config file](/docs/config-file) for the schema.
### loadConfigWithMeta [#loadconfigwithmeta]
The same load, plus provenance: returns `{ config, source, glossary }`, where `source` says whether
the config came from a search hit, an explicit path, or an in-memory override (with the absolute
`filepath` when there is one), and `glossary` records whether the glossary was absent, inline, or
resolved from a file. Use it when you need to display where the config came from.
### defineConfig [#defineconfig]
An identity helper for authoring a typed `verbatra.config.ts`: it returns its argument unchanged,
and exists purely for type inference and editor autocomplete, including completion of the selected
provider's known model IDs. The model restriction is authoring-only; at runtime any non-empty
string passes validation.
### verbatraConfigSchema [#verbatraconfigschema]
The zod schema `loadConfig` validates with, exported so your own tooling can validate a config
object the same way the SDK does. Unknown top-level keys are rejected, so a stray secret cannot
hide in the config.
### scaffoldingMetadata [#scaffoldingmetadata]
Read-only metadata the CLI `init` command derives its prompts from: `providerEnv` (provider id to
the environment variable its key is read from), `scaffoldModels` (a default scaffold model per LLM
provider), and `supportedFormats` (the closed set of format ids). The `ScaffoldableProviderId` type
covers the four hosted providers; `openai-compatible` is excluded because it has no single required
environment variable.
## The RunSummary anatomy [#the-runsummary-anatomy]
`translate`, `importWorkbook`, and each successful `watch` run resolve to a `RunSummary`:
```ts
interface RunSummary {
dryRun: boolean; // true when nothing was written and no provider was called
locales: LocaleSummary[]; // one entry per target locale, in config order
succeeded: string[]; // locales whose run succeeded
failed: string[]; // locales whose run failed
usage?: UsageSummary; // summed input/output tokens; absent when no call reported usage
budget?: RunBudget; // present only when maxTokens is configured
}
interface LocaleSummary {
locale: string;
status: "succeeded" | "failed";
translated: string[]; // keys translated this run (in dry-run, keys that would be)
cacheHits: string[]; // keys served from the translation-memory cache this run
unchanged: string[]; // keys already up to date
orphaned: string[]; // target keys with no matching source key (always reported)
pruned: string[]; // orphaned keys removed this run; empty unless pruning is on
invalidIcuSource: string[]; // source keys skipped for invalid ICU
integrityMismatches: string[]; // translations withheld for a placeholder mismatch
providerFailures: string[]; // keys withheld because nothing was translated for them
budgetWithheld: string[]; // keys never sent because a "stop" budget already tripped
generated: string[]; // CLDR plural forms synthesized this run
unfilled: string[]; // import only: changed rows the translator left blank
malformedRows: { row: number; column: string }[]; // import only: rows the reader could not parse
duplicateKeys: { key: string; row: number }[]; // import only: later rows for a duplicated key
notices: LocaleNotice[]; // provider notices and SDK notices for this locale
needsReview: { key: string; reasons: string[] }[]; // accepted keys flagged for a second look
usage?: UsageSummary; // this locale's summed tokens; absent if nothing reported usage
error?: { code: string; message: string }; // present only when status is "failed"
}
```
The parts worth knowing:
* **needsReview** lists accepted, written keys the review heuristics flagged, each with its reason
codes: `LENGTH_RATIO_OUTLIER`, `EQUALS_SOURCE`, `GLOSSARY_TERM_MISSED`, `INTEGRITY_REORDERED`,
and `PROVIDER_DEGRADED`. A review flag is advisory and never withholds a key, so a key never
appears in both `needsReview` and `integrityMismatches`. See
[Translation safety](/docs/translation-safety) for what each code means and
[Review in Studio](/docs/review-in-studio) for working the queue.
* **notices** live per locale, never at the top level. They cover provider notices (say, a DeepL
degradation) and SDK notices with the codes `PLURAL_CATEGORIES_INCOMPLETE`, `SUB_BATCH_FAILED`,
`BLANK_ROW_BASELINE_RETAINED`, and `BUDGET_TOKENS_EXCEEDED`.
* **usage** is `undefined`, never a fabricated zero, whenever nothing in that scope reported usage:
a dry run never calls a provider, and DeepL never reports tokens. `RunSummary.usage` is the sum
over the locales.
* **budget** appears only when `maxTokens` is configured: `{ maxTokens, behavior, supported,
tokensUsed, exceeded }`. Against a token-less provider or a dry run it is still present with
`supported: false`, so the guardrail is visibly inert rather than falsely tripped.
* **error.code** on a failed locale is a preserved string (the underlying provider or adapter code,
with `LOCALE_FAILED` only as a fallback), so do not treat it as a closed set.
* **cacheHits** lists keys served from the [translation-memory cache](/docs/the-cache) rather than
the provider. **unfilled**, **malformedRows**, and **duplicateKeys** are populated only by
`importWorkbook` (a `translate` run leaves them empty): a blank `changed` row, a workbook row the
reader could not parse, and a duplicated key whose first occurrence won.
## The error model [#the-error-model]
Whole-run failures throw an `SdkError`: one class, a stable `code`, and a secret-free `message`.
Branch on the code, not the message. Per-locale failures, provider notices, and integrity findings
are surfaced as data on the `RunSummary`, never thrown.
| Code | When |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| `CONFIG_NOT_FOUND` | no config was found by search, or an explicit `configPath` does not exist (thrown by `loadConfig`) |
| `CONFIG_INVALID` | a config was found but is unparseable or fails validation, or its glossary file could not be resolved |
| `UNKNOWN_FORMAT` | no adapter is registered for the configured format; thrown before any file is read |
| `UNKNOWN_LOCALE` | a requested locale is not among the configured target locales |
| `UNKNOWN_KEY` | a requested key is not in the source resource (`keyValue`, `editEntry`, `retranslateEntry`) |
| `PROVIDER_CONSTRUCTION_FAILED` | the provider could not be built; wraps the provider's own error, including a missing API key |
| `SOURCE_UNREADABLE` | the source locale file does not exist |
| `SOURCE_INVALID` | the source locale file could not be read or parsed; wraps the adapter's read error |
| `LOCK_FILE_INVALID` | the lock file is present but corrupt, oversized, or at an unsupported version |
| `LOCK_CONTENDED` | a locale's write lock could not be acquired before its timeout; the message names the lock file's path |
| `CONCURRENCY_INVALID` | `concurrency` was set but is not an integer of at least 1; raised before any locale runs |
| `CONCURRENCY_BUDGET_CONFLICT` | a live run set `concurrency` above 1 while `maxTokens` is configured; raised before any provider call (a dry run is exempt) |
| `LOCALE_FAILED` | never thrown: the fallback `code` recorded on a failed locale's `error` |
Two exceptions to the one-error-class rule: `retranslateEntry` re-throws the provider's own
`ProviderError` when the single call fails, and `runStatus` never throws at all. For how these
codes map to CLI exit codes, see [CI and exit codes](/docs/ci-and-exit-codes); for symptom-first
help, see [Troubleshooting](/docs/troubleshooting).
---
# FAQ (https://verbatra.kreitz-webdev.de/docs/faq)
Quick answers, each grounded in how verbatra actually behaves. For symptom-first help with error
messages, see [Troubleshooting](/docs/troubleshooting).
## How do I control cost? [#how-do-i-control-cost]
Four levers, all free of a provider call until you decide otherwise:
* Runs are incremental by default: only keys that are missing or whose source changed since the
lock baseline are sent to the provider. An unchanged project costs nothing to re-run.
* `--dry-run` (or `translate({ config, dryRun: true })`) previews exactly what would be sent,
with no provider call and no writes.
* `maxTokens` in the config sets a whole-run token ceiling, with `budgetBehavior` deciding what
happens when it is reached: `"warn"` (default) flags it and continues, `"stop"` withholds every
key not yet attempted; withheld keys are retried automatically next run.
* Gemini has a real free API tier, and the `openai-compatible` provider runs against a local
model at zero API cost.
## Which provider should I start with? [#which-provider-should-i-start-with]
Gemini: it has a free API tier, so you can translate a whole project at no cost, and switching
later means editing one `id` in the config. Anthropic and OpenAI are the paid-LLM quality picks,
DeepL is the dedicated machine-translation option, and `openai-compatible` keeps everything on
your own hardware. See [Providers](/docs/providers) for the full comparison.
## Can I run a local model? [#can-i-run-a-local-model]
Yes. The `openai-compatible` provider points verbatra at any server that speaks the OpenAI chat
API, such as LM Studio, Ollama, or vLLM, via its `baseUrl` option. Most local servers need no API
key: when neither an `apiKeyEnvVar`-named variable nor `OPENAI_COMPATIBLE_API_KEY` is set,
verbatra sends the fixed placeholder `"local"`. If your server does need a key, name its
environment variable with `apiKeyEnvVar`.
## How do keys keep their order? [#how-do-keys-keep-their-order]
The JSON-family, YAML, and ARB adapters round-trip files in exact document order: existing keys
keep their positions (including integer-like keys), and new keys are appended in source order. A
translated file diffs cleanly against its previous version. See [Formats](/docs/formats).
## Why did a translation get flagged for review? [#why-did-a-translation-get-flagged-for-review]
Accepted translations pass through review heuristics that flag suspicious results without
withholding them: a length far out of proportion to the source (`LENGTH_RATIO_OUTLIER`), a
translation identical to the source (`EQUALS_SOURCE`), a missed glossary term
(`GLOSSARY_TERM_MISSED`), reordered placeholders (`INTEGRITY_REORDERED`), or a degraded provider
path (`PROVIDER_DEGRADED`). The flags land on the run summary's `needsReview` list and in Studio's
Review queue. See [Translation safety](/docs/translation-safety).
## Does verbatra work in a monorepo? [#does-verbatra-work-in-a-monorepo]
Yes. The config search starts in the current working directory and walks upward, so running from a
package directory finds that package's config. From anywhere else, pass `--cwd ` (every
command supports it) or point at a specific file with `--config `. In the SDK, the same
knobs are `cwd` and `configPath` on `loadConfig`. The `files.pattern` and the lock file resolve
against the working directory.
## What should I commit? [#what-should-i-commit]
Commit your locale files and `verbatra.lock.json`: the lock records, per key, the source content
hash each translation came from, and committing it is what makes runs incremental and drift
detection work everywhere, including CI. Do not commit `.env`, `.env.local`, or
`.verbatra-local/`; `verbatra init` adds all three to `.gitignore`. See
[The lock file](/docs/the-lock-file).
## How do I re-translate everything? [#how-do-i-re-translate-everything]
Deleting the lock file does not do it: without a baseline, keys that exist in both source and
target count as up to date, so a run after deleting the lock translates nothing. To rebuild a
locale from scratch, delete that locale's file and run `verbatra translate`: every key is then
missing and gets translated fresh. For one key, use Studio's retranslate action or the SDK's
`retranslateEntry`. To re-translate keys whose source text changed, just run `translate`: that is
the normal incremental path.
## Does using the CLI mean installing the SDK? [#does-using-the-cli-mean-installing-the-sdk]
`@verbatra/cli` depends on `@verbatra/sdk`, so installing the CLI brings the SDK along
automatically; there is nothing extra to install. The reverse also holds: the SDK works standalone
in your own scripts with no CLI. Only `@verbatra/studio` is a separate, optional install, loaded
dynamically by the `studio` command.
## Where do API keys live? [#where-do-api-keys-live]
Only in environment variables: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, or
`DEEPL_API_KEY`, plus whatever variable you name for `openai-compatible`. The CLI loads `.env` and
`.env.local` from the working directory (real environment variables win). The config schema
rejects unknown keys precisely so a secret cannot end up in a committed file, and error messages
name the variable but never a value. See [Providers](/docs/providers).
---
# Troubleshooting (https://verbatra.kreitz-webdev.de/docs/troubleshooting)
Each entry below matches a real error code or message. Whole-run failures carry a stable code
(`SdkError`); branch or search on the code, not the message text. For how failures map to CLI exit
codes, see [CI and exit codes](/docs/ci-and-exit-codes); for the full code table, see the
[SDK reference](/docs/sdk).
## The configuration is invalid (CONFIG\_INVALID) [#the-configuration-is-invalid-config_invalid]
**Symptom**: `The verbatra configuration is invalid: ...`, listing one issue per field.
**Cause**: the config was found but fails schema validation: a missing required field, a
`files.pattern` without the `{locale}` token, a source locale listed in `targetLocales`, or an
unrecognized top-level key. An unrecognized key gains the hint `API keys are read from the
environment, not the config`: the schema is strict precisely so a secret cannot hide in a
committed file.
**Fix**: correct the field the message names. See [The config file](/docs/config-file) for the
full schema. If the message is `No verbatra configuration found...` (`CONFIG_NOT_FOUND`), run
`verbatra init` or pass `--config `.
## No adapter for the format (UNKNOWN\_FORMAT) [#no-adapter-for-the-format-unknown_format]
**Symptom**: `No adapter is registered for format "..."` followed by the supported list.
**Cause**: the config's `format` is not one of the eight registered format ids. This is checked
before any file is read.
**Fix**: use one of the ids from [Formats](/docs/formats), for example `i18next-json` or `xliff`.
## Requested locale is not configured (UNKNOWN\_LOCALE) [#requested-locale-is-not-configured-unknown_locale]
**Symptom**: `Requested locale not in the configured target locales: ... Configured targets: ...`
**Cause**: a `--locales` value (or an SDK `locales`/`locale` input) names a locale that is not in
`targetLocales`. Locale filters select from the configured list; they never add to it.
**Fix**: add the locale to `targetLocales` in the config, or fix the typo in the filter.
## The source file is missing or unparseable (SOURCE\_UNREADABLE, SOURCE\_INVALID) [#the-source-file-is-missing-or-unparseable-source_unreadable-source_invalid]
**Symptom**: `The source locale file was not found at .` or `The source locale file at could not be read: ...`
**Cause**: `files.pattern` with the source locale substituted does not point at an existing file
(`SOURCE_UNREADABLE`), or the file exists but the adapter rejects it, for example invalid JSON or
a structural problem (`SOURCE_INVALID`, wrapping the adapter's message).
**Fix**: check the path the message prints; it is the pattern resolved against the working
directory, so a wrong `--cwd` is a common cause. For structural rejections, see the
`INVALID_STRUCTURE` entry below.
## Missing API key (PROVIDER\_CONSTRUCTION\_FAILED) [#missing-api-key-provider_construction_failed]
**Symptom**: `Failed to construct provider "anthropic": The ANTHROPIC_API_KEY environment variable
is not set.` (or the matching variable for your provider).
**Cause**: the provider reads its key from the environment when it is constructed, and the named
variable is unset or empty. Error messages name the variable but never contain a key value, and
keys are never read from the config or CLI arguments.
**Fix**: set the variable the message names. The CLI loads `.env` and `.env.local` from the
working directory; the SDK does not, so in your own script use `node --env-file=.env` or export
the variable. `openai-compatible` only raises this when the config names an `apiKeyEnvVar` that is
unset; without one it falls back to a keyless placeholder for local servers.
## Rate limits, timeouts, and auth failures mid-run (RATE\_LIMITED, TIMEOUT, AUTH\_FAILED) [#rate-limits-timeouts-and-auth-failures-mid-run-rate_limited-timeout-auth_failed]
**Symptom**: the run completes, but some keys were not translated: they appear under a locale's
`providerFailures`, with a `SUB_BATCH_FAILED` notice carrying the provider code. The locale still
counts as succeeded, so the exit code stays 0.
**Cause**: a provider call failed after construction: HTTP 429 (`RATE_LIMITED`), a network or
request timeout (`TIMEOUT`), or HTTP 401/403 (`AUTH_FAILED`, an invalid or revoked key).
**Fix**: nothing is lost. The affected keys keep their prior lock baseline and are picked up again
on the next run, so for `RATE_LIMITED` or `TIMEOUT` simply re-run later. `AUTH_FAILED` does not
resolve by retrying: replace the key behind the environment variable. A smaller `maxBatchSize`
also reduces the blast radius of a single failed request.
## The lock file is corrupt (LOCK\_FILE\_INVALID) [#the-lock-file-is-corrupt-lock_file_invalid]
**Symptom**: `The lock-file at is not valid JSON.`, `... has an unexpected shape.`,
`... has version N, but this version of verbatra supports version 1.`, or `... exceeds the maximum
allowed size ...`
**Cause**: `verbatra.lock.json` was hand-edited, truncated, produced by an incompatible version,
or damaged in a merge.
**Fix**: restore the file from version control; that keeps every baseline intact. Deleting it also
clears the error, but loses the record of which source version each translation came from, so
source edits made before the deletion are no longer detected as stale. See
[The lock file](/docs/the-lock-file).
## Another process holds the write lock (LOCK\_CONTENDED) [#another-process-holds-the-write-lock-lock_contended]
**Symptom**: `Could not acquire the write lock at : another process may be holding it. If no
verbatra process is currently running, this lock file was likely left behind by one that was
killed; delete it and retry.`
**Cause**: writes to a locale are serialized through a per-locale lock file. A concurrent
`translate`, `watch`, `import`, or Studio write is holding it, or a killed process left the lock
file behind.
**Fix**: exactly what the message says: wait for the other run to finish, or, if none is running,
delete the lock file at the printed path and retry.
## Dotted keys or YAML keys collide (INVALID\_STRUCTURE) [#dotted-keys-or-yaml-keys-collide-invalid_structure]
**Symptom**: `A dotted key and a nested key path resolve to the same path.` (or the literal-leaf
variant), or for YAML: `A mapping key is a map or sequence (expected scalar keys).`
**Cause**: two entries in one file resolve to the same effective key, for example a literal
`"a.b"` key next to a nested `a: { b: ... }`, which verbatra rejects rather than silently dropping
one; or a YAML file uses a composite mapping key, which has no faithful string form.
**Fix**: rename one of the colliding keys, or replace the composite YAML key with a scalar. When
the file is your source locale, this surfaces wrapped in `SOURCE_INVALID`. See
[Formats](/docs/formats) for each format's key rules.
## The run stopped short of some keys (token budget) [#the-run-stopped-short-of-some-keys-token-budget]
**Symptom**: a `BUDGET_TOKENS_EXCEEDED` notice: `The run's cumulative token usage (N) reached the
configured budget of M tokens (behavior: ...)`; with `budgetBehavior: "stop"`, keys also appear
under `budgetWithheld`.
**Cause**: the configured `maxTokens` ceiling was crossed. With `"warn"` (the default) the run
continues unchanged; with `"stop"` every key not yet attempted is withheld for the rest of the
run. The budget never changes the exit code.
**Fix**: this is the guardrail working. Withheld keys keep their baselines and are translated on
the next run; raise `maxTokens` or switch to `"warn"` if you want single-run completion. A budget
against DeepL or a dry run reports `supported: false` and never trips, because no token usage
exists to measure.
## Studio: the port is already in use [#studio-the-port-is-already-in-use]
**Symptom**: `port 5849 is already in use` (or your `--port` value).
**Cause**: another process, often an earlier Studio instance, is bound to the port. Studio
defaults to 5849 on 127.0.0.1.
**Fix**: stop the other process, or start Studio on another port: `verbatra studio --port 6000`.
## Studio: @verbatra/studio is not installed [#studio-verbatrastudio-is-not-installed]
**Symptom**: `Verbatra Studio requires @verbatra/studio. Install it with: pnpm add -D
@verbatra/studio`
**Cause**: `@verbatra/studio` is a separate package the `studio` command loads dynamically, so the
rest of the CLI works without it.
**Fix**: install it as the hint says, then re-run `verbatra studio`.
## Studio: retranslate and translate actions are missing [#studio-retranslate-and-translate-actions-are-missing]
**Symptom**: editing translations works, but the retranslate and translate-pending actions do not
appear.
**Cause**: provider spend is a capability you grant at startup. Without it, the spend-gated
methods are not registered at all; local file editing is always on and needs no flag.
**Fix**: restart Studio with `verbatra studio --allow-spend`, or set
`VERBATRA_STUDIO_ALLOW_SPEND=1` (also `true`, `yes`, or `on`). Rapid repeated actions can also hit
Studio's own throttle, `METHOD_RATE_LIMITED: Too many calls to this method; wait before
retrying.`; that clears on its own. See [Review in Studio](/docs/review-in-studio).
---
# verbatra check (https://verbatra.kreitz-webdev.de/docs/cli/check)
Report, per target locale, how many keys are missing, stale, or up to date, then exit with a code a CI step can branch on. `check` runs the same read and diff that [`verbatra translate`](/docs/cli/translate) performs right before it would call a provider, then stops: no provider call, no files written, no [lock file](/docs/the-lock-file) update, and no API key needed.
## Synopsis [#synopsis]
```bash
verbatra check [flags]
```
## Flags [#flags]
| Flag | Argument | Default | Effect |
| ----------- | -------- | ---------------------- | ------------------------------------------------------------------------------- |
| `--cwd` | `` | current directory | resolve config and locale files from this directory |
| `--config` | `` | search for one | load this config file instead of searching for one |
| `--locales` | `` | all configured targets | comma-separated subset of target locales to check |
| `--json` | none | off | print the check summary as one JSON object on stdout; errors still go to stderr |
A key is missing when the source has it and the target does not; stale when the source string changed since it was last translated. A locale is in sync when it has neither. An empty or unknown `--locales` value is rejected with exit `2` rather than silently checking nothing, so a typo in a locale name never lets a CI gate pass. Want the actual key lists behind the counts? That is [`verbatra diff`](/docs/cli/diff).
## Examples [#examples]
```bash
# fail the build when translations drifted
verbatra check
# machine-readable status for two locales
verbatra check --locales de,fr --json
```
## Exit codes [#exit-codes]
| Code | Meaning |
| ---- | ---------------------------------------------------------------------------------------------------- |
| `0` | in sync: no locale has a missing or stale key |
| `1` | out of sync: at least one locale has a missing or stale key (the report is still printed) |
| `2` | could not run: a whole-run error, or a usage error (including an empty or unknown `--locales` value) |
Exit `1` means "out of sync", not a failure: the run itself succeeded, the result just is not all green.
## Related [#related]
* [`verbatra diff`](/docs/cli/diff) lists the exact keys behind these counts.
* [CI and exit codes](/docs/ci-and-exit-codes) shows check as a drift gate.
* [The lock file](/docs/the-lock-file) explains how staleness is detected.
---
# verbatra diff (https://verbatra.kreitz-webdev.de/docs/cli/diff)
List, per target locale, the exact pending keys: the ones a run would add (missing from the target), the ones it would re-translate (source changed since last translation), and the ones now orphaned (in the target but gone from the source). `diff` runs the same read and diff that [`verbatra translate`](/docs/cli/translate) performs right before it would call a provider, then stops: no provider call, no files written, no [lock file](/docs/the-lock-file) update, and no API key needed.
## Synopsis [#synopsis]
```bash
verbatra diff [flags]
```
## Flags [#flags]
| Flag | Argument | Default | Effect |
| ----------- | -------- | ---------------------- | ------------------------------------------------------------------------------ |
| `--cwd` | `` | current directory | resolve config and locale files from this directory |
| `--config` | `` | search for one | load this config file instead of searching for one |
| `--locales` | `` | all configured targets | comma-separated subset of target locales to diff |
| `--json` | none | off | print the diff summary as one JSON object on stdout; errors still go to stderr |
A locale has pending changes when it has at least one key to add or re-translate. Orphaned keys are reported but never count as pending, because a default run does not remove them (only [`verbatra translate --prune`](/docs/cli/translate) does). An empty or unknown `--locales` value is rejected with exit `2` rather than silently diffing nothing. Where [`verbatra check`](/docs/cli/check) answers "how much has drifted" with counts, `diff` answers "which keys" with the lists.
## Examples [#examples]
```bash
# list the pending keys per locale (exit 1 if any are pending)
verbatra diff
# machine-readable key lists for tooling or a PR comment
verbatra diff --json
```
## Exit codes [#exit-codes]
| Code | Meaning |
| ---- | --------------------------------------------------------------------------------------------------------- |
| `0` | no pending changes: no locale has a key to add or re-translate |
| `1` | pending changes: at least one locale has a missing or changed key (orphaned keys alone never produce `1`) |
| `2` | could not run: a whole-run error, or a usage error (including an empty or unknown `--locales` value) |
## Related [#related]
* [`verbatra check`](/docs/cli/check) gives the per-locale counts behind these lists.
* [`verbatra translate`](/docs/cli/translate) applies the pending changes; `--prune` removes the orphaned keys.
* [CI and exit codes](/docs/ci-and-exit-codes) shows diff in a pipeline.
---
# verbatra export (https://verbatra.kreitz-webdev.de/docs/cli/export)
Write the strings that need translating into a styled Excel workbook a human can fill in and send back for [`verbatra import`](/docs/cli/import). By default the workbook carries only the missing and changed strings: the same keys an automated run would send to a provider. `export` never calls a provider and needs no API key.
## Synopsis [#synopsis]
```bash
verbatra export [flags]
```
## Flags [#flags]
| Flag | Argument | Default | Effect |
| --------------------- | -------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `--cwd` | `` | current directory | resolve config and locale files from this directory |
| `--config` | `` | search for one | load this config file instead of searching for one |
| `--out` | `` | `verbatra-translations.xlsx` | write the workbook to this path |
| `--locales` | `` | all configured targets | comma-separated subset of target locales to export |
| `--include-unchanged` | none | off | also export already up-to-date strings |
| `--json` | none | off | print the export result (path and per-locale row counts) as one JSON object on stdout; errors still go to stderr |
An empty or unknown `--locales` value is rejected with exit `2` rather than silently exporting nothing.
## Examples [#examples]
```bash
# write the workbook with missing and changed strings
verbatra export
# only the German and French sheets, to a specific path
verbatra export --locales de,fr --out handoff/round-2.xlsx
```
## Exit codes [#exit-codes]
| Code | Meaning |
| ---- | ---------------------------------------------------------------------------------------------------- |
| `0` | the workbook was written |
| `2` | could not run: a whole-run error, or a usage error (including an empty or unknown `--locales` value) |
`export` has no per-locale failure mode, so it never exits `1`.
## Related [#related]
* [Manual translation](/docs/manual-translation) covers the workbook layout, the review columns, and the full round-trip.
* [`verbatra import`](/docs/cli/import) reads the filled workbook back.
---
# verbatra import (https://verbatra.kreitz-webdev.de/docs/cli/import)
Read a workbook a translator filled in back into your locale files. Every value runs through the same placeholder integrity and ICU integrity checks as an automated run: values that pass are written and [the lock file](/docs/the-lock-file) is updated; values that fail are withheld and reported instead of written, so a bad cell never lands in a locale file.
## Synopsis [#synopsis]
```bash
verbatra import [flags]
```
`` is the path to the workbook produced by [`verbatra export`](/docs/cli/export) and filled in by the translator.
## Flags [#flags]
| Flag | Argument | Default | Effect |
| ----------- | -------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `--cwd` | `` | current directory | resolve config and locale files from this directory |
| `--config` | `` | search for one | load this config file instead of searching for one |
| `--dry-run` | none | off | validate and report without writing locale files or updating the lock |
| `--json` | none | off | print the run summary as one JSON object on stdout (the same [`RunSummary` shape](/docs/sdk) as `translate`); errors still go to stderr |
## Examples [#examples]
```bash
# import the filled workbook
verbatra import verbatra-translations.xlsx
# validate and report, write nothing
verbatra import verbatra-translations.xlsx --dry-run
```
## Exit codes [#exit-codes]
| Code | Meaning |
| ---- | -------------------------------------------------------------------------------------- |
| `0` | every target locale succeeded |
| `1` | the run finished but one or more locales failed |
| `2` | could not run: a whole-run error (config, workbook, format, or lock), or a usage error |
The contract matches [`verbatra translate`](/docs/cli/translate), so a manual handoff branches the same way in CI.
## Related [#related]
* [Manual translation](/docs/manual-translation) covers the full export, fill, import round-trip and what a translator may edit.
* [Translation safety](/docs/translation-safety) explains the checks that can withhold a value.
* [`verbatra export`](/docs/cli/export) produces the workbook this command reads.
---
# Overview (https://verbatra.kreitz-webdev.de/docs/cli)
`@verbatra/cli` ships the `verbatra` binary, a thin wrapper over [`@verbatra/sdk`](/docs/sdk). Eight commands do the work:
| Command | What it does |
| ---------------------------------- | --------------------------------------------------------------------------------------------- |
| [`init`](/docs/cli/init) | create a verbatra config and `.env.example` for this project |
| [`translate`](/docs/cli/translate) | translate every target locale once, then exit |
| [`watch`](/docs/cli/watch) | re-translate on every source change until interrupted |
| [`check`](/docs/cli/check) | report which keys are missing or stale per locale, read-only |
| [`diff`](/docs/cli/diff) | list the exact keys that would be added, re-translated, or are orphaned per locale, read-only |
| [`export`](/docs/cli/export) | export untranslated strings into an Excel workbook for a human translator |
| [`import`](/docs/cli/import) | import a filled workbook back into the locale files |
| [`studio`](/docs/cli/studio) | start Verbatra Studio, the local translation dashboard |
## Install and invoke [#install-and-invoke]
Install `@verbatra/cli` as a dev dependency and run the binary through your package manager:
```bash
pnpm add -D @verbatra/cli
pnpm verbatra translate # or: npx verbatra translate, yarn verbatra translate
```
You need Node.js `>=22.14.0`.
## Shared conventions [#shared-conventions]
* `--cwd ` resolves the config and locale files from that directory instead of the current one. Every command takes it.
* `--config ` loads that config file instead of searching for one. Every command except `init` takes it; the search order is described in [The config file](/docs/config-file).
* `--json` prints a machine-readable result on stdout, keeping stdout clean for piping: errors go to stderr. `translate`, `watch`, `check`, `diff`, `export`, and `import` support it; `init` and `studio` do not.
* `--help` and `--version` print and exit `0`. An unknown command or flag exits `2`.
## Environment files [#environment-files]
`translate`, `watch`, and `studio` load `.env.local` and then `.env` from the working directory before they run. A variable already set in your real environment always wins. API keys are read only from the environment, never from the config file or a flag: see [Providers](/docs/providers). The read-only commands (`check`, `diff`) and the workbook commands (`export`, `import`) never call a provider and load no `.env` files.
## Exit codes [#exit-codes]
The exit code is the contract a CI step or script branches on:
| Code | Meaning |
| ----- | ----------------------------------------------------------------------------------------------------------------------------- |
| `0` | success (also `--help` and `--version`) |
| `1` | `translate` or `import` finished but some locales failed, `check` found a locale out of sync, or `diff` found pending changes |
| `2` | could not run: a whole-run error (config, source, provider, lock), or a usage error |
| `130` | `watch` or `studio` was force-stopped by a second interrupt |
Each command page spells out how these codes apply to it. See [CI and exit codes](/docs/ci-and-exit-codes) for wiring them into a pipeline.
---
# verbatra init (https://verbatra.kreitz-webdev.de/docs/cli/init)
Scaffold a project: `init` writes a `verbatra.config.ts` and a `.env.example` naming your provider's API key variable (never a key value), and makes sure `.gitignore` covers `.env`, `.env.local`, and `.verbatra-local/`. When stdin is a terminal and `--yes` is absent, it prompts for anything you did not pass as a flag; otherwise it takes the defaults, so it runs unattended in CI.
## Synopsis [#synopsis]
```bash
verbatra init [flags]
```
## Flags [#flags]
| Flag | Argument | Default | Effect |
| ------------ | ----------- | ----------------------- | ---------------------------------------------------------------------------------------------------------- |
| `--cwd` | `` | current directory | write the config and env files to this directory |
| `--provider` | `` | none (required) | provider to scaffold: `anthropic`, `openai`, `gemini`, or `deepl`; prompted for when omitted interactively |
| `--source` | `` | `en` | the source locale your strings are written in |
| `--targets` | `` | `de` | comma-separated target locales to translate into |
| `--path` | `` | `locales/{locale}.json` | locale file pattern; must contain the `{locale}` token |
| `--yes` | none | off | skip prompts and accept the defaults |
| `--force` | none | off | overwrite an existing `verbatra.config.ts` or `.env.example` |
The format is not a flag: `init` detects it from your dependencies (`i18next`, `vue-i18n`, `next-intl`, or `@ngx-translate/core`). With no single unambiguous match it falls back to `i18next-json` and leaves a TODO comment listing every supported format. Without `--force`, an existing `verbatra.config.ts` or `.env.example` is skipped, never overwritten, and the command still succeeds.
## Examples [#examples]
```bash
# create config + .env example, prompting for the rest
verbatra init --provider gemini
# non-interactive, accept all defaults
verbatra init --provider deepl --yes
```
## Exit codes [#exit-codes]
| Code | Meaning |
| ---- | ------------------------------------------------------------------------ |
| `0` | files written, or safely skipped because they already exist |
| `2` | a missing or unknown `--provider`, an invalid scaffold, or a usage error |
## Related [#related]
* [Your first translation](/docs/your-first-translation) walks from init to the first run.
* [The config file](/docs/config-file) documents every key the scaffold sets.
* [Providers](/docs/providers) covers the provider options and API key variables.
---
# verbatra studio (https://verbatra.kreitz-webdev.de/docs/cli/studio)
Start Verbatra Studio, a local web dashboard over your verbatra project: translation status, the needs-review queue, run activity, and your resolved settings, all live over the files on disk. Editing your own locale files works out of the box; only provider spend is opt-in, behind `--allow-spend`. Before it starts, `studio` loads `.env.local` and then `.env` from the working directory and resolves your config once; a config error exits `2` before any server starts.
## Synopsis [#synopsis]
```bash
verbatra studio [flags]
```
## Flags [#flags]
| Flag | Argument | Default | Effect |
| ---------------------- | -------- | ----------------- | ------------------------------------------------------------------------------------------ |
| `--cwd` | `` | current directory | resolve config and locale files from this directory |
| `--config` | `` | search for one | load this config file instead of searching for one |
| `--port` | `` | `5849` | listen on this port; must be an integer from 1 to 65535 |
| `--allow-spend` | none | off | allow Studio to call a translation provider (retranslate a key, translate pending changes) |
| `--expose-agent-tools` | none | off | register Studio's review actions as WebMCP tools so a browser AI agent can operate them |
When the `--allow-spend` flag is absent, Studio reads the `VERBATRA_STUDIO_ALLOW_SPEND` environment variable instead: `1`, `true`, `yes`, or `on` (case-insensitive) counts as on. The CLI flag always wins over the environment variable. Without either, Studio never contacts a provider; local editing needs no flag.
`--expose-agent-tools` resolves the same way from `VERBATRA_STUDIO_AGENT_TOOLS`, with the same truthy set and the same flag-wins-over-variable rule. It is off by default. Spend from an agent requires both this flag and `--allow-spend`; neither implies the other. See [Operate Studio with a browser agent](/docs/agent-tools-in-studio) for the tool catalog and the security model.
## The printed URL [#the-printed-url]
Studio binds to `127.0.0.1` only. Once it is listening, it prints one line to stdout:
```
Verbatra Studio running at http://127.0.0.1:5849/?token=
```
Open that exact URL: the token in the query string bootstraps your browser session, and every request without it is rejected. Nothing is printed before the port is successfully bound, so a failed startup never leaks a URL or a token.
## Installation [#installation]
`@verbatra/studio` is its own package, reached through a dynamic import so the rest of the CLI works without it. If it is not installed, `verbatra studio` exits `2` with:
```
Verbatra Studio requires @verbatra/studio. Install it with: pnpm add -D @verbatra/studio
```
## Examples [#examples]
```bash
# start Verbatra Studio on the default port
verbatra studio
# enable the provider-calling actions, on a specific port
verbatra studio --allow-spend --port 6000
```
## Exit codes [#exit-codes]
| Code | Meaning |
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | stopped cleanly by a single interrupt (Ctrl-C, or SIGTERM) |
| `1` | stopping failed: closing the server threw during shutdown |
| `2` | could not start: a config error, `@verbatra/studio` not installed, the port in use, or a usage error (an out-of-range or non-integer `--port`) |
| `130` | force-stopped by a second interrupt while shutdown was in flight |
## Related [#related]
* [Review in Studio](/docs/review-in-studio) walks through the Translations page, the review queue, and retranslating with `--allow-spend`.
* [Operate Studio with a browser agent](/docs/agent-tools-in-studio) covers `--expose-agent-tools` and the WebMCP tool surface.
* [`verbatra check`](/docs/cli/check) and [`verbatra diff`](/docs/cli/diff) are the same computations Studio shows live.
---
# verbatra translate (https://verbatra.kreitz-webdev.de/docs/cli/translate)
Run one translation pass over every target locale, then exit. `translate` reads the source locale, diffs it against [the lock file](/docs/the-lock-file), sends only the new or changed strings to the provider, runs the [integrity checks](/docs/translation-safety), writes the locale files, and updates the lock. Anything still current is never re-sent, so you never pay twice for an unchanged string.
## Synopsis [#synopsis]
```bash
verbatra translate [flags]
```
## Flags [#flags]
| Flag | Argument | Default | Effect |
| --------------- | -------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| `--cwd` | `` | current directory | resolve config and locale files from this directory |
| `--config` | `` | search for one | load this config file instead of searching for one |
| `--dry-run` | none | off | preview changes without calling a provider or writing files |
| `--prune` | none | the config's `prune` option, otherwise off | remove orphaned keys (in a target file but absent from source) from the written file and the lock |
| `--concurrency` | `` | `1` | translate up to `n` target locales at once; must be a positive integer |
| `--no-cache` | none | off (cache on) | bypass the local [translation-memory cache](/docs/the-cache) (`verbatra.cache.json`) for this run |
| `--json` | none | off | print the run summary as one JSON object on stdout (the [`RunSummary` shape](/docs/sdk)); errors still go to stderr |
By default a run deletes nothing: orphaned keys are reported and left in place. `--prune` removes exactly those keys and nothing else, and overrides the config's `prune` option for this run. Before it runs, `translate` loads `.env.local` and then `.env` from the working directory; a variable already set in the real environment wins.
`--concurrency` runs several target locales in parallel to finish a large project faster. It is refused with exit `2` when the config sets a `maxTokens` budget, because concurrent locales cannot honor a token cap deterministically (a dry run is exempt). As a run advances, progress is printed to stderr (one line per locale start, provider sub-batch, and locale finish), so a `--json` run's stdout stays a single clean summary. `--no-cache` skips the [translation-memory cache](/docs/the-cache) for the run, making exactly the provider calls it would with no cache present.
## Examples [#examples]
```bash
# translate once using the config it finds
verbatra translate
# preview the keys that would be pruned, spending nothing
verbatra translate --prune --dry-run
```
## Exit codes [#exit-codes]
| Code | Meaning |
| ---- | ---------------------------------------------------------------------------------------------- |
| `0` | every target locale succeeded |
| `1` | the run finished but one or more locales failed |
| `2` | could not run: a whole-run error (config, source, format, provider, or lock), or a usage error |
One locale failing is not fatal: the run finishes, exits `1`, and the per-locale detail sits in the summary.
## Related [#related]
* [How it works](/docs/how-it-works) explains the read, diff, translate, write pipeline.
* [The cache](/docs/the-cache) explains what `--no-cache` bypasses and how reuse is scoped.
* [Translation safety](/docs/translation-safety) covers the integrity checks and the needs-review queue.
* [`verbatra diff`](/docs/cli/diff) previews the same pending keys read-only.
* [CI and exit codes](/docs/ci-and-exit-codes) shows how to branch on these codes.
---
# verbatra watch (https://verbatra.kreitz-webdev.de/docs/cli/watch)
Watch the source locale file and re-run a translation on each debounced change, until you interrupt it. `watch` runs an initial translation at startup, then keeps your target locales current as you save. Before it runs, it loads `.env.local` and then `.env` from the working directory; a variable already set in the real environment wins.
## Synopsis [#synopsis]
```bash
verbatra watch [flags]
```
## Flags [#flags]
| Flag | Argument | Default | Effect |
| --------------- | -------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--cwd` | `` | current directory | resolve config and locale files from this directory |
| `--config` | `` | search for one | load this config file instead of searching for one |
| `--debounce` | `` | `300` | wait this many milliseconds after the last change before translating; must be a bare positive integer (a value like `250ms`, `0`, or `-1` is a usage error, exit `2`) |
| `--concurrency` | `` | `1` | translate up to `n` target locales at once per run; must be a positive integer |
| `--no-cache` | none | off (cache on) | bypass the local [translation-memory cache](/docs/the-cache) (`verbatra.cache.json`) on every run |
| `--json` | none | off | print each run as one NDJSON record (one JSON object per line) on stdout |
With `--json`, each run emits one [`WatchRunResult`](/docs/sdk) record: a success carrying the run summary, or a failure carrying an error code and message. The startup line, the stopping notice, and per-run progress all go to stderr, so the stdout stream stays clean NDJSON. A failed run is just a record on the stream: it neither stops the watcher nor changes the exit code.
`--concurrency` applies to each cycle's run. Because each run's token budget is fresh, a `--concurrency` above `1` still fails every run with exit `2` when the config sets a `maxTokens` budget. `--no-cache` bypasses the [translation-memory cache](/docs/the-cache) on every cycle.
## Stopping [#stopping]
The first interrupt (Ctrl-C, or SIGTERM) lets the current run finish, stops the watcher cleanly, and exits `0`. A second interrupt while that shutdown is still in flight force-stops with exit `130`.
## Examples [#examples]
```bash
# watch and re-translate on each change
verbatra watch
# calmer in a busy editor: wait 1s after the last change, stream NDJSON
verbatra watch --debounce 1000 --json
```
## Exit codes [#exit-codes]
| Code | Meaning |
| ----- | ---------------------------------------------------------------------------------------------------------------------------- |
| `0` | stopped cleanly by a single interrupt |
| `2` | could not start or stop: a config error, a startup or shutdown failure, or a usage error (including an invalid `--debounce`) |
| `130` | force-stopped by a second interrupt |
## Related [#related]
* [`verbatra translate`](/docs/cli/translate) is the one-shot run `watch` repeats.
* [The cache](/docs/the-cache) explains what `--no-cache` bypasses on each run.
* [How it works](/docs/how-it-works) explains what each run does.
* [The SDK reference](/docs/sdk) documents the `WatchRunResult` and `RunSummary` shapes.
---
# Add a language (https://verbatra.kreitz-webdev.de/docs/add-a-language)
Adding a language is a one-line config change plus a run. verbatra sees the new locale as missing everything and fills it completely; your existing locales cost nothing, because the lock file still has them on record as current.
## Add the locale to your config [#add-the-locale-to-your-config]
Add it to `targetLocales` in `verbatra.config.ts`:
```ts
import { defineConfig } from "@verbatra/cli";
export default defineConfig({
sourceLocale: "en",
targetLocales: ["de", "fr"], // "fr" added
format: "i18next-json",
files: {
pattern: "locales/{locale}.json",
},
provider: {
id: "gemini",
options: {
model: "gemini-2.5-flash",
maxOutputTokens: 4096,
},
},
});
```
Two rules, both enforced when the config loads: a target locale must not be the `sourceLocale`, and two entries must not collide case-insensitively (no `de` and `DE` in the same list).
## Preview the work [#preview-the-work]
Before spending a token, see what a run would do. `diff` is read-only and lists the pending keys per locale:
```bash
verbatra diff
```
The new locale has no target file yet, so verbatra treats it as empty and every source key shows up as missing; your existing locales list nothing, assuming their source strings have not changed. `diff` exits `1` while changes are pending, `0` once there are none. `verbatra translate --dry-run` gives you the same preview shaped as a run summary, with no provider call and no writes.
## Translate [#translate]
```bash
verbatra translate
```
The run translates every source key into the new locale and writes the file at your `files.pattern` (here `locales/fr.json`). A large source is split into sub-batches of at most 50 keys per provider request (configurable with `maxBatchSize`), so one oversized request cannot sink the whole locale.
This is the incremental behavior at work: only the new locale triggers provider calls. For each existing locale, verbatra diffs the source against that locale's baseline in `verbatra.lock.json`, finds no missing or changed keys, and sends nothing. Afterwards, `verbatra check` exits `0`: every locale, including the new one, is in sync.
## Languages with more plural categories [#languages-with-more-plural-categories]
Some languages need more CLDR plural categories than your source has. English has two (`one` and `other`); Polish, Arabic, and Russian have more. By default verbatra translates the plural forms it finds in the source and does not invent the extras: the run still succeeds and emits a per-locale notice (code `PLURAL_CATEGORIES_INCOMPLETE`) naming the missing categories.
To have verbatra synthesize the missing forms instead, set `generatePlurals: true` in your config:
```ts
export default defineConfig({
// ...
generatePlurals: true,
});
```
Generation is supported for i18next-JSON projects translated by an LLM provider only; DeepL, the other formats, and unknown target languages fall back to the notice and never fail the run. Generated forms are integrity-checked like any translation and reported separately from translated keys in the run summary.
## Maintaining the source locale [#maintaining-the-source-locale]
You only ever hand-maintain the source locale. To add strings, put them in the source file and run `translate` again: verbatra fills them into every target, including the one you just added.
## Next [#next]
* [The lock file](/docs/the-lock-file): why re-runs are cheap and what to commit.
* [Configuration](/docs/config-file): the full config schema, including `glossary` and `tone`.
* [Providers](/docs/providers): provider options and where to get a key.
---
# Your first translation (https://verbatra.kreitz-webdev.de/docs/your-first-translation)
Four steps take you from an empty project to a translated locale file. All you need is Node.js `>=22.14.0` and an API key for one provider.
## 1. Install [#1-install]
verbatra is a dev dependency:
```bash
pnpm add -D @verbatra/cli
```
npm and yarn work too. The package ships the `verbatra` binary; run it through your package manager (`pnpm verbatra ...`, `npx verbatra ...`, or `yarn verbatra ...`). The code blocks below use the bare name.
## 2. Scaffold a config [#2-scaffold-a-config]
```bash
verbatra init --provider gemini
```
With a terminal attached, `init` prompts for anything you did not pass as a flag, each with a default:
* the provider: one of `anthropic`, `openai`, `gemini`, or `deepl` (flag `--provider`, the only input without a default)
* the source locale, default `en` (`--source`)
* the target locales, comma-separated, default `de` (`--targets`)
* the locale file pattern, default `locales/{locale}.json` (`--path`)
Pass `--yes` to skip the prompts and take the defaults. Without a terminal (in CI, for example) `init` never prompts; it uses the defaults and only requires `--provider`.
`init` writes three things:
* `verbatra.config.ts`: your project config, validated against the real schema before it is written
* `.env.example`: names the provider's key variable, never a key value
* `.gitignore` entries for `.env`, `.env.local`, and `.verbatra-local/`, created or appended so a real key or local state never lands in a commit
Re-running `init` skips files that already exist; `--force` overwrites them. It also pre-fills `format` by looking at your dependencies: a project using i18next, vue-i18n, next-intl, or `@ngx-translate/core` gets the matching JSON format, anything else defaults to `i18next-json` with a TODO comment to change it. For Gemini the scaffold looks like this:
```ts
import { defineConfig } from "@verbatra/cli";
export default defineConfig({
sourceLocale: "en",
targetLocales: ["de"],
format: "i18next-json",
files: {
pattern: "locales/{locale}.json",
},
provider: {
id: "gemini",
options: {
model: "gemini-2.5-flash",
maxOutputTokens: 4096,
},
},
});
```
## 3. Set your API key [#3-set-your-api-key]
Keys come from the environment, never from the config file. Each hosted provider reads exactly one variable; for Gemini that is `GEMINI_API_KEY`. Copy the scaffolded example and fill in your key:
```bash
cp .env.example .env
```
Then open `.env` and paste your key after `GEMINI_API_KEY=`. verbatra loads `.env.local` and then `.env` from the working directory before a run, and a variable already set in your shell always wins. See [Providers](/docs/providers) for every provider's variable.
Gemini's API has a genuinely free tier, which makes it the cheapest way to try verbatra. Grab a key from [Google AI Studio](https://aistudio.google.com/apikey) and set `GEMINI_API_KEY`. The free tier has per-minute and per-day request limits, so pace a large first-time translation.
## 4. Translate [#4-translate]
If you do not have a source file yet, create one at the configured pattern, for example `locales/en.json`:
```json
{
"greeting": "Hello, {{name}}!",
"cart": {
"empty": "Your cart is empty."
}
}
```
Then run:
```bash
verbatra translate
```
verbatra reads the source locale, sees that every key is missing from `de`, sends them to the provider in batches, checks each result for placeholder and ICU integrity, and writes `locales/de.json`. The run ends with a per-locale summary: keys translated, keys unchanged, orphaned keys, and any notices. The exit code is `0` when every locale succeeded and `1` when one failed; add `--json` for a machine-readable summary.
## What just happened [#what-just-happened]
Three things are now on disk:
* `locales/de.json`: the target locale file. It has the same keys as your source, in the same document order, and `{{name}}` survived translation intact; a result that dropped it would have been withheld, not written.
* `verbatra.lock.json`: the lock file. For each target locale it maps every translated key to a hash of the source string that translation came from:
```json
{
"version": 1,
"locales": {
"de": {
"cart.empty": "",
"greeting": ""
}
}
}
```
* `.verbatra-local/`: process-local state (the run-status snapshot and per-locale write locks). `init` gitignored it; never commit it.
The lock file is the baseline for every future run. Run `verbatra translate` again without editing anything and nothing is sent: every key is already current. Edit one source string and only that key is retranslated. To preview any run without a provider call or a single write, use `verbatra translate --dry-run`.
Commit `verbatra.lock.json` alongside your locale files, so every machine and your CI diff against the same baseline. See [The lock file](/docs/the-lock-file).
## Next [#next]
* [Add a language](/docs/add-a-language): drop a new target locale into this setup.
* [How it works](/docs/how-it-works): the full pipeline behind a run.
* [Configuration](/docs/config-file): everything `verbatra.config.ts` can hold.