---
title: "Exit codes and JSON output"
description: "The exit-code contract, the JSON envelope every --json command prints, the codes the CLI raises itself, and how verbatra writes to stderr and the terminal."
type: "reference"
---
# Exit codes and JSON output

The exit-code contract, the JSON envelope every --json command prints, the codes the CLI raises itself, and how verbatra writes to stderr and the terminal.

Everything a script or CI step can rely on across commands. Each [command page](https://verbatra.kreitz-webdev.de/docs/cli.md) adds only what is specific to that command.

## Exit codes [#exit-codes]

The exit code is the contract your CI step branches on:

| Code | Meaning |
| --- | --- |
| `0` | success, per command as listed below the table |
| `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 (or, with `--qa`, a committed translation with an integrity error, or with a review warning or an incomplete plural under `--strict`, or, with `--require-reviewed`, a machine-written translation nobody approved, or, with `--sensitive`, content that looks sensitive in the source file or glossary), `check --file` found a syntax or integrity error in the file (or, under `--strict`, a review warning or an incomplete plural), `diff` found a missing or changed key (or, with `--unused`, a complete scan found an unused source key), `report provenance` found `verbatra.provenance.json` corrupt or from a newer verbatra, `doctor` found at least one failed check, `types --check` found the committed declaration out of date, or `studio` or `mcp` failed while shutting its server down |
| `2` | could not run: a whole-run error (bad config, unreadable source, corrupt lock file, a locale the provider does not support), a usage error (an empty `--locales` list, `INVALID_LOCALES`, or an invalid `--debounce` or `--port`), an unconfigured `--locales` value (`UNKNOWN_LOCALE`), `init` missing a flag it needs, finding several formats or file patterns that fit, refusing to overwrite an existing config, or unable to scaffold a valid config, `watch` failing to start or to stop, `studio` unable to load the config, import `@verbatra/studio`, or start its server, or `mcp` given a `--config` path that does not exist, or unable to import `@verbatra/mcp`, start its server, or honor `--redact-values` with the installed `@verbatra/mcp` (a missing or invalid config found by the search starts it unconfigured) |
| `3` | `translate` (a dry run and `--estimate` included) in a [human-only](https://verbatra.kreitz-webdev.de/docs/human-only-workflow.md) project (`provider: { id: "none" }`) finished cleanly, but left keys for a human translation; a failed or partial locale still exits `1` |
| `130` | `translate` or `import` was interrupted with Ctrl-C (SIGINT) after releasing its write locks, or `watch`, `studio` or `mcp` was force-stopped by a second interrupt |
| `143` | `translate` or `import` was stopped with SIGTERM after releasing its write locks |

Exit `0` means, per command:

- `translate` or `import`: every locale finished complete
- `check`: every locale is in sync
- `diff`: no pending changes
- `report`: it printed its report
- `doctor`: no setup problem
- `extract`: it completed its scan
- `pseudo`: it generated its pseudolocale
- `types`: it generated its declaration
- `export`: it wrote its workbook
- `tmx`: it imported or exported its translation memory
- `init`: it scaffolded the project or found it already up to date
- `watch`, `studio` or `mcp`: it stopped cleanly
- `--help` or `--version`: it printed the help or the version

On `130` and `143`, stderr ends with `verbatra: interrupted (SIGINT), released locks` (or `SIGTERM`), or under `--json` with the record `{"type":"interrupted","signal":"SIGINT","locksReleased":true}`. If releasing the locks fails or does not finish within 5 seconds, the line says so instead (`could not release locks: <reason>` or `lock release did not finish within 5s`), and the JSON record carries `"locksReleased":false` with `"reason":"failed"` (plus `message`) or `"reason":"timed-out"` (plus `deadlineMs`).

Some edge cases worth knowing:

<AvailableFrom version="0.9.0" />

- 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. Read `partial` on the summary to tell the two apart. A failed sub-batch leaves a `SUB_BATCH_FAILED` notice on the locale naming the provider's error code, or `PROVIDER_CALL_FAILED` when the failure was not a structured provider error.
- A **locale the provider does not support** is a whole-run error too, never one failed locale. `translate` (a dry run and `--estimate` included), `watch` at startup, and a single-key retranslation refuse it with `LOCALE_UNSUPPORTED_BY_PROVIDER` and exit `2` before any provider is constructed or anything is spent, so no locale of the run has started and nothing was written. It is judged against the language table verbatra ships for DeepL and Google Cloud Translation; an LLM provider accepts any locale. Leave the locale out with `--locales` to translate the others, and run [`verbatra doctor --locales`](https://verbatra.kreitz-webdev.de/docs/cli/doctor.md#locale-support) to see why. See [Language support](https://verbatra.kreitz-webdev.de/docs/language-support.md#checking-support-before-a-run).
- 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 all three long-running commands (`watch`, `studio` and `mcp`) exit `0` on it. Do not assume they behave alike beyond that: if the stop itself fails, `watch` exits `2` while `studio` and `mcp` exit `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`.
- `doctor` reads a broken config differently from every other command: a config the search does not find, or one that fails validation, is a failed check and exit `1`, because reporting exactly that is the command's job. It exits `2` only when it cannot run at all, such as an explicit `--config` path that does not exist.
- 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 six codes.
- A [protected](https://verbatra.kreitz-webdev.de/docs/protecting-human-translations.md) key (a stale value a person wrote, imported, or changed outside verbatra, or a pinned key) does not change the exit code of `translate` in a project with a provider: it exits `0` and says on stderr how many keys it left (pinned keys are not counted). `check` still exits `1` for it, because the key stays stale until a person resolves it. In a human-only project it counts toward exit `3`.

## JSON output [#json-output]

Every command except `studio` and `mcp` accepts `--json` for machine-readable output on stdout: `translate`, `watch`, `check`, `diff`, `report`, `export`, `import`, `tmx`, `doctor`, `pseudo`, `types`, `extract`, and `init`. 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<TResult> =
  | { ok: true; version: 1; command: string; result: TResult }
  | {
      ok: false;
      version: 1;
      command: string | null;
      code: string;
      message: string;
      causeCode?: string; // the code of the error this one wraps, when it has one
      candidates?: string[]; // the values to choose from, on an error that has them
      missing?: string[]; // the flags still to pass, on an init usage error
      hint?: string; // the next step that resolves the error, when there is one
    };
```

`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. `candidates` appears only on an error that asks you to choose, such as `init`'s `FORMAT_AMBIGUOUS`, and lists the values to choose from. `missing` appears on an `init` usage error that leaves flags unanswered and names each flag still to pass. `causeCode` appears when the error wraps another coded error, such as a `PROVIDER_CONSTRUCTION_FAILED` caused by `MISSING_API_KEY`; the stderr line then ends with `(cause: MISSING_API_KEY)`.

<AvailableFrom version="0.12.0" />

`hint` appears when the error has a next step: one short imperative sentence that resolves it, such as `"Set GEMINI_API_KEY in the environment, or, with the CLI, in a .env file in the project directory."` for a missing key or `` "Run `verbatra check --help` to see the options and arguments it accepts." `` for a `USAGE_ERROR`. Act on it instead of parsing `message`. It names variables, flags, and paths, never a key value. Every SDK, provider, and format adapter code has one (the SDK exports the same text as [`errorHint`](https://verbatra.kreitz-webdev.de/docs/sdk/errors.md#errorhint)), and so does every [CLI error code](https://verbatra.kreitz-webdev.de/docs/error-codes.md#cli-error-codes) except `CLI_ERROR`. Without `--json`, the same text follows the error line on stderr as `next: ...`, unless `--quiet` is set. `mcp --json` is the exception: it is refused with one `USAGE_ERROR` line on stderr, with no envelope and no `next:`. Startup errors of `studio` and `mcp` get no `next:` line either.

The `result` of a successful envelope is different for each command and is described under JSON output on its page: [`extract`](https://verbatra.kreitz-webdev.de/docs/cli/extract.md), [`translate`](https://verbatra.kreitz-webdev.de/docs/cli/translate.md), [`watch`](https://verbatra.kreitz-webdev.de/docs/cli/watch.md), [`check`](https://verbatra.kreitz-webdev.de/docs/cli/check.md), [`diff`](https://verbatra.kreitz-webdev.de/docs/cli/diff.md), [`report`](https://verbatra.kreitz-webdev.de/docs/cli/report.md), [`doctor`](https://verbatra.kreitz-webdev.de/docs/cli/doctor.md), [`pseudo`](https://verbatra.kreitz-webdev.de/docs/cli/pseudo.md), [`types`](https://verbatra.kreitz-webdev.de/docs/cli/types.md), [`export`](https://verbatra.kreitz-webdev.de/docs/cli/export.md), [`import`](https://verbatra.kreitz-webdev.de/docs/cli/import.md), [`tmx`](https://verbatra.kreitz-webdev.de/docs/cli/tmx.md), [`init`](https://verbatra.kreitz-webdev.de/docs/cli/init.md).

## Schemas [#schemas]

<AvailableFrom version="0.12.0" />

Every record `--json` prints has a JSON Schema (draft 2020-12). The schemas ship inside the npm packages and are served at stable URLs, so a script, an agent or an editor can validate output instead of reading prose. Every output schema allows properties it does not list, as the envelope contract above promises; keep it that way if you copy one. The config schema is the deliberate exception: it describes what you write, so it rejects an unknown key to catch a typo.

| Document | URL | File in the package |
| --- | --- | --- |
| Any envelope, success or error | [`envelope.json`](https://verbatra.kreitz-webdev.de/schema/v1/envelope.json) | `@verbatra/cli/schemas/envelope.json` |
| One command's envelope, with its `result` | `https://verbatra.kreitz-webdev.de/schema/v1/<command>-envelope.json` | `@verbatra/cli/schemas/<command>-envelope.json` |
| The error envelope | [`error-envelope.json`](https://verbatra.kreitz-webdev.de/schema/v1/error-envelope.json) | `@verbatra/cli/schemas/error-envelope.json` |
| The `init` result alone, without the envelope | [`init-result.json`](https://verbatra.kreitz-webdev.de/schema/v1/init-result.json) | `@verbatra/cli/schemas/init-result.json` |
| A JSON line on stderr: progress, lock wait, interrupt | [`stderr-record.json`](https://verbatra.kreitz-webdev.de/schema/v1/stderr-record.json) | `@verbatra/cli/schemas/stderr-record.json` |
| The config file | [`config.json`](https://verbatra.kreitz-webdev.de/schema/v1/config.json) | `@verbatra/sdk/schemas/config.json`, or `@verbatra/sdk/config-schema.json`: the same schema without `$id` and `title` |

Each command page links its envelope under JSON output, and [https://verbatra.kreitz-webdev.de/schema/v1](https://verbatra.kreitz-webdev.de/schema/v1) lists every document with its title. A command envelope points at its `result` schema by URL in `$ref`, so a validator loads it over the network, or from `node_modules/@verbatra/sdk/dist/schemas` when you register those files first.

`v1` tracks envelope `version: 1` and always serves the latest release. An added field stays in `v1`; a change that breaks a payload raises the envelope `version` and adds `/schema/v2/`. The config schema uses the same URL scheme but follows the config's own changes. The URL always serves the latest release while a copy from npm stays at its version, so an older copy can reject a record from a newer CLI where a schema lists closed values, such as a stderr record's `type` or an envelope's `command`.

## CLI error codes [#cli-error-codes]

<AvailableFrom version="0.12.0" />

The CLI raises these codes itself. The same list ships as `CLI_ERROR_CODES` (with the type `CliErrorCode`) from `@verbatra/cli`, so a script can check a code against it. Any other code comes from the SDK, a provider, or a format adapter. What each code means, which commands raise it, and its next step are on [Error codes](https://verbatra.kreitz-webdev.de/docs/error-codes.md#cli-error-codes).

## Errors on stderr [#errors-on-stderr]

Every error goes to stderr as one structured line (`verbatra: error [CODE] message`) in both modes. Since 0.12.0 that includes an argument-parsing error (an unknown option or command, a missing argument): it prints `verbatra: error [USAGE_ERROR] unknown option '--nope'` and exits `2`, where it printed commander's own `error: unknown option '--nope'` with no code before. `init` follows the same grammar since 0.12.0; before that, a missing provider printed `verbatra: --provider is required (...)` with no code. Since 0.12.0, a hint follows the line as `next: ...` without `--json`, and under `--json` a parsing error writes the `USAGE_ERROR` envelope with its `hint` to stdout; `mcp` keeps stdout for the protocol, so it refuses `--json` on stderr only. The startup of `studio` and `mcp` is the exception to the structured line: when `@verbatra/studio` or `@verbatra/mcp` is not installed, stderr carries the install instruction alone, with no `verbatra:` prefix. Under `--json`, the `message` of a parsing error is commander's own text, `error: ` prefix included. 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.

## Terminal output [#terminal-output]

<AvailableFrom version="0.12.0" />

Results go to stdout. Progress, notices, hints, warnings and errors go to stderr, and verbatra adapts them to where stderr is going:

- On an interactive terminal, fixed labels such as `error`, `[ok]` and `next:` are colored, and a step that takes longer than 300 ms shows an ASCII spinner (`| / - \`).
- When stderr is piped or redirected, `CI` is set to anything but empty, `0` or `false`, or `TERM` is `dumb`, the output is plain, append-only lines with no color and no animation.
- The status labels of the [`doctor`](https://verbatra.kreitz-webdev.de/docs/cli/doctor.md) report on stdout (`[ok  ]`, `[warn]`, `[fail]`, `[skip]`) are colored when stdout itself is an interactive terminal, so a redirected report stays plain unless `FORCE_COLOR` turns color on.
- `--json` never adds color or animation, and stdout carries nothing but envelopes. Plain `verbatra:` warning lines can still reach stderr, so a consumer parses only the stderr lines that start with `{`. See [JSON output](https://verbatra.kreitz-webdev.de/docs/cli/output.md#json-output).

| Flag or variable | Effect |
| --- | --- |
| `-q`, `--quiet` | print only results, warnings and errors: drops progress lines, `next:` hints and informational stderr lines, while the result summary, including its notices, stays. It works before or after the command name |
| `--no-color` | never color the output |
| `NO_COLOR`, `NODE_DISABLE_COLORS`, `VERBATRA_NO_COLOR` | any non-empty value turns color off; `VERBATRA_NO_COLOR` also overrides `FORCE_COLOR` |
| `FORCE_COLOR` | `1`, `2`, `3`, `true` or an empty value turns color on even when stderr or stdout is not a terminal, and any other value, `0` included, turns it off. Once set, it wins over `NO_COLOR`, `NODE_DISABLE_COLORS`, `CI` and `TERM=dumb`; `--no-color` and `VERBATRA_NO_COLOR` still win |
| `VERBATRA_NO_SPINNER` | any non-empty value replaces the spinner with static `label... done (1.2s)` lines, for screen readers and terminal recordings |

The `output` samples on the command pages show the plain form. An interactive terminal shows a running step as a spinner line that ends in `[ok]` and the elapsed time.

Status words are plain ASCII (`[ok]`, `[warn]`, `[fail]`, `[skip]`), so the output reads the same in every terminal and log. Every line passes through the same key redaction as the rest of the output, and only the fixed labels are ever colored, never a key, path or message.
