Docs / Guides
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 covers the first run. This page picks up from there
with complete recipes to copy into a script; the SDK reference 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: true implies dryRun. The figure lands on RunSummary.estimate: branch on unit for
the billed dimension and on pricing for whether a rates block priced
it.
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 explains the method,
and RunSummary lists what each EstimateCaveatCode in caveats leaves
out.
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.
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.
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
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.
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.
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.
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
Pass an AbortSignal as signal. translate then resolves rather than rejects: it writes what
already arrived, leaves the rest pending, and sets cancelled: true.
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_ error code, and one it stopped mid-run the RUN_ notice. 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 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.
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_. extract lists every other error it raises.
Generate a typed catalog
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.
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 lists everything a
generating run refuses.
Build a pseudolocale
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.
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
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.
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 lists what each of them throws.
Values a person wrote
retranslateEntry refuses a value a person wrote with KEY_, 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.
Work through the review queue
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_ 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.
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 has the batch variants and every code they throw.
Read every value in bulk
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.
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_.
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.
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 for what translators may edit and how rows are
validated.
The TMX round-trip
importTmx and exportTmx move the translation memory 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.
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_, 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
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.
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 });