---
title: "SDK recipes"
description: "Complete, runnable patterns for driving verbatra from your own scripts and CI jobs, each one built on the public @verbatra/sdk entry points."
type: "how-to"
---
# SDK recipes

Complete, runnable patterns for driving verbatra from your own scripts and CI jobs, each one built on the public @verbatra/sdk entry points.

The [SDK quickstart](https://verbatra.kreitz-webdev.de/docs/sdk-quickstart.md) covers the first run. This page picks up from there
with complete recipes to copy into a script; the [SDK reference](https://verbatra.kreitz-webdev.de/docs/sdk.md) catalogs every entry
point. The SDK does not load `.env` files, so run a recipe that calls a provider with
`node --env-file=.env`. To work through a rate-limited provider one language at a time, pass
`locales: ["de"]` to `translate`.

## Estimate a run before spending [#estimate-a-run-before-spending]

<AvailableFrom version="0.11.0" />

`estimate: true` implies `dryRun`. The figure lands on `RunSummary.estimate`: branch on `unit` for
the billed dimension and on `pricing` for whether a [`rates`](https://verbatra.kreitz-webdev.de/docs/config-file.md#rates) block priced
it.

```ts
import { loadConfig, translate } from "@verbatra/sdk";

const summary = await translate({ config: await loadConfig(), estimate: true });
const estimate = summary.estimate;

if (estimate !== undefined) {
  console.log(`${estimate.keys} keys in ${estimate.requests} requests to ${estimate.rateKey}`);

  if (estimate.unit === "tokens") {
    console.log(`about ${estimate.inputTokens} in, ${estimate.outputTokens} out`);
  } else {
    console.log(`about ${estimate.sourceCharacters} source characters`);
  }

  if (estimate.pricing === "priced") {
    console.log(`about ${estimate.cost} ${estimate.currency}, rates read ${estimate.asOf}`);
    for (const locale of estimate.locales) {
      console.log(`  ${locale.locale}: ${locale.cost}`);
    }
  } else {
    console.log(`no currency figure: ${estimate.pricing}`);
  }

  console.log(`leaves out: ${estimate.caveats.join(", ")}`);
}
```

It bounds the plan, not the invoice; [Estimating cost](https://verbatra.kreitz-webdev.de/docs/estimating-cost.md) explains the method,
and [RunSummary](https://verbatra.kreitz-webdev.de/docs/sdk/run-summary.md) lists what each `EstimateCaveatCode` in `caveats` leaves
out.

## 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.

```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](https://verbatra.kreitz-webdev.de/docs/ci-and-exit-codes.md).

```ts
import { diff, loadConfig } from "@verbatra/sdk";

const summary = await diff({ config: await loadConfig() });

if (summary.hasPendingChanges) {
  for (const locale of summary.locales) {
    console.error(`${locale.locale}: missing ${locale.missing.join(", ")}; changed ${locale.changed.join(", ")}`);
  }
  process.exitCode = 1;
}
```

## Test your integration without a key [#test-your-integration-without-a-key]

A fake provider passed as `createProvider` runs the whole flow, files and integrity gate
included, with no key, no network call and deterministic output: what a unit test of your own
tooling needs.

```ts title="fake-provider.ts"
import { type CreateProvider, check, loadConfig, translate } from "@verbatra/sdk";

const createProvider: CreateProvider = () => ({
  id: "fake",
  kind: "machine-translation",
  supportsGlossary: false,
  translateBatch: async (request) => ({
    values: new Map(
      request.entries.map((entry) => [entry.key, `[${request.targetLocale}] ${entry.value}`]),
    ),
    integrity: new Map(),
    notices: [],
  }),
});

const config = await loadConfig();
const summary = await translate({ config }, { createProvider });
const after = await check({ config });

console.log(`${summary.succeeded.length} locales translated, in sync: ${after.inSync}`);
if (summary.failed.length > 0 || !after.inSync) {
  process.exitCode = 1;
}
```

The run writes real files, so test against a temporary copy of the project. A real provider plugs
in the same way; see [Custom providers](https://verbatra.kreitz-webdev.de/docs/sdk/extend.md#custom-providers).

## 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.

## Cancel a run [#cancel-a-run]

<AvailableFrom version="0.12.0" />

Pass an `AbortSignal` as `signal`. `translate` then resolves rather than rejects: it writes what
already arrived, leaves the rest pending, and sets `cancelled: true`.

```ts
import { loadConfig, translate } from "@verbatra/sdk";

const controller = new AbortController();
process.once("SIGINT", () => controller.abort());

const summary = await translate({ config: await loadConfig(), signal: controller.signal });

if (summary.cancelled === true) {
  const { succeeded, partial, failed } = summary;
  console.error(
    `cancelled: ${succeeded.length} done, ${partial.length} partial,`,
    `${failed.length} failed or not started`,
  );
  process.exitCode = 130;
}
```

A locale the abort kept from starting carries the [`RUN_CANCELLED` error code](https://verbatra.kreitz-webdev.de/docs/error-codes.md#run_cancelled), and one it stopped mid-run the [`RUN_CANCELLED` notice](https://verbatra.kreitz-webdev.de/docs/error-codes.md#notice-run_cancelled). An abort that arrives after every locale finished leaves `cancelled` absent, and a whole-run error at startup still rejects.

## Extract keys from your source code [#extract-keys-from-your-source-code]

<AvailableFrom version="0.11.0" />

`extract` is the one entry point that reads application code rather than a locale file. It writes
only the source catalog, and only genuinely new keys: an existing key keeps its value, and a key no
call site mentions is left alone. Everything the scan cannot resolve comes back as data, so a
dry run makes a good gate before the writing run.

```ts
import { extract, loadConfig } from "@verbatra/sdk";

const config = await loadConfig();
const preview = await extract({ config, dryRun: true });

console.log(`${preview.scannedFiles} files scanned, ${preview.existingKeys} keys already present`);
for (const added of preview.added) {
  console.log(`+ ${added.key} = ${JSON.stringify(added.value)} (${added.file}:${added.line})`);
}
for (const conflict of preview.conflicts) {
  console.warn(`${conflict.key} has two defaults: ${conflict.values.join(" | ")}`);
}
for (const site of preview.dynamic) {
  console.warn(`non-static key argument at ${site.file}:${site.line}`);
}
for (const diagnostic of preview.diagnostics) {
  console.warn(`${diagnostic.file}: ${diagnostic.reason}`);
}

if (preview.added.length > 0 && preview.conflicts.length === 0) {
  const run = await extract({ config });
  console.log(`wrote ${run.added.length} keys to ${run.sourcePath}: ${run.written}`);
}
```

`config.extract` has to name the framework and the source roots, or the call throws
`EXTRACT_NOT_CONFIGURED`. [`extract`](https://verbatra.kreitz-webdev.de/docs/sdk/inspect.md#extract) lists every other error it raises.

## Generate a typed catalog [#generate-a-typed-catalog]

<AvailableFrom version="0.11.0" />

`generateTypes` declares the source catalog as TypeScript: a union of every key, plus the arguments
each message interpolates. It reads one file and writes one file, and two runs over an unchanged
catalog write byte-identical bytes, which is what makes the `check` mode a usable CI gate.

```ts
import { generateTypes, loadConfigWithMeta } from "@verbatra/sdk";

const loaded = await loadConfigWithMeta();
const out = "src/generated/messages.d.ts";
const configPath = loaded.source.kind === "override" ? undefined : loaded.source.filepath;

const result = await generateTypes({
  config: loaded.config,
  out,
  ...(configPath !== undefined ? { configPath } : {}),
});

console.log(`${result.keys} keys in ${result.path}, ${result.withArguments} take arguments`);
for (const message of result.unresolved) {
  console.warn(`arguments not claimed for ${message.key}: ${message.reason}`);
}

// In CI, compare instead of writing.
const gate = await generateTypes({ config: loaded.config, out, check: true });
if (gate.stale) {
  console.error(`${gate.path} is out of date, re-generate it`);
  process.exitCode = 1;
}
```

Pass `configPath` so the config file the run loaded is refused as an output path. A `check` run
writes nothing and only reports `stale`. [`verbatra types`](https://verbatra.kreitz-webdev.de/docs/cli/types.md) lists everything a
generating run refuses.

## Build a pseudolocale [#build-a-pseudolocale]

<AvailableFrom version="0.11.0" />

`pseudolocalize` builds a fake locale from the source strings alone, so layout truncation and
strings that were never extracted show up before any API key exists. It calls no provider, and its
output lands outside `files.pattern`, so no run spends on it or reports it as drifted.

```ts
import { loadConfig, pseudolocalize } from "@verbatra/sdk";

const result = await pseudolocalize({ config: await loadConfig() });

console.log(`${result.locale} written to ${result.path}: ${result.written}`);
console.log(`${result.transformed} of ${result.entries} entries transformed`);
for (const key of result.copied) {
  console.warn(`copied from source verbatim, a pseudolocalized value would not pass the gate: ${key}`);
}
```

`locale` defaults to `en-XA`, or to `ar-XB` with `mode: "bidi"`, and `out` to `.verbatra-local/pseudo`. The transform is deterministic,
so a second run over unchanged source rewrites nothing and reports `written` as false.

## 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. The edit above
// recorded a value a person wrote, which retranslateEntry protects unless told otherwise.
const retry = await retranslateEntry({
  config,
  locale: "de",
  key: "checkout.title",
  includeHuman: true,
});
if (retry.accepted) {
  console.log(`wrote: ${retry.value}`);
  if (retry.reviewReasons.length > 0) {
    console.warn(`flagged for review: ${retry.reviewReasons.join(", ")}`);
  }
}
```

[`retranslateEntry`](https://verbatra.kreitz-webdev.de/docs/sdk/review.md#retranslateentry) lists what each of them throws.

### Values a person wrote [#values-a-person-wrote]

<AvailableFrom version="0.12.0" />

`retranslateEntry` refuses a value a person wrote with `KEY_PROTECTED`, including the one
`editEntry` just saved, unless you pass `includeHuman: true` or the config sets
`humanEdits: "overwrite"`. That is why the recipe passes it. A pinned key is refused always; see [Protecting human translations](https://verbatra.kreitz-webdev.de/docs/protecting-human-translations.md).

## Work through the review queue [#work-through-the-review-queue]

<AvailableFrom version="0.12.0" />

`reviewQueue` lists every machine-written value nobody has approved yet, with the reasons the last
run flagged it for. This script approves the unflagged values and rejects the rest, which removes
them so the next `translate` fills them again. `expectedValue` makes a call throw
`REVIEW_VALUE_CHANGED` if the value changed in between. On a machine with no record of the last run,
like a fresh clone or CI runner, `reasons` is empty too, so the script stops without `lastRunAt`.

```ts title="review.ts"
import { approveEntry, keyValue, loadConfig, rejectEntry, reviewQueue } from "@verbatra/sdk";

const config = await loadConfig();
const queue = await reviewQueue({ config });

if (!queue.available) {
  console.error(`no review queue: ${queue.reason}`);
  process.exit(1);
}
if (queue.lastRunAt === undefined) {
  console.error("no run recorded on this machine, so flags are unknown: run translate first");
  process.exit(1);
}

for (const { locale, needsReview } of queue.locales) {
  for (const entry of needsReview) {
    const { target } = await keyValue({ config, locale, key: entry.key });
    if (target === undefined) {
      continue;
    }
    const decision = { config, locale, key: entry.key, expectedValue: target };
    if (entry.reasons.length > 0) {
      await rejectEntry(decision);
      console.log(`rejected ${locale}/${entry.key}: ${entry.reasons.join(", ")}`);
    } else {
      await approveEntry(decision);
      console.log(`approved ${locale}/${entry.key}`);
    }
  }
}
```

[Review and retranslate](https://verbatra.kreitz-webdev.de/docs/sdk/review.md#approveentry) has the batch variants and every code they throw.

## Read every value in bulk [#read-every-value-in-bulk]

<AvailableFrom version="0.10.0" />

`localeValues` is the bulk counterpart to `keyValue`: one pass over the files already on disk,
returning every key's current source and target text per locale.

```ts title="scan-values.ts"
import { loadConfig, localeValues } from "@verbatra/sdk";

const report = await localeValues({ config: await loadConfig() });

for (const { locale, values } of report) {
  for (const [key, pair] of Object.entries(values)) {
    if (pair.source === undefined) {
      console.warn(`${locale}/${key}: orphaned, no source key any more`);
    } else if (pair.target === undefined) {
      console.warn(`${locale}/${key}: not translated yet`);
    } else if (pair.target.includes("TODO")) {
      console.log(`${locale}/${key}: ${pair.target}`);
    }
  }
}
```

Read-only: it calls no provider, writes nothing, and touches neither the lock file nor the cache.
It covers every configured target locale; pass `locales` to narrow it to some of them, and a locale outside `targetLocales` throws `UNKNOWN_LOCALE`.

## 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.

```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](https://verbatra.kreitz-webdev.de/docs/manual-translation.md) for what translators may edit and how rows are
validated.

## The TMX round-trip [#the-tmx-round-trip]

<AvailableFrom version="0.11.0" />

`importTmx` and `exportTmx` move the [translation memory](https://verbatra.kreitz-webdev.de/docs/the-cache.md) in and out of TMX, the
interchange format the mainstream translation platforms produce and consume. Unlike the
workbook pair, it moves the whole memory as one multilingual file between tools.

```ts
import { exportTmx, importTmx, loadConfig, tmxErrorLocation } from "@verbatra/sdk";

const config = await loadConfig();

// Validate another tool's file before it touches the memory.
try {
  const preview = await importTmx({ config, file: "legacy.tmx", dryRun: true });
  console.log(`${preview.units} units, source language ${preview.sourceLanguage ?? "undeclared"}`);
  for (const locale of preview.locales) {
    console.log(
      `${locale.locale}: +${locale.added}, kept ${locale.kept}, conflicting ${locale.conflicting}`,
    );
  }

  // Then land it. Pass overwrite: true to let the file win where the two disagree.
  const landed = await importTmx({ config, file: "legacy.tmx" });
  if (!landed.memoryWritable) {
    console.error("the memory file was written by a newer verbatra and was left untouched");
  }
} catch (error) {
  const at = tmxErrorLocation(error);
  if (at === undefined) {
    throw error;
  }
  console.error(`legacy.tmx is malformed at line ${at.line}, column ${at.column}`);
}

// ...and hand this project's memory to another tool.
const exported = await exportTmx({ config, out: "out/memory.tmx" });
console.log(`${exported.units} units to ${exported.path}`);
```

`importTmx` reports
damaged data rather than throwing on it: a refused file raises `SOURCE_INVALID`, and
`tmxErrorLocation` reads the place out of that error without casting it or its `cause`. Only entries
under the current configuration fingerprint are exported, which is the same set a run would reuse.

## 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 });
```
