# 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: a translation that fails any of the integrity gate's checks 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]
Of the candidates that reach the provider, those sharing the same source content are grouped
first, so that text costs one request per locale rather than one per key; the value that comes
back is copied to the other keys in its group and re-checked against each one's own source in the
integrity step below. The groups 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.
## Run the integrity gate [#run-the-integrity-gate]
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 pass every check the gate applies; see
[Translation safety](/docs/translation-safety) for what those are. A candidate that fails any of
them 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 the same source content share one translation; the second is a cache hit.
A reused value is not trusted blindly. It still passes the same [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 `translate`, `watch` and `import` top up an existing `.gitignore` that is missing the entry, so a project scaffolded by an older version picks it up on the next run. 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, or oversized file degrades to an empty cache and is simply rebuilt on the next run. A file whose `version` this build does not recognize was written by a newer verbatra: it also degrades to an empty cache for the run, but is left on disk untouched rather than rebuilt, so upgrading is not destructive. The run reports a notice and otherwise proceeds normally. 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).
## Adopting an existing project [#adopting-an-existing-project]
Pointing verbatra at a repository whose locale files already hold reviewed translations does not
retranslate them. On the first run there is no lock file, so no key can be detected as stale: a
source key the target file already has is unchanged, is never sent to the provider, and keeps its
existing value. Only keys the target file does not have are missing, and only those are translated.
When nothing was accepted, pruned, or generated for a locale, its existing target file is not
rewritten at all.
The run still records the current source hash for every key the target holds, so the first run is
the adoption step: it establishes the baseline at zero provider cost, and from the second run on,
source edits show up as drift the normal way.
Adoption is decided by key presence, never by value, which has two consequences worth knowing
before that first run:
* An empty or untranslated value counts as translated. A target value that is an empty string, or
that is still the untranslated source text, is a key that exists, so it is unchanged and never
filled in. Scaffolding tools that pre-create locale files with empty string values run straight
into this. The fix is to delete the key from the target file: emptying its value does not help,
because an empty string is still an entry. Once the key is gone, the next run sees it as missing
and translates it.
* A translation that was already out of date is recorded as current. The lock takes the source hash
as it is now, so a translation that had already drifted from the source before you adopted the
project is taken as up to date and will not be revisited. Fix or delete those keys before the
first run.
Plural generation follows the same adoption rule. A plural form the target file already holds and
that verbatra did not generate itself is kept as it is, never regenerated and never sent to the
provider (a form the lock already tracks is still regenerated when its source changes), so
`generatePlurals` is safe to leave on while adopting a project that carries hand-written plural
forms; only forms genuinely absent from the target are synthesized.
You can see all of this before spending anything: `verbatra diff` lists the exact missing and
changed keys per locale, `verbatra check` reports the same as counts, and
`verbatra translate --dry-run` gives you the full run summary. None of the three calls a provider,
writes a locale file, or writes the lock.
## 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.
When a run translates several locales at once and hits a whole-run error (in practice a corrupt
lock file), it stops claiming new locales and waits for the ones already running to finish, so
every lock the run took is released before the command exits. No locale is started after the run
has already failed, and no lock is orphaned by the failure itself.
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 is a first run, and with no baseline nothing can be
detected as stale. Source keys the target file does not have are missing and get translated; keys
it does have are unchanged and are left untouched, as described under adoption above.
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.
Committing both is also what makes a rollback possible. Because the lock records source hashes and
nothing about the translated values, reverting your locale files without this file leaves it
claiming the reverted translations are current, and nothing will retranslate them. See
[Recovery and rollback](/docs/recovery-and-rollback).
---
# 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. It also runs per key on a reused value. When one translation is
served to several keys with the same source content (a [cache](/docs/the-cache) hit, or the copy
that makes identical text cost a single provider request), each key runs the gate against its own
source entry, and a key whose check fails is withheld rather than written. Sameness is decided by a
content hash that treats text as equal up to Unicode NFC normalization and LF line endings, so two
keys can match without being byte-identical. That is exactly why the gate is re-run per key.
* 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.
* Fabricated single-brace tokens: for the double-brace formats (i18next, ngx-translate, and YAML), a
`{name}`-shaped token is ordinary literal text rather than interpolation, so it is not extracted as
a placeholder and is not required to survive a translation. Inventing one is still rejected: a
`{name}`-shaped token that appears in the candidate and never appeared in the source is a
fabrication under any interpolation convention, which is what makes it safe to refuse without
knowing the project's delimiters. The check runs in that one direction only, so a candidate that
drops or changes the surrounding literal text is left alone.
* Degeneracy: the value must not be structurally degenerate, such as a repetition loop or a length
that has run away from the source. This catches a model that collapsed into repeating a fragment
even when every placeholder survived.
* Emptiness: a non-empty source may not be translated to an empty or whitespace-only value.
Clearing a translation is a separate, deliberate act, and the workbook's `[[CLEAR]]` sentinel is
the way to do it; see [Manual translation](/docs/manual-translation).
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. Under `warn` the budget never changes the
command's exit code. Under `stop` it can: a locale that had keys withheld is reported as `partial`
when some keys still landed and `failed` when none did, and both exit `1`. 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. |
| `files.localeStyle` | `"literal"`, `"posix"`, or `"android"` | `"literal"` | How the `{locale}` token is spelled for each locale. |
| `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. The pattern is a literal path, not a glob, and `{locale}` is the only token it understands, so one config addresses exactly one file per locale. If your project splits strings across several namespace files per locale, see [Namespace layouts](/docs/formats#namespace-layouts).
* **`files.localeStyle`** decides what the token expands to. `literal`, the default, uses the configured tag as written, which is what the JSON, YAML, XLIFF, ARB, properties, and Apple `.lproj` layouts all need. `posix` replaces `-` with `_`, for gettext (`locale/pt_BR/LC_MESSAGES/messages.po`) and the Java `messages_{locale}.properties` layout. `android` expands the token to a whole Android resource directory, written as `res/{locale}/strings.xml`: `values` for the source locale, then `values-de`, `values-pt-rBR`, and `values-b+zh+Hans`. A style refuses a locale it has no correct spelling for, rather than writing a directory the platform would silently ignore.
### 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.
Under `"warn"` a budget trip never changes the command's exit code. Under `"stop"` it can: a locale whose keys were withheld is reported as `partial` when some keys still landed and `failed` when none did, and both exit `1`. `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 by `translate`, `watch`, and `studio`, 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.
* **Single-brace text under the double-brace formats.** In `i18next-json`, `ngx-translate-json`, and `yaml`, a `{name}`-shaped token is literal text, not interpolation, so it is not extracted as a placeholder and a translation is free to drop or reword it. It is still guarded in one direction: a `{name}`-shaped token that shows up in a translation and never appeared in the source is rejected as a fabrication, which covers a placeholder invented outright and one altered into a name the source never had. verbatra has no setting for i18next's custom `interpolation.prefix` and `interpolation.suffix`, so if you have switched to single-brace delimiters, a translation that silently drops one of your placeholders is not caught.
* **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.
## Namespace layouts [#namespace-layouts]
Splitting strings across several namespace files per locale is common in i18next projects, with `common.json`, `auth.json`, and so on sitting next to each other under a locale directory. One verbatra config addresses exactly one file per locale: `files.pattern` is a literal path with `{locale}` substituted, it is never expanded as a glob, and there is no namespace token. A pattern such as `public/locales/{locale}/*.json` is looked up as a file literally named `*.json` and fails with `SOURCE_UNREADABLE`.
A single-namespace layout is the one that works as configured:
```ts
files: {
pattern: "public/locales/{locale}/common.json",
},
```
### One config per namespace [#one-config-per-namespace]
For a multi-namespace project, write one config per namespace and run verbatra once per config. Any filename works, since `--config` loads an explicit path by its extension:
```bash
verbatra translate --config verbatra.common.config.ts
verbatra translate --config verbatra.auth.config.ts
```
Each run handles its own namespace: keys that are missing from a target file are translated as usual.
Every config in the same working directory shares one `verbatra.lock.json`, and each `translate` run replaces that locale's recorded baselines with the keys it just processed. After the second run, the first namespace has no baselines left, so editing one of its source strings is reported as in sync by `check` and `diff` and is not retranslated. Changing the order of the runs does not help; whichever runs last wipes the other. To retranslate an edited string under this setup, delete that key from the target file (or delete the whole target file) so it counts as missing rather than changed.
## 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, including the single-brace fabrication guard described above, 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`.
* **Line endings follow the destination.** A file containing any CRLF is written back entirely with CRLF, a CR-only file with CR, and everything else, including a file that does not exist yet, with LF. A mixed file therefore converges on one style rather than being kept line by line, and a `\r` or `\n` inside a value is still escaped rather than emitted as a line break. This keeps a two-key translation change a two-line diff in the CRLF repositories these files often live in.
* **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`). The optional `requestTimeoutMs` is also accepted; see [Request timeout](#request-timeout).
## 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. The optional `requestTimeoutMs` is also accepted; see [Request timeout](#request-timeout).
## 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. The optional `requestTimeoutMs` is also accepted; see [Request timeout](#request-timeout).
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. It takes two optional options: `glossaryId`, naming a glossary you have already created in DeepL, and `requestTimeoutMs` (see [Request timeout](#request-timeout)):
```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. The optional `requestTimeoutMs` is accepted here too, and matters most for this provider: see [Request timeout](#request-timeout).
### 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 the integrity checks, 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 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.
### Request timeout [#request-timeout]
Every provider bounds each outbound request, so a server that accepts the connection but never answers cannot stall a run and hold a locale's write lock open. The bound is `requestTimeoutMs`, an optional positive integer of milliseconds accepted in the `options` of all five providers (`anthropic`, `openai`, `gemini`, `deepl`, and `openai-compatible`):
```ts
provider: {
id: "openai-compatible",
options: {
baseUrl: "http://192.168.178.74:1234/v1",
model: "qwen2.5-14b-instruct",
maxOutputTokens: 1024,
requestTimeoutMs: 300000, // 5 minutes
},
}
```
Leave it out and verbatra applies its own default of `120000` ms (two minutes): long enough that a legitimately slow large batch is not cut off, short enough that a hung-but-alive server cannot block a locale indefinitely. Raise it when a slow local model or a large `maxBatchSize` needs longer; lower it to fail faster. When the bound elapses, that request fails with the retriable `TIMEOUT` code and only its own sub-batch is affected, so the keys come back on the next run.
DeepL is the one exception to cancellation: its client cannot abort a request already in flight, so on timeout verbatra stops waiting and moves on, but the request it sent is left to settle at DeepL.
### 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. |
| `INVALID_REQUEST` | The request failed validation before any network call: a malformed field, or a locale code DeepL does not accept (a regional source code, or a bare `en` or `pt` target). Fix the config or the request; retrying will not help. |
| `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, and [`requestTimeoutMs`](#request-timeout) raises the bound. |
| `AUTH_FAILED` | HTTP 401 or 403: the key is invalid, revoked, or lacks permission. Retrying will not help. |
| `PROVIDER_UNAVAILABLE` | HTTP 5xx: the provider failed on its own side, and the request itself was never at fault. The retry layers below already absorb a transient 5xx, so one that reaches this code is a sustained outage. Wait and retry later. |
| `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. When the failure is a recognizable transport problem, the message names it (connection refused, host name not resolved, connection closed, host unreachable, TLS certificate not verified) and says what to check; otherwise it falls back 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.
For `openai-compatible`, a `PROVIDER_ERROR` also names the host and port of the configured `baseUrl`, since an endpoint you host yourself is the one that actually goes unreachable: `The translation provider request to localhost:11434 failed: the connection was refused. Check that the endpoint is running and that the configured host and port are right, then run again.` Only the URL's host component is used, so any path, query, or user-info in `baseUrl` never reaches a message.
### 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 fails any of its checks 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.
## The degraded-mode notice [#the-degraded-mode-notice]
Registration has three possible outcomes, and only one of them is a problem.
**Nothing attempted, nothing reported.** When `document.modelContext` is absent (a browser without WebMCP, or Chrome without the flag) or when you did not opt in, Studio attempts no registration at all. The dashboard is unchanged and the console stays quiet. That is the designed no-op, not a failure.
**Eleven tools and no notice.** Without `--allow-spend`, Studio attempts eleven registrations rather than thirteen: the two spend tools are skipped before they are ever attempted. Eleven tools is the healthy count for a session without spend, so their absence is the expected state and never raises a notice.
**The notice.** If Studio attempted a registration and the browser rejected it, the dashboard renders a warning at the top of the content area, on every view:
> Agent tools degraded: 2 of the agent tool registrations failed with SecurityError. The dashboard itself is unaffected. The browser console lists every failing tool.
The count and the error name are the real ones from that pass. The notice has no dismiss control and stays for the life of the page. Reloading the tab runs a fresh registration pass, since the status is held in memory only.
### What still works while degraded [#what-still-works-while-degraded]
A failing tool never cancels the ones after it, so every tool that did register stays callable and the surface is usually partly usable rather than down. The dashboard is untouched: the human UI, the RPCs, and every capability gate behave exactly as they would with the surface off, and no locale file is affected.
### Reading the console [#reading-the-console]
Studio writes one aggregate line at error level, not one line per tool, because a shared cause fails every tool identically:
```text
Verbatra agent tools: 2 of 11 tool registrations failed. verbatra_status_diff (SecurityError: ...); verbatra_key_value (SecurityError: ...)
```
The first sentence gives how many of the attempted registrations failed, and out of how many attempts. Each failing tool then follows as `name (ErrorName: message)`, separated by semicolons. The error name identifies the cause: it is the `name` of the value the browser rejected with, usually a `DOMException` such as `SecurityError` or `InvalidStateError`.
A pass that never reached the per-tool loop reports a different line and does not raise the notice:
```text
Verbatra agent tools: registration did not start (TypeError: Failed to fetch).
```
That means the project snapshot call that decides whether the surface is opted in failed at load, so no tool was attempted.
### What to do about it [#what-to-do-about-it]
No Studio setting fixes a rejected registration, because the rejection comes from the browser. In practice:
* Read the error name in the console first. It names the cause more precisely than the notice does.
* Reload the tab. Registration runs once per page load, so a transient rejection clears.
* Re-check the browser requirements above. A missing flag or a browser without WebMCP produces the silent no-op, never this notice, so if you see the notice the surface did start.
* Keep using the tools that registered, or work in the dashboard, which is unaffected either way.
## 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` finished every locale complete, `check` found every locale in sync, `diff` found no pending changes, `export` wrote its workbook, `init` scaffolded the project, `watch` or `studio` stopped cleanly, or `--help` or `--version` was printed |
| `1` | it ran, but the result is not clean: `translate` or `import` finished with at least one failed or partial locale, `check` found a locale out of sync, `diff` found a missing or changed key, or `studio` failed while shutting its server down |
| `2` | could not run: a whole-run error (bad config, unreadable source, corrupt lock file), a usage error (an empty or unknown `--locales` value, an invalid `--debounce` or `--port`), `init` without a resolvable provider or unable to scaffold a valid config, `watch` failing to start or to stop, or `studio` unable to load the config, import `@verbatra/studio`, or start its server |
| `130` | `watch` or `studio` was force-stopped by a second interrupt |
Some edge cases worth knowing:
* A **partial** locale counts as failure. It means the file was written but some keys are still missing, usually because a provider sub-batch failed or the integrity gate refused a translation. It exits `1` exactly like a failed locale, because a half-translated locale on disk is not a state a pipeline should wave through. Read `partial` on the summary to tell the two apart.
* A **corrupt lock file** is a whole-run error in `translate` and `import` alike, never one failed locale. It is a single shared file, so a lock file that turns corrupt while the run is under way stops the run with `2` rather than carrying on through the remaining locales. Whatever was written before the abort stays on disk; fix the lock file and re-run the command.
* A single interrupt is a clean stop, and both `watch` and `studio` exit `0` on it. Do not assume the two behave alike beyond that: if the stop itself fails, `watch` exits `2` and `studio` exits `1`.
* 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`.
* One failure mode sits outside the contract: a parse failure that is not a usage error is re-thrown, and the binary does not catch it, so Node's default handling of an unhandled rejection applies instead of any of the four codes.
## 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. `translate`, `watch` and `export` take the same flag, which is how you translate one locale at a time against a rate-limited provider.
## JSON output [#json-output]
Six commands accept `--json` for machine-readable output on stdout: `translate`, `watch`, `check`, `diff`, `export`, and `import`. Every record is one line, and every record has the same envelope around it, so you branch on one field and never have to guess which command's payload you are holding:
```ts
type Envelope =
| { ok: true; version: 1; command: string; result: TResult }
| { ok: false; version: 1; command: string | null; code: string; message: string };
```
`version` is the version of this envelope shape, not the package version. It is an integer so you compare it with `===` rather than range-parsing it, and it changes only when an existing field changes meaning or disappears. New fields can appear without a bump, so ignore the ones you do not recognize.
A run that fails as a whole writes exactly one `ok: false` record to stdout and exits `2`. Its `code` is the same stable error code the stderr line carries, so it is what you branch on:
```json
{ "ok": false, "version": 1, "command": "translate", "code": "CONFIG_INVALID", "message": "..." }
```
`command` is `null` only when the failure happened before a subcommand was resolved.
Errors also go to stderr as one structured line (`verbatra: error [CODE] message`) in both modes, unchanged, so a script that reads the exit code and stderr needs no update. Without `--json`, a failing run still writes nothing at all to stdout. Progress and lock-wait records always go to stderr, so stdout carries nothing but envelopes.
The rest of this section describes the `result` each command puts inside a successful envelope.
`verbatra translate --json` and `verbatra import --json` carry one `RunSummary`:
```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
partial: string[]; // locales written with keys still missing; these exit 1 too
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 envelope per run as NDJSON (one JSON object per line), with `command: "watch"`. A succeeded run is an `ok: true` record carrying that run's `RunSummary`; a failed run is an `ok: false` record carrying its code and message. A failed run is just a record on the stream: it neither stops the watcher nor changes the exit code.
`verbatra check --json` carries 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` carries 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` carries 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.
`translate`, `watch`, and `studio` also load `.env.local` and then `.env` from the working directory before they run, with real environment variables always winning; `check`, `diff`, `export`, and `import` load no `.env` files, so in CI you will normally rely on `env:` alone.
---
# Estimating cost (https://verbatra.kreitz-webdev.de/docs/estimating-cost)
Before you point an AI tool at a real locale file, you want an order-of-magnitude answer to what it will spend. This page gives you a method rather than a price list: verbatra does not track provider rates, and rates change, so the durable part is knowing which units you are billed for and how to count them. Every number below is an assumption you replace with your own.
The short version for a typical project: a few hundred keys into a few locales on a cheap model costs cents, not dollars, and the run after that costs almost nothing because verbatra only sends what changed.
## Step 1: size the job with a dry run [#step-1-size-the-job-with-a-dry-run]
A dry run is the tool-supported answer to "how much work is there". It computes exactly which keys would be sent, without constructing a provider, reading an API key, making a network call, or writing anything:
```bash
verbatra translate --dry-run
```
```
verbatra translate (dry run)
de: 400 translated, 0 unchanged
es: 400 translated, 0 unchanged
fr: 400 translated, 0 unchanged
3 succeeded, 0 partial, 0 failed (dry run: nothing written)
```
On a dry run, the `translated` count is the number of keys that **would** be sent to the provider: the keys missing from the target file plus the keys whose source text changed since the lock baseline, minus any skipped for invalid ICU. That count is the input to everything below.
Treat it as an **upper bound**, for two reasons. A dry run never reads [the cache](/docs/the-cache), so keys a live run would serve from translation memory are still counted here. And a dry run does not deduplicate: on a live run, keys whose source text is identical are grouped and only one representative is sent, so a file with repeated strings bills for fewer keys than the dry run shows.
A dry run reports counts, not tokens and not money. Turning the count into a number is the rest of this page. See [`verbatra translate`](/docs/cli/translate) for the full flag list.
## Step 2: count the requests [#step-2-count-the-requests]
verbatra does not send one request per key. It splits each locale's work into sequential sub-batches of at most `maxBatchSize` keys (default `50`), and each sub-batch is one provider request:
```
requests per locale = ceil(keys to translate / maxBatchSize)
```
Batch size matters more than key count for the fixed overhead, because part of every request is constant (see the next step). Two adjustments:
* A response that comes back missing keys triggers **at most one** bounded repair request for the still-missing subset. In normal operation this is zero extra requests.
* `generatePlurals` adds its own batched requests for the plural forms it fills in, counted the same way.
## Step 3: count the tokens in a request [#step-3-count-the-tokens-in-a-request]
An LLM request carries four things. Only one of them scales with your key count:
| Part | Scales with | Approximate size |
| ------------- | ----------------------------- | ---------------------------------------- |
| System rules | nothing: constant per request | \~250 tokens |
| Output schema | nothing: constant per request | \~100 tokens |
| Items payload | keys in the sub-batch | key + value + JSON punctuation, per item |
| Response | keys in the sub-batch | key + translated value, per item |
The system rules are a compile-time constant, identical on every request, which is why the per-request overhead is knowable rather than a guess. The items payload is a JSON object holding the source and target locale, an optional tone and glossary, and one item per key with its `key`, `value`, and optional `description` and `meaning`. The response repeats each key alongside its translation, so output size tracks input size closely.
For counting, the usual rule of thumb is **\~4 characters per token** for English prose. Non-Latin scripts and heavily punctuated strings run denser, so treat this as an order of magnitude, not a measurement.
## Step 4: price it with your provider's rates [#step-4-price-it-with-your-providers-rates]
verbatra deliberately publishes no prices. Look up the current rate for your model on the provider's own page, which is the only authority:
| Provider | Billed by | Pricing |
| ----------------- | --------------------------------------------- | ------------------------------------------------------------------- |
| Gemini | input and output tokens (free tier available) | [Gemini API pricing](https://ai.google.dev/gemini-api/docs/pricing) |
| Anthropic | input and output tokens | [Anthropic pricing](https://www.anthropic.com/pricing) |
| OpenAI | input and output tokens | [OpenAI API pricing](https://openai.com/api/pricing/) |
| DeepL | source **characters**, not tokens | [DeepL Pro pricing](https://www.deepl.com/pro) |
| openai-compatible | nothing: your own hardware | no API cost |
## A worked example [#a-worked-example]
Substitute your own numbers for every assumption in this block.
**Assumptions**
* 400 keys to translate per locale (the dry-run count above), 3 target locales
* average source value 40 characters, average key name 20 characters
* no `description`, `meaning`, glossary, or tone
* `maxBatchSize` at its default of `50`
* provider `gemini`, model `gemini-2.5-flash`
* 4 characters per token
* translated values roughly the same length as the source
* **illustrative** rate of `$0.10` per million input tokens and `$0.40` per million output tokens: this is a placeholder to make the arithmetic concrete, not a quoted price. Look up the real one.
**Requests**
`ceil(400 / 50)` = 8 requests per locale, 24 requests in total.
**Input tokens per request**
| | |
| ------------------------------------------------- | ----------- |
| System rules | 250 |
| Output schema | 100 |
| 50 items x (20 + 40 + \~20 punctuation) chars / 4 | 1,000 |
| **Total** | **\~1,350** |
**Output tokens per request**
50 items x (20 char key + 40 char value + \~15 punctuation) / 4 = **\~950**.
**Totals across 3 locales**
* Input: 24 x 1,350 = **\~32,400 tokens**
* Output: 24 x 950 = **\~22,800 tokens**
* Cost: (32,400 / 1,000,000 x $0.10) + (22,800 / 1,000,000 x $0.40) = $0.0032 + $0.0091 = **about 1.2 cents**
Note where the fixed overhead lands: the system rules and schema account for 24 x 350 = 8,400 of the 32,400 input tokens, about a quarter. Raising `maxBatchSize` spreads that constant over more keys; lowering it (to stay under a rate limit, say) costs proportionally more.
## The accurate method: calibrate on one locale [#the-accurate-method-calibrate-on-one-locale]
Any estimate above is arithmetic on assumptions. The precise number comes from translating one locale for real and reading the tokens verbatra reports.
`translate` has no locale-selection flag, so narrow the run through the config instead. Copy your config, cut `targetLocales` down to a single locale, and point at the copy with `--config`:
```bash
verbatra translate --config verbatra.one-locale.config.ts
```
```
verbatra translate
de: 400 translated, 0 unchanged, 18400 tokens (10800 in, 7600 out)
total: 18400 tokens (10800 in, 7600 out)
1 succeeded, 0 partial, 0 failed
```
Multiply that measured figure by your remaining locale count. Token usage is also on the `--json` output as `usage.inputTokens` and `usage.outputTokens`, so a script can do the scaling. This is the number to bring to whoever approves the spend.
The calibration run is a real run: it spends real money and writes real translations for that locale. That is the point, and the work is not wasted, because the remaining locales pick up from the same source and the calibrated locale is now done.
To cap a run rather than predict it, set `maxTokens` with `budgetBehavior: "stop"`, which withholds every key not yet attempted once the ceiling is crossed; withheld keys are retried automatically on the next run. See [the configuration reference](/docs/config-file).
## DeepL is billed differently [#deepl-is-billed-differently]
DeepL is a machine-translation API, not a token-billed LLM, so it does not share the formula above:
* The billable unit is **source characters**. For the example project that is 400 keys x 40 characters = 16,000 characters per locale, 48,000 across three.
* verbatra sends DeepL only the source text. There are no system rules, no key names, and no JSON envelope in the billed payload, so there is no per-request constant to amortize.
* DeepL withholds strings carrying placeholders or ICU syntax rather than risking them, so the characters actually sent can be lower than the raw total.
* DeepL reports no token usage, so a configured `maxTokens` budget is reported as unsupported rather than silently appearing to work.
## Why the second run is nearly free [#why-the-second-run-is-nearly-free]
The single biggest factor in what verbatra costs over time is that runs are incremental. Only keys that are missing or whose source text changed since [the lock file](/docs/the-lock-file) baseline are sent. Re-running an unchanged project sends nothing and costs nothing.
So the worked example above is the **first-run** cost, the one-time bill for translating a project from scratch. Day to day you are paying for the handful of strings you edited since the last run, which is why the ongoing cost of keeping a project translated is far below the initial number. [The cache](/docs/the-cache) removes repeat cost further, by reusing an identical earlier translation instead of paying for it twice.
To rehearse the whole thing at zero cost first, use Gemini's free tier or point the `openai-compatible` provider at a local model. See [Providers](/docs/providers).
---
# 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 its own repository, [verbatra/action](https://github.com/verbatra/action), separate from the verbatra monorepo, and is listed on the [GitHub Actions Marketplace](https://github.com/marketplace/actions/verbatra). Reference it by repository, pinned to a commit SHA:
```text
uses: verbatra/action@
```
`verbatra/action@v1` is the convenience form: the `v1` tag moves with each v1 release, so fixes reach you without a workflow edit, at the cost of not knowing exactly what runs. SHA pinning is the security-conscious choice either way, and is what the examples on this page use. There is no npm package; the action is consumed only through `uses:`.
It used to ship from inside the verbatra monorepo and was referenced by path. That older form no longer resolves, so point existing workflows at the repository above.
## Usage [#usage]
```yaml
name: translate
on:
push:
branches: [main]
permissions:
contents: read
jobs:
verbatra:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@
- uses: verbatra/action@
with:
version: 0.8.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. The pin above is only an example: check the [`@verbatra/cli` package on npm](https://www.npmjs.com/package/@verbatra/cli) for the current release and bump the pin deliberately, rather than tracking a moving tag.
### 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.8.0` or `0.8.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 or came out partial), 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 integrity 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.
* **Degenerate or empty values**: a value that collapsed into a repetition loop, ran away in length from its source, or came back empty for a non-empty source is withheld. Use the `[[CLEAR]]` sentinel above to clear a translation deliberately.
* **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 blank row whose key still needs a translation (reported as unfilled, still pending, whether the row was exported as `new` or `changed`), 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. Membership in the unfilled list is decided against the project as it stands at import time, so a first handoff that comes back untouched reports every pending key rather than nothing, and a row exported as `changed` whose key no longer needs work is left out.
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 comes out complete, `1` when one fails or comes out partial, `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
```
## CSV and TSV instead of a workbook [#csv-and-tsv-instead-of-a-workbook]
Some vendors and pipelines want plain text rather than Excel. `--format csv` and `--format tsv` write the same columns, in the same order, as one file per locale:
```bash
# write handoff/de.csv and handoff/fr.csv
verbatra export --format csv --out handoff
# import every locale file in the directory back
verbatra import handoff --format csv
```
Import also takes a single file (`verbatra import handoff/de.csv --format csv`) and reads the locale from the file name, so keep the files named as they were exported.
What differs from the workbook:
* **No protection.** A workbook leaves only `Translation` editable and hides `Source hash`; a text file cannot. Every column is editable and the hash is visible. An edited or blanked hash is not trusted: the row is compared against the live source and withheld as drift, exactly like a stale row.
* **No instructions sheet.** Pass the handing-off rules above to the translator yourself.
* **Quoting is RFC 4180.** A value containing the delimiter, a double quote, a line break, or padding whitespace is quoted, and quotes inside it are doubled, so nothing in a string can shift a column. Files are written with LF line endings, and `csv` carries a UTF-8 BOM so Excel reads it as UTF-8.
* **A spreadsheet may still change what it opens.** Opening a `.csv` or `.tsv` in a spreadsheet can coerce a value (`007` becoming `7`) and can treat a value starting with `=`, `+`, `-`, or `@` as a formula. Quoting does not prevent it, because a spreadsheet strips the quoting before evaluating. verbatra escapes the lead instead: a value beginning with `=`, `+`, `-`, `@`, a tab, or a carriage return is exported with a leading apostrophe, the marker that tells a spreadsheet to treat the cell as text. Import strips that apostrophe again, so ordinary translations like `-5 Grad` and `+49 30 1234567` are stored exactly as the translator typed them. A leading space is not part of the guard and is left alone. Treat a file coming back from a translator as untrusted input.
* **A narrower re-export retires the locales it drops.** Every export records the locales it wrote in a hidden manifest in the output directory. Files from an earlier, wider selection stay on disk but are refused on import (`HANDOFF_FILE_STALE`) instead of being applied as if they were fresh. Nothing is ever deleted, so an unrelated file in that directory is safe.
* **Everything else is identical.** The same diff picks the rows, and every returned row runs the same integrity gate, so a delimited handoff and a workbook produce the same result for the same values.
## 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 integrity 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 });
// ...or one CSV file per locale, into a directory
await exportWorkbook({ config, format: "csv", out: "handoff" });
// ...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 integrity gate that guards 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 integrity 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
npm install --save-dev @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 fails any of the integrity checks is rejected with the reason named and **nothing is written**. Clearing a translation is not one of the things an edit can express, so an empty value is refused too; the workbook's `[[CLEAR]]` sentinel is the supported way to unset a key. 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} ok, ${summary.partial.length} partial, ${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 || summary.partial.length > 0) {
process.exitCode = 1;
}
```
A `partial` locale is one whose file was written with keys still missing. It is not a success:
the CLI exits `1` on it, and your own script should treat it the same way, which is why the check
above tests `partial` alongside `failed`.
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. Pass `locales: ["de"]` to
restrict the run to a subset of the configured targets, which is how you work through a
rate-limited provider one language at a time; an unconfigured locale throws `UNKNOWN_LOCALE` before
anything is read or spent. 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
integrity 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 and integrity 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
npm install --save-dev @verbatra/sdk
# pnpm
pnpm add -D @verbatra/sdk
# yarn
yarn add -D @verbatra/sdk
```
Requires Node.js `>=22.14.0`.
The pnpm line installs correctly but exits `1` with `ERR_PNPM_IGNORED_BUILDS`: pnpm gates the
install scripts of the bundled Gemini SDK and its `protobufjs`, and leaves an unanswered
`pnpm-workspace.yaml` behind that makes every later pnpm command in the project fail the same way.
Run `pnpm approve-builds` once and approve neither entry, or see
[Troubleshooting](/docs/troubleshooting) for the non-interactive fix. There is no `npx` shortcut
around this the way there is for the CLI, because you are importing a library, not running a
binary.
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 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). A whole-run error raised once locales
are already running stops any further locale from being started, and the locales in flight finish
and release their write locks before `translate` rejects, so the rejection arrives after the
slowest one rather than instantly.
* `cache` defaults to true and reuses the local [translation-memory cache](/docs/the-cache)
(`verbatra.cache.json`, also exported as `CACHE_FILE_NAME`); 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`.
Returns a `WatchController` with one method, `stop()`, which closes the watcher and awaits the
in-flight run. Three problems are refused at startup, before the watcher exists, so `watch` itself
rejects rather than returning a controller: a `concurrency` that is not an integer of at least 1
throws `CONCURRENCY_INVALID`, a `concurrency` above 1 against a config that sets `maxTokens` throws
`CONCURRENCY_BUDGET_CONFLICT` (there is no dry-run watch, so the budget conflict always applies),
and a missing source file throws `SOURCE_UNREADABLE`. Every failure after startup 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
integrity 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" | "degenerate" | "empty", value }` with nothing
written. `"empty"` covers an empty or whitespace-only value for a source that has text: an edit
cannot express clearing a key, so use the workbook's `[[CLEAR]]` sentinel for that.
### retranslateEntry [#retranslateentry]
Re-runs the provider for exactly one key and locale: a single-entry call through the same provider
path `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" | "degenerate" | "empty", 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.
## Locale paths [#locale-paths]
### createLocalePathResolver [#createlocalepathresolver]
Resolves the project's locale-to-path mapping in both directions, from `files.pattern`, the
configured locales, and `files.localeStyle`. `createLocalePathResolver(cwd, config)` returns
`{ pathFor, localeFor }`: `pathFor(locale)` is the absolute path of one locale's file, and
`localeFor(path)` is the locale that owns a path, or `undefined` for a path this project does not
own. Every SDK entry point resolves paths through it, so a watcher or dashboard built on the SDK
sees exactly the paths a run writes.
Every check runs when the resolver is created, before any file is read: a pattern and style that
cannot be combined, or a locale the style has no correct spelling for, throws
`LOCALE_LAYOUT_INVALID`, and two locales resolving to one path throws `LOCALE_PATH_COLLISION`.
## The workbook pair [#the-workbook-pair]
The translator handoff, as an Excel workbook or as delimited text; 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?, format? }`. `format` is `xlsx` by
default; `csv` and `tsv` write one `.csv` or `.tsv` per locale instead, so `out`
names a directory for them (created if missing) and a file path for `xlsx`. `out` defaults to
`DEFAULT_WORKBOOK_PATH` (`verbatra-translations.xlsx`) or `DEFAULT_DELIMITED_PATH`
(`verbatra-translations`), both exported as constants. The accepted values are exported as `EXCHANGE_FORMATS` and the default as `DEFAULT_EXCHANGE_FORMAT`, so a tool that wraps the SDK can validate a format argument without hardcoding the list. 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 and integrity
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?, format? }`. With `csv` or `tsv`, `workbook` is either
one interchange file or the directory holding one per locale, and the locale comes from the file
name. 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
partial: string[]; // locales written with keys still missing; the CLI exits 1 on these
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" | "partial" | "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: blank rows whose key still needs a translation
malformedRows: { row: number; line?: number; column: string }[]; // import only: rows the reader could not parse
duplicateKeys: { key: string; row: number; line?: 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`, `BUDGET_TOKENS_EXCEEDED`, and `CACHE_VERSION_UNRECOGNIZED`. The
cache notice is run-wide (one file, shared by every locale), so it is attached to each locale in
the run.
* **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 row whose key still needs a
translation at import time (whether it was exported as `new` or as `changed`), a workbook row the
reader could not parse, and a duplicated key whose first occurrence won. The optional `line` on
the last two is the file line the record starts on, present only for a delimited (`csv` or `tsv`)
import.
## 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 |
| `LOCALE_LAYOUT_INVALID` | `files.pattern` and `files.localeStyle` cannot be combined, or the style has no valid path spelling for a configured locale |
| `LOCALE_PATH_COLLISION` | two configured locales resolve to the same absolute file 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) |
| `TARGET_UNWRITABLE` | a target locale file could not be written (its directory is not writable, does not exist, is read-only, or is out of space); the message names the target file and the file-system code, never the internal temporary file the atomic write uses |
| `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.
## What does a run actually cost? [#what-does-a-run-actually-cost]
A worked example, to put an order of magnitude on it. A project with 400 keys to translate into
3 locales, at the default `maxBatchSize` of 50, sends 8 requests per locale, so 24 in total. At
roughly 4 characters per token, with 40-character source values and 20-character key names, that
is about 32,400 input tokens and 22,800 output tokens, so about 55,000 tokens for the whole run.
On `gemini-2.5-flash`, at an illustrative rate of $0.10 per million input tokens and $0.40 per
million output tokens, that works out to about 1.2 cents.
The assumptions behind that number, so you can rescale it to your own project: 400 keys per
locale and 3 locales; 40-character values and 20-character key names; no `description`, `meaning`,
glossary, or tone; `maxBatchSize` at its default; 4 characters per token; translations about as
long as their source. The rate is a placeholder that makes the arithmetic concrete, not a quoted
price: look up the real one on your provider's own pricing page, because verbatra does not track
provider rates and they change.
Two things dominate the real bill more than the key count. Every request carries a constant
overhead of about 350 tokens (the fixed system rules and output schema), so a larger
`maxBatchSize` spreads that constant over more keys and a smaller one costs proportionally more.
And that figure is the first-run cost only: runs are incremental, so day to day you pay for the
handful of strings you changed, not the whole file. DeepL does not fit this formula at all, since
it bills source characters rather than tokens.
See [Estimating cost](/docs/estimating-cost) for the method, the DeepL case, and how to calibrate
against a real measured run.
## 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`,
`.verbatra-local/`, or `verbatra.cache.json`; `verbatra init` adds all four to `.gitignore`, and
`translate`, `watch`, and `import` top up an existing `.gitignore` that is missing any of them. 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`. `translate`, `watch`,
and `studio` load `.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).
---
# Recovery and rollback (https://verbatra.kreitz-webdev.de/docs/recovery-and-rollback)
verbatra has no undo command and keeps no backups. A run rewrites your locale files in place and
updates `verbatra.lock.json`, and nothing on disk remembers the previous state. Version control is
the recovery mechanism, which is the practical reason both the locale files and the lock file are
committed: together they are the snapshot you can go back to.
## What a run writes [#what-a-run-writes]
A `translate`, `watch`, or `import` run writes two files a rollback has to care about:
* the target locale files, one per locale, at your `files.pattern` path
* `verbatra.lock.json`, one source content hash per translated key
The rest of what it writes is regenerable sidecar state you never restore: `verbatra.cache.json`
and the `.verbatra-local/` directory, both gitignored. A run may also add those entries to an
existing `.gitignore` that is missing them, which is one-time housekeeping rather than part of any
rollback.
Both tracked files are written atomically (a temporary file, then a rename), so a reader sees
either the old file or the new one, never a half-written middle. There is no partial file to
repair.
## Roll back a completed run [#roll-back-a-completed-run]
If the run is not committed yet, discard both files together:
```bash
git checkout HEAD -- locales verbatra.lock.json
```
If the run is already committed, revert that commit:
```bash
git revert
```
Either way, restore the locale files and `verbatra.lock.json` in the same operation. The next
section is why.
## Why both files, or neither [#why-both-files-or-neither]
The lock file records a hash of the **source** value that produced each translation. It records
nothing about the translated value itself. So reverting your locale files does not, and cannot,
tell the lock file anything.
**Reverting only the locale files.** The lock still holds the hash of the source as it is now. On
the next run, a key the revert restored is present in the target file, so it is not missing; and
its recorded hash still matches the current source, so it is not changed. It is unchanged, it is
never sent to the provider, and the value you rolled back to stays in place permanently. The keys
the run newly created are the exception: the revert removed them from the file, so they come back
as missing and are translated again. Everything the run *replaced* is what gets stranded.
The failure is silent by design, not by accident. `verbatra check` and `verbatra diff` compare
against the same lock baseline, so they report the locale as clean. No command will tell you those
translations are out of date, because as far as the recorded baseline is concerned, they are not.
**Reverting only the lock file.** The mirror image, and destructive in the other direction. The
restored hashes are older than the current source, so the affected keys read as changed, and the
next run translates them again and overwrites what is in your locale files, including any manual
edits made since. You pay for the provider calls a second time and lose the hand corrections.
**Reverting both.** The source hashes and the translations they describe go back to the same point
in time, the baseline is consistent again, and the next run behaves exactly as it would have if
the bad run had never happened.
## Re-running after a rollback [#re-running-after-a-rollback]
Rolling back and running `verbatra translate` again is a redo, not a second opinion. With the same
provider, model, tone, and glossary, the [cache](/docs/the-cache) fingerprint is unchanged, so the
run serves the identical translations back from `verbatra.cache.json` without calling the provider
at all.
To get a different result, change what made the first one wrong. Editing the tone or the glossary,
or switching model, changes the fingerprint and re-translates on its own. If you want the same
configuration re-sent to the provider, pass `--no-cache` to
[`translate`](/docs/cli/translate).
To rebuild a locale from scratch rather than roll one run back, see
[How do I re-translate everything?](/docs/faq#how-do-i-re-translate-everything) in the FAQ. Note
that deleting the lock file is not a reset: without a baseline nothing can be detected as stale,
so a run after deleting it translates nothing.
## An interrupted or failed run [#an-interrupted-or-failed-run]
An interrupted run needs no rollback. Just run it again.
Each locale is committed on its own: its target file is written, then its lock entries are
recorded. Interrupting the command therefore leaves the locales that had finished fully written and
recorded, and the ones it had not reached completely untouched. A re-run picks up the untouched
ones and skips the finished ones.
The one gap, between a locale's file write and its lock update, resolves itself on the next run.
Keys the run had added are now in the target file with no lock entry, so they are adopted as they
stand; keys whose source had drifted still carry their old hash, so they are detected as changed
and translated again. Neither case loses work.
Keys withheld during a run (a failed integrity check, a failed provider call, an invalid-ICU
source, or a stopped token budget) keep their previous hash rather than being recorded as done, so
the next run retries them. See [Troubleshooting](/docs/troubleshooting) for what each of those
means.
If the interrupted process was killed hard, it may have left a write lock behind under
`.verbatra-local/`. The next run reports `LOCK_CONTENDED` and prints the path to delete.
## Before a run you cannot easily undo [#before-a-run-you-cannot-easily-undo]
* Commit your locale files and `verbatra.lock.json` first, so `git checkout HEAD --` is available.
* Preview with `verbatra translate --dry-run`, or with `verbatra diff` for the exact key lists and
`verbatra check` for the counts. None of the three calls a provider or writes a file.
* Add `--locales ` to try one locale before running the rest.
---
# 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).
## pnpm blocks a dependency's install scripts (ERR\_PNPM\_IGNORED\_BUILDS) [#pnpm-blocks-a-dependencys-install-scripts-err_pnpm_ignored_builds]
**Symptom**: `pnpm add -D @verbatra/cli` prints its install summary and then exits `1`:
```
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: @google/genai@2.15.0, protobufjs@7.6.5
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
```
pnpm also writes a `pnpm-workspace.yaml` into your project holding an unanswered choice:
```yaml
allowBuilds:
'@google/genai': set this to true or false
protobufjs: set this to true or false
```
Until you answer it, every later pnpm command in that project fails the same way, including
`pnpm exec verbatra init`.
**Cause**: pnpm does not run a third-party dependency's install scripts unless you allow them.
`@google/genai` and its transitive `protobufjs` declare such scripts, and the Gemini SDK is
installed whichever provider you configure, so every pnpm install path hits this. Nothing in
verbatra declares an install script of its own, and npm and yarn install the same package without
a prompt. The install itself did complete: `node_modules/.bin/verbatra` already works.
**Fix**: answer the choice once. Interactively, run `pnpm approve-builds` and approve neither
entry. Non-interactively, which is what you want in CI, write the answer before you install by
putting this in `pnpm-workspace.yaml` at the project root:
```yaml
allowBuilds:
'@google/genai': false
protobufjs: false
```
verbatra does not need those scripts, and this repository declines both. With the answer in place
`pnpm add -D @verbatra/cli` exits `0` and later pnpm commands run normally. The package versions
in the message track the release; the fix does not change.
## 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.
A wildcard in the pattern is another cause: `files.pattern` is a literal path, not a glob, so
`public/locales/{locale}/*.json` is looked up as a file named `*.json`. See
[Namespace layouts](/docs/formats#namespace-layouts).
## 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. `translate`, `watch`, and `studio` load `.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 is
reported as `partial` when some keys did land and `failed` when none did, and the command exits
`1` either way.
**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, and use
`--locales ` to work through the target locales one at a time when the limit is a strict
per-minute or per-day quota. `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. When `TIMEOUT` is the rule rather than a
hiccup (a slow local model, or large batches), raise `requestTimeoutMs` in the provider's options:
it bounds every request at two minutes by default. See
[Request timeout](/docs/providers#request-timeout).
## 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).
## A locale file could not be written (TARGET\_UNWRITABLE) [#a-locale-file-could-not-be-written-target_unwritable]
**Symptom**: a locale fails with `Could not write the locale file locales/de.json (EACCES). Check
the write permissions on the containing directory, then run again.`
**Cause**: the target file, or the directory holding it, cannot be written by the user running
verbatra: no write permission, a directory that does not exist, a read-only mount, or a full disk.
Locale files are written atomically through a temporary file in the same directory, so a directory
that is unwritable fails the write even when the locale file itself looks fine.
**Fix**: the message names the target relative to your working directory and the file-system code
behind the failure. Fix that (`chmod` the directory, create it, remount read-write, free space) and
re-run. Only the affected locale fails; the others still run, and the failed locale keeps its
previous file and lock baseline, so nothing is half-written.
## 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. To change how long a run waits before giving
up, pass `--lock-timeout ` to [`translate`](/docs/cli/translate) or
[`watch`](/docs/cli/watch); it defaults to `600` seconds.
## 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 and does not change the exit code; with `"stop"` every key not yet attempted is
withheld for the rest of the run, which leaves that locale `partial` (some keys landed) or `failed`
(none did), and both exit `1`.
**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`. The hint spells out the pnpm
command, so if you install with pnpm, see
[pnpm blocks a dependency's install scripts](#pnpm-blocks-a-dependencys-install-scripts-err_pnpm_ignored_builds)
first.
## 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).
## Undoing a run (no error code) [#undoing-a-run-no-error-code]
**Symptom**: a run produced translations you do not want, and you need the previous state back.
**Cause**: verbatra rewrites locale files in place and updates the lock file. There is no undo
command and no backup; version control is the recovery mechanism.
**Fix**: revert the locale files and `verbatra.lock.json` together. Reverting only the locale files
leaves the lock claiming the reverted translations are current, so nothing retranslates them and
`check` and `diff` still report the locale as clean. See
[Recovery and rollback](/docs/recovery-and-rollback).
---
# 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 one [JSON envelope](/docs/ci-and-exit-codes#json-output) on stdout carrying the check summary under `result`, or the error code on a failed run; the human-readable error line still goes 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 one [JSON envelope](/docs/ci-and-exit-codes#json-output) on stdout carrying the diff summary under `result`, or the error code on a failed run; the human-readable error line still goes 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`, or `verbatra-translations` for `csv` and `tsv` | write the handoff here: a file path for `xlsx`, a directory for `csv` and `tsv` |
| `--format` | `` | `xlsx` | write an Excel workbook (`xlsx`) or one delimited file per locale (`csv`, `tsv`) |
| `--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 one [JSON envelope](/docs/ci-and-exit-codes#json-output) on stdout carrying the export result (path and per-locale row counts) under `result`, or the error code on a failed run; the human-readable error line still goes 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
# one de.csv and fr.csv under handoff/
verbatra export --format csv --out handoff
```
## CSV and TSV [#csv-and-tsv]
`--format csv` and `--format tsv` write plain text instead of a workbook: one `.csv` or `.tsv` per exported locale, carrying the same columns in the same order as a workbook sheet. `--out` then names the directory the files are written into, and the directory is created if it is missing.
A delimited file is diffable and opens anywhere, but it carries no cell protection: every column is editable and the `Source hash` column is visible. An edited hash is never trusted, import compares it against the live source and withholds the row.
Each delimited export also writes a hidden manifest into the directory, `.verbatra-export-csv.json` or `.verbatra-export-tsv.json`, naming the locales it just wrote. It is what lets a re-export with a narrower `--locales` selection retire the locales it dropped: their files stay on disk, and [`verbatra import`](/docs/cli/import) refuses them as leftovers rather than applying an outdated translation. The export deletes nothing from the output directory, so a file you put there yourself is never at risk.
## Exit codes [#exit-codes]
| Code | Meaning |
| ---- | ---------------------------------------------------------------------------------------------------- |
| `0` | the handoff 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 CSV and TSV variant, the review columns, and the full round-trip.
* [`verbatra import`](/docs/cli/import) reads the filled handoff 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 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 handoff produced by [`verbatra export`](/docs/cli/export) and filled in by the translator: the workbook file for `xlsx`, and for `csv` and `tsv` either one `.csv` file or the directory holding one file per locale.
## 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 |
| `--format` | `` | `xlsx` | read an Excel workbook (`xlsx`) or delimited files (`csv`, `tsv`) |
| `--json` | none | off | print one [JSON envelope](/docs/ci-and-exit-codes#json-output) on stdout carrying the run summary under `result` (the same [`RunSummary` shape](/docs/sdk) as `translate`), or the error code on a failed run; the human-readable error line still goes 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
# import every locale file in a CSV handoff directory
verbatra import handoff --format csv
```
With `csv` or `tsv` the locale comes from the file name, not from a sheet name, so keep every file named exactly as it was exported. A configured target locale with no file is reported as that locale's failure, never silently skipped.
A file left over from an earlier export with a wider `--locales` selection is never applied. Import reconciles the directory against the manifest the most recent export wrote there and reports such a locale as failed with `HANDOFF_FILE_STALE`, so an outdated translation cannot slip back in unnoticed. Re-export that locale to refresh its file, or delete the file. A directory with no readable manifest (one assembled by hand, or one that lost the hidden file on its way through an archive) is read as it always was, every present file is imported. Naming a single file directly is likewise taken at face value.
## Exit codes [#exit-codes]
| Code | Meaning |
| ---- | -------------------------------------------------------------------------------------- |
| `0` | every target locale came out complete |
| `1` | the run finished but one or more locales failed or came out partial |
| `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. That includes partial locales: a locale written with keys still missing exits `1`, not `0`. It also includes the lock file: a lock file that is corrupt, or that turns corrupt while the import is running, stops the whole run with `2` instead of reporting one failed locale, so the locales still to come are left untouched. Locales applied before the abort stay written; fix the lock file and import again.
A blank `Translation` cell never fails a locale: the row is skipped, its prior lock baseline is kept, and the key is reported as `unfilled` whenever it still needs a translation, whether the row was exported as `new` or `changed`. Importing a workbook nobody has filled in yet therefore succeeds and returns the full inventory of pending keys.
## 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 handoff 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
npm install --save-dev @verbatra/cli
npx verbatra translate # or: pnpm 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 [envelope](/docs/ci-and-exit-codes#json-output) on stdout, one line per record, keeping stdout clean for piping. Branch on its `ok` field: a success carries the command's payload under `result`, and a failed run carries the same stable error code the stderr line names. The human-readable error line goes to stderr either way. `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 or came out partial, `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`, `.verbatra-local/`, and `verbatra.cache.json`. 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
```
That pnpm command installs correctly but exits `1` with `ERR_PNPM_IGNORED_BUILDS`, and leaves an unanswered `pnpm-workspace.yaml` behind that makes later pnpm commands fail the same way; see [Troubleshooting](/docs/troubleshooting) for the one-time fix. npm and yarn install the package without it.
## 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 |
| `--locales` | `` | every configured target locale | translate only this comma-separated subset; an unconfigured locale fails the run with `UNKNOWN_LOCALE` before anything is read or spent, and an empty list is a usage error, exit `2` |
| `--dry-run` | none | off | preview changes without calling a provider or writing files (no API key needed) |
| `--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 |
| `--lock-timeout` | `` | `600` | how long to wait for a held per-locale write lock before failing; must be a bare positive integer of seconds (a value like `60s`, `0`, or `-1` is a usage error, exit `2`) |
| `--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 one [JSON envelope](/docs/ci-and-exit-codes#json-output) on stdout carrying the run summary under `result` (the [`RunSummary` shape](/docs/sdk)), or the error code on a failed run; the human-readable error line still goes 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.
`--dry-run` constructs no provider at all, so it needs no API key and cannot spend anything: it reads the source and target files, diffs them against the lock file, and reports exactly what a real run would change. It writes nothing (no locale file, no lock update, no run-status snapshot), it neither reads nor writes the cache, and it takes no per-locale write lock, so it never waits on a run already in progress. The `.env` load above still happens on a dry run; there is simply no key to read. That makes it a complete way to try verbatra on a real project before creating a provider account.
`--locales de,fr` narrows the run to a subset of the configured targets. Locales left out are untouched: their files, their lock entries, and their cache entries all stay as they were. It is the flag to reach for on a rate-limited free tier, where translating one locale at a time is the only way through, and it is also the fastest way to re-run a single locale that came out partial. The run-status snapshot always describes the most recent run alone, so a subset run narrows it to that subset.
`--lock-timeout` tunes how long a locale waits for a contended [write lock](/docs/the-lock-file) before the run fails with `LOCK_CONTENDED`: raise it when another run legitimately holds the lock for a while, lower it to fail fast in CI.
`--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). If a whole-run error (in practice a corrupt lock file) surfaces once locales are already running, no further locale is started, and the ones in flight finish and release their write locks before the command exits with `2`, so the run leaves no lock behind for the next one to wait on. 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
# one locale at a time, which is what a rate-limited free tier needs
verbatra translate --locales de
```
## Exit codes [#exit-codes]
| Code | Meaning |
| ---- | ---------------------------------------------------------------------------------------------- |
| `0` | every target locale came out complete |
| `1` | the run finished but one or more locales failed or came out partial |
| `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. A **partial** locale exits `1` as well. That is a locale whose file was written but still has keys missing, typically because a provider sub-batch failed or the integrity gate refused a translation, and shipping it as a success would leave a half-translated file in your repository unnoticed.
## 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 |
| `--locales` | `` | every configured target locale | watch and translate only this comma-separated subset; an unconfigured locale is rejected at startup with `UNKNOWN_LOCALE` before any watching begins, and an empty list is a usage error, exit `2` |
| `--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`) |
| `--lock-timeout` | `` | `600` | how long to wait for a held per-locale write lock before failing; must be a bare positive integer of seconds (a value like `60s`, `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 [JSON envelope](/docs/ci-and-exit-codes#json-output) on stdout, NDJSON style (one object per line) |
With `--json`, each run emits one [envelope](/docs/ci-and-exit-codes#json-output) record with `command: "watch"`: an `ok: true` record carrying that run's [`RunSummary`](/docs/sdk) under `result`, or an `ok: false` record 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.
`--locales de,fr` narrows every cycle of the session to a subset of the configured targets, which keeps a development loop cheap when you only care about one language. The subset is validated once at startup rather than on each run.
`--lock-timeout` applies to every cycle: it tunes how long a locale waits for a contended [write lock](/docs/the-lock-file) before that run fails with `LOCK_CONTENDED`. A failed run is only a record on the stream, so the watcher keeps going either way.
`--concurrency` applies to each cycle's run. Because each run's token budget is fresh, a `--concurrency` above `1` cannot be honored when the config sets a `maxTokens` budget: `watch` refuses that combination at startup and exits `2` before the initial run, instead of starting a session that fails every cycle. `--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
# keep only German current while you work
verbatra watch --locales de
```
## 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)
Five 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 once you are ready to translate for real. The first three steps cost nothing and need no account.
## 1. Install [#1-install]
verbatra is a dev dependency:
```bash
npm install --save-dev @verbatra/cli
```
pnpm and yarn work too. The package ships the `verbatra` binary; run it through your package manager (`npx verbatra ...`, `pnpm verbatra ...`, or `yarn verbatra ...`). The code blocks below use the bare name.
pnpm takes one extra step. `pnpm add -D @verbatra/cli` installs correctly but exits `1` with `ERR_PNPM_IGNORED_BUILDS`, and leaves an unanswered `pnpm-workspace.yaml` behind that makes every later pnpm command in the project fail the same way. Run `pnpm approve-builds` once and approve neither entry, or see [Troubleshooting](/docs/troubleshooting) for the non-interactive fix.
## 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`, `.verbatra-local/`, and `verbatra.cache.json`, created or appended so a real key, local state, or the regenerable cache 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. Preview without an API key [#3-preview-without-an-api-key]
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."
}
}
```
Now preview the run:
```bash
verbatra translate --dry-run
```
A dry run constructs no provider at all, so it needs no API key and cannot spend anything. It reads your files, diffs them, and prints the same per-locale summary a real run would, without sending a single string or writing a single file. Use it to confirm your format, file pattern, and locales are right before you sign up for anything.
[`verbatra check`](/docs/cli/check) and [`verbatra diff`](/docs/cli/diff) are provider-free in the same way: `check` reports per-locale counts, `diff` lists the exact keys.
## 4. Set your API key [#4-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=`. `translate`, `watch`, and `studio` load `.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.
## 5. Translate [#5-translate]
With the key in place, run the same command for real:
```bash
verbatra translate
```
verbatra reads the source locale, sees that every key is missing from `de`, sends them to the provider in batches, runs each result through the integrity gate, 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 came out complete and `1` when one failed or came out partial (written, but with keys still missing); 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.
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.