---
title: "Config file"
description: "Every config key with its type, default, and the version that added it, plus where the file can live and how a bad config fails."
type: "reference"
---
# Config file

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

verbatra reads one non-secret config object and validates it before any work. No API key is ever read from it ([Providers](https://verbatra.kreitz-webdev.de/docs/providers.md#keys-come-from-the-environment)), so the config is safe to commit.

## A minimal config [#a-minimal-config]

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

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

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

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

See [locales and files](#locales-and-files), [provider](#provider), [glossary](#glossary), [network](#network), [sensitive data](https://verbatra.kreitz-webdev.de/docs/data-handling.md#sensitive-content-guard), [budget](#maxtokens-and-budgetbehavior), [rates](#rates) and [extract](#extract).

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

## Every key [#every-key]

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

| Key | Type | Default | Since | Effect |
| --- | --- | --- | --- | --- |
| `$schema` | string | none | 0.9.0 | Points an editor at the [JSON Schema](#editor-validation); ignored at runtime. |
| `sourceLocale` | locale code | required | 0.1.0 | The locale your source strings are written in. |
| `targetLocales` | locale code array | required | 0.1.0 | At least one; never `sourceLocale`, never two equal ignoring case. |
| `format` | format id | required | 0.1.0 | The [format](https://verbatra.kreitz-webdev.de/docs/formats.md) of your locale files, or a `custom:` id for an [outside adapter](https://verbatra.kreitz-webdev.de/docs/custom-format-adapters.md). |
| `files.pattern` | string | required | 0.1.0 | The path to each locale file, with a `{locale}` token. |
| `files.localeStyle` | `"literal"`, `"posix"`, `"android"` | `"literal"` | 0.7.0 | How `{locale}` is spelled in the path. |
| `provider.id` | provider id | required | 0.1.0 | The translation provider, or `none` for no machine translation. |
| `provider.options` | object | required, except for `none` | 0.1.0 | The provider's [own options](https://verbatra.kreitz-webdev.de/docs/providers.md). |
| `glossary` | object or file path | none | 0.1.0 | Fixed translations, forbidden renderings and do-not-translate terms. |
| `tone` | `"formal"`, `"informal"`, `"neutral"` | none | 0.1.0 | The register to translate in, applied [per provider](https://verbatra.kreitz-webdev.de/docs/providers.md#tone-and-glossary-across-providers). |
| `prune` | boolean | `false` | 0.3.0 | Remove orphaned keys from target files and the lock; `translate --prune` overrides it. |
| `generatePlurals` | boolean | `false` | 0.3.0 | Synthesize the CLDR plural forms a target language needs. |
| `fuzzyCache.enabled` | boolean | required in the block | 0.11.0 | Reuse a slightly changed source's translation. |
| `fuzzyCache.threshold` | number, `0.5` to `1` | `0.9` | 0.11.0 | How alike old and new source must be. |
| `humanEdits` | `"protect"`, `"suggest"`, `"overwrite"` | `"protect"` | 0.12.0 | What a run does with a stale key a person wrote. |
| `pinnedKeys` | string array | none | 0.12.0 | Key patterns (`*` wildcard) no machine write touches. |
| `network.policy` | `"any"`, `"local-only"`, `"allowlist"` | `"any"` | 0.12.0 | Which hosts a provider may [connect to](https://verbatra.kreitz-webdev.de/docs/network-policy.md). |
| `network.allowedHosts` | string array | none; required for `"allowlist"` | 0.12.0 | Extra permitted hosts: names, `*.suffix` wildcards, IPs or CIDR ranges. |
| `sensitiveData.mode` | `"off"`, `"warn"`, `"block"`, `"redact"` | required in the block; `"off"` without the block | 0.12.0 | What a run does with [sensitive content](https://verbatra.kreitz-webdev.de/docs/data-handling.md#sensitive-content-guard) before sending. |
| `sensitiveData.detectors` | detector id array | `secret`, `email`, `iban`, `credit-card` | 0.12.0 | Detectors to run; `phone`, `ip`, `private-host` are opt-in. |
| `sensitiveData.patterns` | string array | none | 0.12.0 | Regular expressions for your own terms, such as codenames. |
| `sensitiveData.allow` | string array | none | 0.12.0 | Matches never reported; `*` is a wildcard, and case is ignored. |
| `maxBatchSize` | positive integer | `50` | 0.3.0 | The most entries per provider request. |
| `maxLength` | map of key to positive integer | none | 0.11.0 | Per-key length budgets in grapheme clusters. |
| `maxTokens` | positive integer | none | 0.5.0 | A whole-run token budget. |
| `budgetBehavior` | `"warn"`, `"stop"` | `"warn"` | 0.5.0 | Whether reaching `maxTokens` warns or stops sending. |
| `rates.asOf` | `YYYY-MM-DD` date | required in the block | 0.11.0 | The date you read the rates. |
| `rates.currency` | three uppercase letters | required in the block | 0.11.0 | The currency of every rate, such as `USD`. |
| `rates.table` | map of rate key to rate | required in the block | 0.11.0 | Rates per [rate key](#rates). |
| `extract.framework` | `"i18next"` | required in the block | 0.11.0 | Which call sites the source scan looks for. |
| `extract.roots` | string array | required in the block | 0.11.0 | The directories to scan, at least one. |
| `extract.exclude` | string array | none | 0.11.0 | Extra directory names to skip. |
| `extract.literals.ignore` | string array | none | 0.11.0 | Exact texts `doctor --literals` holds back. |
| `extract.unused.ignore` | string array | none | 0.11.0 | Keys or `*` patterns `diff --unused` never reports. |

### Locales and files [#locales-and-files]

- **`files.pattern`** must contain `{locale}`, its only token, and resolves against the project root: `locales/{locale}.json` becomes `locales/de.json`. It is a literal path, never a glob, so one config addresses one file per locale ([Namespace layouts](https://verbatra.kreitz-webdev.de/docs/formats.md#namespace-layouts)); `apple-xcstrings` resolves every locale to one catalogue ([Xcode String Catalogs](https://verbatra.kreitz-webdev.de/docs/formats.md#xcode-string-catalogs-xcstrings)).
- **`files.localeStyle`** decides what the token expands to:
  - `literal` writes the tag as configured, as most layouts need.
  - `posix` replaces `-` with `_` for gettext and Java `messages_{locale}.properties` (`pt-BR` becomes `pt_BR`); a variant such as `de-1996` has no posix spelling. `gettext-po` spells scripts the gettext way (next section).
  - `android` expands to a resource directory, written as `res/{locale}/strings.xml`: `values` for the source, then `values-de`, `values-pt-rBR`, `values-b+zh+Hans`.

  A style refuses a locale it cannot spell.

### gettext locale names [#gettext-locale-names]

<AvailableFrom version="0.12.0" />

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

| Config code | File name |
| --- | --- |
| `pt-BR` | `pt_BR` |
| `es-419` | `es_419` |
| `sr-Latn` | `sr@latin` |
| `sr-Latn-RS` | `sr_RS@latin` |
| `uz-Cyrl` | `uz@cyrillic` |
| `ks-Deva-IN` | `ks_IN@devanagari` |
| `zh-Hant-TW` | `zh_TW` |
| `sr-Cyrl-RS` | `sr_RS` |

A script the language and region imply is left out, so `zh-TW` and `zh-Hant-TW` together fail with `LOCALE_PATH_COLLISION`; a script without a gettext modifier, such as `zh-Hant` alone, fails with `LOCALE_LAYOUT_INVALID`. [`verbatra init`](https://verbatra.kreitz-webdev.de/docs/cli/init.md) reads these names back. Other formats keep the ICU spelling (`sr_Latn`).

### Locale codes [#locale-codes]

<AvailableFrom version="0.12.0" />

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

### provider [#provider]

`provider.id` selects one of the [providers](https://verbatra.kreitz-webdev.de/docs/providers.md), or `none`, whose `options` may be omitted and must otherwise be `{}` ([Run without machine translation](https://verbatra.kreitz-webdev.de/docs/human-only-workflow.md)). Every other provider accepts `options.localeMap` and `options.requestTimeoutMs` ([Providers](https://verbatra.kreitz-webdev.de/docs/providers.md#locale-codes-sent-to-the-provider)); from 0.12.0 a misplaced key under `provider`, such as `localeMap`, fails with `CONFIG_INVALID`.

### glossary [#glossary]

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

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

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

#### Per-locale glossary (version 2) [#per-locale-glossary-version-2]

<AvailableFrom version="0.12.0" />

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

```json title="glossary.json"
{
  "version": 2,
  "terms": [
    {
      "source": "Dashboard",
      "targets": { "de": "Übersicht", "fr": "Tableau de bord" },
      "forbidden": { "de": ["Instrumententafel"] },
      "note": "The start page after sign-in",
      "partOfSpeech": "noun"
    },
    { "source": "Sign in", "target": "Anmelden", "caseSensitive": true }
  ],
  "doNotTranslate": ["verbatra", { "term": "Acme", "caseSensitive": false }]
}
```

| Field | Meaning |
| --- | --- |
| `version` | Always `2`; any other number fails. |
| `terms[].source` | The source term; each term appears once. |
| `terms[].target` | The translation for every locale without its own `targets` entry. |
| `terms[].targets` | Translations per locale. A locale uses its own entry, then its base language's (`de` covers `de-AT`), then `target`; codes compare canonically, ignoring case. |
| `terms[].forbidden` | Renderings a locale must never use; a regional locale also obeys its base language's list. |
| `terms[].caseSensitive` | Match the term, translations and forbidden renderings with case. Default `false`. |
| `terms[].note`, `terms[].partOfSpeech` | Free-text context, sent to an LLM provider as data. |
| `doNotTranslate` | Terms copied untranslated. A bare string keeps its case; `{ "term", "caseSensitive": false }` may change it. |

Each term needs a `target`, a `targets` entry or a `forbidden` list; it may not forbid its own translation, name a locale twice, or appear in `doNotTranslate`. No text may be blank; a term is at most 200 characters, a translation or forbidden rendering 2000, a `note` 500 and a `partOfSpeech` 50. A locale's [cache](https://verbatra.kreitz-webdev.de/docs/the-cache.md) fingerprint covers only its own terms. A version 1 file keeps its fingerprint; [`updateGlossaryTerm`](https://verbatra.kreitz-webdev.de/docs/sdk/config.md#updateglossaryterm) rewrites it as version 2 only for an edit version 1 cannot express.

<AvailableFrom version="0.4.0" pkg="@verbatra/studio" /> [Studio](https://verbatra.kreitz-webdev.de/docs/review-in-studio.md) edits only the file form, one term at a time. [Providers](https://verbatra.kreitz-webdev.de/docs/providers.md#tone-and-glossary-across-providers) says which providers apply it; every output is [checked afterwards](https://verbatra.kreitz-webdev.de/docs/translation-safety.md).

### generatePlurals [#generateplurals]

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

### fuzzyCache [#fuzzycache]

`fuzzyCache: { enabled: true }` lets a changed source reuse its earlier form's translation when the two are at least `threshold` alike, always flagged `FUZZY_CACHE_REUSE` ([Fuzzy reuse](https://verbatra.kreitz-webdev.de/docs/the-cache.md#fuzzy-reuse)); `--no-cache` bypasses it.

### humanEdits and pinnedKeys [#humanedits-and-pinnedkeys]

`humanEdits` covers a stale key whose value a person wrote (`human`, `import` or `external` [provenance](https://verbatra.kreitz-webdev.de/docs/the-lock-file.md#provenance-who-wrote-each-value)); a key matching `pinnedKeys` (such as `["legal.*", "brand.name"]`) is never machine-written, whatever `humanEdits` says. `*` matches any run of characters, dots included, and the match is case-sensitive. What each mode does is under [Keep human translations](https://verbatra.kreitz-webdev.de/docs/protecting-human-translations.md).

### network [#network]

See [Restrict network access](https://verbatra.kreitz-webdev.de/docs/network-policy.md).

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

### maxBatchSize [#maxbatchsize]

Missing and changed entries go out in sequential [sub-batches](https://verbatra.kreitz-webdev.de/docs/providers.md#batching) of at most this size. No CLI flag overrides it.

### maxLength [#maxlength]

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

```ts title="verbatra.config.ts"
maxLength: {
  "nav.dashboard": 18,
  "cta.submit": 12,
}
```

- **Here or in `verbatra.limits.json`.** <AvailableFrom version="0.13.0" /> Keys match literally, never as globs; a key set in neither is never measured. `setKeyLengthLimit` takes only source keys, so Studio cannot limit a generated plural form; an entry written by hand for its own key applies. The file beside the lock file takes `{ "version": 1, "maxLength": { ... } }`, the config wins per key, and [`setKeyLengthLimit`](https://verbatra.kreitz-webdev.de/docs/sdk/config.md#setkeylengthlimit) writes it.
- **Grapheme clusters.** Length counts perceived characters, whitespace included: an emoji, an accented letter or a CJK character is one. The limit is inclusive.
- **When.** Every value `translate` produces for the key, cache reuses included; an up-to-date key is measured only once its source changes, or by [`export --include-unchanged`](https://verbatra.kreitz-webdev.de/docs/cli/export.md).
- **Advisory.** An overrun is still written and locked, and flagged [`MAX_LENGTH_EXCEEDED`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#max_length_exceeded) for review.

### maxTokens and budgetBehavior [#maxtokens-and-budgetbehavior]

`maxTokens` is a whole-run budget: input plus output tokens over every provider call (translation and plural generation) and every target locale. [`translate --max-tokens`](https://verbatra.kreitz-webdev.de/docs/cli/translate.md#token-ceiling) can tighten it for one run, never loosen it, and always as a hard `"stop"`.

`budgetBehavior` decides what happens then:

- `"warn"`, the default, counts the spend and flags the overrun but continues.
- `"stop"` <AvailableFrom version="0.11.0" /> makes it a ceiling for every provider. Before each request the run projects its cost like [the pre-run estimate](https://verbatra.kreitz-webdev.de/docs/estimating-cost.md) and refuses one that would pass `maxTokens`. That request's keys and every key not yet attempted (the rest of the locale and every later locale, whose diff and orphan reporting still run) are withheld, keep their prior lock hash and are retried next run.

Under `"stop"`:

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

The budget bounds one `translate` run, never a single-entry retranslation; `watch` starts a fresh one per run. A budget rules out [`--concurrency`](https://verbatra.kreitz-webdev.de/docs/cli/translate.md#parallel-locales) above `1` on a live run; a dry run is exempt. `budgetBehavior` without `maxTokens` has no effect.

<Callout type="warn" title="Two different maxTokens">
The top-level `maxTokens` is this whole-run budget; Anthropic's unrelated `provider.options.maxTokens` caps one response ([Providers](https://verbatra.kreitz-webdev.de/docs/providers.md#anthropic)).
</Callout>

### rates [#rates]

Read only by [`translate --estimate`](https://verbatra.kreitz-webdev.de/docs/estimating-cost.md), which turns rates into money; a live run ignores it.

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

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

### extract [#extract]

Read only by [`extract`](https://verbatra.kreitz-webdev.de/docs/cli/extract.md), [`doctor --literals`](https://verbatra.kreitz-webdev.de/docs/cli/doctor.md#untranslated-literals) and [`diff --unused`](https://verbatra.kreitz-webdev.de/docs/cli/diff.md#unused-source-keys).

```ts title="verbatra.config.ts"
extract: {
  framework: "i18next",
  roots: ["src", "app"],
  exclude: ["generated"],
  literals: { ignore: ["Acme Inc."] },
  unused: { ignore: ["emails.*"] },
}
```

- **`framework`** must name one with an extractor ([call shapes](https://verbatra.kreitz-webdev.de/docs/cli/extract.md#what-it-finds)); any other is rejected.
- **`roots`** resolve against the project root; **`exclude`** adds directory names (not paths), matched anywhere under a root, to the [always-skipped set](https://verbatra.kreitz-webdev.de/docs/cli/extract.md#scan-boundaries).
- **`literals.ignore`** matches a literal's whole text, whitespace collapsed and references decoded ([Untranslated literals](https://verbatra.kreitz-webdev.de/docs/cli/doctor.md#untranslated-literals)).
- **`unused.ignore`** matches the decoded key ([`diff --unused`](https://verbatra.kreitz-webdev.de/docs/cli/diff.md#unused-source-keys)).

## Discovery order [#discovery-order]

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

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

`--config <path>` loads one file instead, relative to the working directory; the SDK's precedence is under [`loadConfig`](https://verbatra.kreitz-webdev.de/docs/sdk/config.md#loadconfig).

## Editor validation [#editor-validation]

<AvailableFrom version="0.9.0" />

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

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

Offline, or to pin the installed version, use the package copy, `./node_modules/@verbatra/sdk/dist/config-schema.json` ([all schemas](https://verbatra.kreitz-webdev.de/docs/cli/output.md#schemas)).

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

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

The extensionless `.verbatrarc` also needs a workspace file match:

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

A YAML config takes a YAML Language Server comment:

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

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

### What the document cannot check [#what-the-document-cannot-check]

Only loading the config checks these rules: the `targetLocales` rules and [locale codes](#locale-codes), [`localeMap`](https://verbatra.kreitz-webdev.de/docs/providers.md#locale-codes-sent-to-the-provider) keys and the `openai-compatible` `baseUrl` and [`apiKeyEnvVar`](https://verbatra.kreitz-webdev.de/docs/providers.md#openai-compatible) rules, valid [`network.allowedHosts`](https://verbatra.kreitz-webdev.de/docs/network-policy.md) addresses and ranges, `sensitiveData.patterns` within the [safety rules](https://verbatra.kreitz-webdev.de/docs/data-handling.md#sensitive-content-guard), and an inline [version 2 glossary](#per-locale-glossary-version-2)'s term rules.

## The working directory and .env [#the-working-directory-and-env]

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

## When loading fails [#when-loading-fails]

- [`CONFIG_NOT_FOUND`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#config_not_found): no config in any searched place, or the `--config` path does not exist.
- [`CONFIG_INVALID`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#config_invalid): a config was found but is unusable. The message lists every issue with its key path; an unknown key adds the hint that API keys come from the environment. A parse failure names the file (with line and column for YAML) but never quotes its content, and every message passes through key redaction.

The CLI exits `2` for both.
