---
title: "SDK reference"
description: "Install @verbatra/sdk and find the reference page for each entry point. Files that do not live on disk reach the SDK through your own file system."
type: "reference"
---
# SDK reference

Install @verbatra/sdk and find the reference page for each entry point. Files that do not live on disk reach the SDK through your own file system.

`@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.

## Start here [#start-here]

New to the SDK? Begin with one of these:

<Cards>
  <Card title="SDK quickstart" href="https://verbatra.kreitz-webdev.de/docs/sdk-quickstart.md">Install the SDK, preview a run without a key, translate from your own script, handle errors, and gate CI.</Card>
  <Card title="SDK recipes" href="https://verbatra.kreitz-webdev.de/docs/programmatic-api.md">Complete scripts for estimates, watch mode, a fake provider, the review queue, and the workbook and TMX round-trips.</Card>
</Cards>

## Reference pages [#reference-pages]

These pages catalog the whole public surface, grouped by the job each entry point does.

<SdkEntryPoints />

<SdkAnchorForward />

## Install [#install]

```bash
npm install --save-dev @verbatra/sdk
```

Requires Node.js `^22.22.0 || >=24`.

With pnpm, `pnpm add --save-dev @verbatra/sdk` 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](https://verbatra.kreitz-webdev.de/docs/troubleshooting.md) 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.

<Callout title="API keys and the environment">
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`.
</Callout>

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; you can ignore it in normal use. See
[The dependency seam](#the-dependency-seam) for what `deps.fs` reaches.

## The dependency seam [#the-dependency-seam]

<AvailableFrom version="0.9.0" />

`deps.fs` replaces the file-system port the SDK's own I/O travels through, typed as `SdkFs`. The
seam is complete: the run-status file, the lock file, the glossary file tools, workbook and
interchange I/O, and the locale files themselves all go through it, because the format adapters read and write
through a port built from the same object. A whole run can therefore be held in memory, which is
how the SDK's own tests avoid touching disk and how an embedding application can back part of a
project with something other than a local disk.

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `fileExists` | `(path: string) => Promise<boolean>` | yes | Reports whether a file exists at the path. The default implementation checks existence only. |
| `readFileBounded` | `function` | yes | Reads a file as UTF-8 text, refusing to read more than `maxBytes`. |
| `readBytesBounded` | `function` | yes | Reads a file as raw bytes, refusing to read more than `maxBytes`. |
| `writeFile` | `(path: string, data: string) => Promise<void>` | yes | Writes UTF-8 text to the path, replacing any existing file atomically. |
| `writeBytes` | `function` | yes | Writes raw bytes to the path, replacing any existing file atomically. |
| `createExclusive` | `(path: string, data: string) => Promise<boolean>` | yes | Creates a file only if it does not already exist, returning `false` when it does. This is the primitive behind the per-locale write lock, so it must be atomic against other processes. |
| `deleteFile` | `(path: string) => Promise<void>` | yes | Removes the file at the path, succeeding even when it is already absent. |
| `mkdir` | `((path: string) => Promise<void>)` | no | Creates a directory and any missing parents. Optional; omit it if the backing store has no directories. |
| `readDirectory` | `function` | no | Lists one directory's immediate entries, without recursing and without following symbolic links. Optional, so an implementation written before source extraction existed keeps working; extract is the only entry point that needs it and reports its absence as `EXTRACT_FS_UNSUPPORTED`. |
| `realpath` | `((path: string) => Promise<string>)` | no | Resolves every symbolic link in an existing path and returns the canonical absolute path. Optional, so an implementation written before it existed keeps compiling. The output guards of generateTypes, exportTmx, exportWorkbook, and pseudolocalize use it to see where a path really lands, so a symbolic link cannot carry an output file outside the working directory or onto a file the project depends on. Without it, those guards compare paths as written. A symbolic link at the output path whose target does not exist cannot be resolved, so the guards assume SdkFs.writeFile and SdkFs.writeBytes replace a link there rather than writing through it, as the default atomic write does. |
| `rename` | `((from: string, to: string) => Promise<void>)` | no | Moves the file at `from` to `to` in one atomic step, replacing any file already at `to`. The SDK only ever renames within one directory. Optional, so an implementation written before it existed keeps compiling. The write lock uses it to remove a lock or reclaim guard left by a process that is gone, and to release its own lock: it moves the file aside under a unique `<name>.<pid>.<host tag>.<uuid>.stale` name and deletes it only when what it moved is the record it expected. Anything else, such as a lock another process took in the meantime, is put back with SdkFs.createExclusive; a record that cannot be put back because a third process took the path in the meantime is deleted, since its holder's ownership check fails from then on. An abandoned reclaim guard is cleared only after it is seen unchanged across two polls, and only when its recorded process is gone: a guard records no heartbeat. A reclaim rename that fails with `EPERM`, `EBUSY`, or `EACCES`, as Windows reports for a file another process has open, is treated as "not reclaimed this poll" and retried until the lock timeout. A release rename that fails for any reason falls back to reading the lock file and deleting it when it still holds this holder's token, and that fallback retries a failure with one of those three codes a few times, a poll apart, before it gives up. Each lock records a random ownership token, and before every write it protects the holder checks that the lock file still holds its token, failing with `LOCK_CONTENDED` and writing nothing when it does not. That check and the write are two steps, and nothing bounds the time between them: a holder paused after the check (a sleeping machine, a stopped process, a debugger, a clock step, or a long block of its event loop) can have its lock judged abandoned once its heartbeat is three intervals old (30 seconds by default), and still write once when it resumes, after another process took the lock over. Without `rename`, the lock reads the file and deletes it when unchanged, a second such window. A `.stale` file left in `.verbatra-local/locks` by a process that crashed mid-move is safe to delete by hand; the next lock acquisition deletes it once the process that moved it is gone, or, on a file system that implements SdkFs.touch, once it is older than the stale threshold, measured from the move. |
| `touch` | `((path: string) => Promise<void>)` | no | Sets the modification time of an existing file to the current time of this process's clock, not the file server's. Optional. Together with SdkFs.mtimeMs it is the write lock's heartbeat: a holder whose file system implements `touch` records a heartbeat interval (10 seconds) in its lock, touches the lock file right after creating it, and refreshes its modification time at that interval while it holds the lock. A file moved aside is touched too, so its age counts from the move. |
| `mtimeMs` | `((path: string) => Promise<number \| undefined>)` | no | Reads a file's modification time. Optional. A waiting process whose file system implements it treats a lock on its own machine whose holder recorded a heartbeat interval but has not refreshed the file for three intervals (30 seconds by default) as abandoned, even when the recorded process is still running, such as a long-lived Studio, `watch`, or MCP server whose lock record outlived its hold. Without `touch` and `mtimeMs`, a lock is judged abandoned only when its recorded process is provably gone. |

Two contract points an implementation has to honor. Reads are size-bounded, so `readFileBounded`
and `readBytesBounded` take a byte limit and report `missing` or `too-large` as a state rather than
throwing, which is what keeps a hostile or accidentally huge file from exhausting memory. Writes are
expected to be atomic, so a crash mid-write never leaves a half-written file behind, and
`createExclusive` must be atomic against other processes because it is the primitive behind the
per-locale write lock. Before it writes a locale file, the run-status file, or an output file such
as a workbook, the SDK calls the optional `mkdir` with the parent directory, so `writeFile` and
`writeBytes` need not create one; leave `mkdir` out when the store has no directories.
`createExclusive` gets no such call and creates a missing parent itself. The other required
members are `fileExists` and `deleteFile`, which succeeds when the file is already gone.

`readDirectory` is optional, so an implementation written before source extraction existed keeps
compiling. [`extract`](https://verbatra.kreitz-webdev.de/docs/sdk/inspect.md#extract) is the only entry point that needs it, and reports its absence as
`EXTRACT_FS_UNSUPPORTED`. It lists one directory's immediate entries as `{ name, kind }`, where
`kind` is `"file"`, `"directory"`, or `"other"`. A symbolic link belongs in `"other"`: the scan
never follows one, which is what keeps it inside the roots it was given.

`realpath` is optional too. It resolves every symbolic link in an existing path, and the output
guards of [`generateTypes`](https://verbatra.kreitz-webdev.de/docs/sdk/run.md#generatetypes), [`exportTmx`](https://verbatra.kreitz-webdev.de/docs/sdk/exchange.md#exporttmx),
[`exportWorkbook`](https://verbatra.kreitz-webdev.de/docs/sdk/exchange.md#exportworkbook), and [`pseudolocalize`](https://verbatra.kreitz-webdev.de/docs/sdk/run.md#pseudolocalize) use it to see where an
output path really lands, so a link cannot carry a written file outside `cwd` or onto a file the
project depends on. Without it, those guards compare paths as written. A link at the output path
whose target does not exist cannot be resolved, so the guards assume `writeFile` and `writeBytes`
replace a link there rather than writing through it, as the default atomic write does.

`rename` is optional as well.

<AvailableFrom version="0.12.0" />

It moves a file to a new name in the same directory in one atomic step, replacing any file already
there, and rejects with an error whose `code` is `ENOENT` when the source is missing. The write lock
uses it to remove a lock or reclaim guard left by a process that is gone, and to release its own
lock: the file is moved aside under a unique `<name>.<pid>.<tag>.<uuid>.stale` name and deleted only
when it is still the record the lock expected. Anything else, such as a lock another process took in
the meantime, is put back with `createExclusive`. A rename that fails with `EPERM`, `EBUSY`, or
`EACCES`, as Windows reports for a file another process has open, counts as "not reclaimed this
poll", and the lock keeps polling until its timeout. When the rename that releases a lock fails for
any reason, the release falls back to reading the lock file and deleting it while it still holds
the holder's token, and retries an `EPERM`, `EBUSY`, or `EACCES` failure a few times, a poll apart.
A record that should have been put back but cannot be, because a third process took the path in
the meantime, is deleted, since its holder's token check fails from then on. Without `rename`, the lock reads the file and
deletes it when unchanged, which leaves a wider window in which a lock another process just took is
deleted.

Moving a file aside and putting it back is two steps, so in rare interleavings two processes can
briefly both believe they hold the same lock. Every lock therefore records a random ownership
token, and before each write the lock protects (the locale file, the lock file, the provenance
file, the glossary) the holder checks that the lock file still carries its token. When it does not,
the operation stops with `LOCK_CONTENDED` and writes nothing. A lock file found missing or holding
another token on release is reported the same way when the operation succeeded, since the other
process may have changed the same files since; an operation that failed throws its own error. The
check and the write are still two steps, and nothing bounds the time between them: a holder paused
right after the check (a sleeping machine, a stopped process, a debugger, a clock step, or a long
block of its event loop) stops its heartbeat, so once its lock is three intervals old (30 seconds
by default) another process on the same machine takes it over, and the paused holder can still
write once when it resumes. The token check relies on `createExclusive` creating a file that
`readFileBounded` reads back, so a custom implementation must keep that promise.

`touch` and `mtimeMs` are optional too.

<AvailableFrom version="0.12.0" />

`touch(path)` sets an existing file's modification time to now, and `mtimeMs(path)` returns it in
milliseconds, or `undefined` when the file is missing. With `touch`, a lock holder records a
heartbeat interval of 10 seconds in its lock and refreshes the file's modification time at that
interval while it holds the lock. It touches the lock file right after creating it too, so the
file's age is measured by this process's clock rather than the file server's. With `mtimeMs`, a waiting process treats a lock on its own
machine whose recorded process is still running but whose file has not been refreshed for three of
the recorded intervals (30 seconds by default) as abandoned. That covers a lock record that outlived
its hold, for example one put back for a long-lived Studio, `watch`, or MCP server process that had
already released it. A reclaim guard records no heartbeat: it is cleared once its process is
provably gone and it has been seen unchanged across two polls. Without these members, a lock is judged abandoned
only when its recorded process is provably gone.

A process that crashes while it has a lock moved aside can leave a `.stale` file in
`.verbatra-local/locks/`. Such files are safe to delete by hand, and the next lock acquisition
deletes them once the process that moved them is gone or, when `touch` is implemented, once they
are older than the stale threshold, counted from the move. A file system without `touch` keeps
them until their mover is gone.

`deps.fs` cannot reach a caller-supplied `deps.adapterRegistry`. Those adapters
were constructed by the caller, so their file access is whatever the caller wired into them.
Supplying both means the caller owns that wiring.

Config loading sits outside the seam as well. `loadConfig` and `loadConfigWithMeta` take no `deps`:
they read the config file itself from the real disk, and a glossary file the config names through
their own `fs` option, so the `deps.fs` a later flow receives reaches neither read. To keep a whole
project in memory, pass the same `SdkFs` as `loadConfig({ fs })`, and hand the config in as
`configOverride` rather than as a file. The flows resolve paths against their own `cwd`, so pass it
there too.

The example keeps a whole project in memory with only the required members. Under the provider
`none` the run needs no key and leaves `greeting` for a person to translate.

```ts title="Example"
import { check, loadConfig, type SdkFs, translate } from "@verbatra/sdk";

const files = new Map<string, Uint8Array>();
const encoder = new TextEncoder();
const decoder = new TextDecoder();

function bounded(path: string, maxBytes: number) {
  const bytes = files.get(path);
  if (bytes === undefined) return { kind: "missing" } as const;
  if (bytes.byteLength > maxBytes) return { kind: "too-large" } as const;
  return { kind: "ok", bytes } as const;
}

const memoryFs: SdkFs = {
  fileExists: async (path) => files.has(path),
  readFileBounded: async (path, maxBytes) => {
    const read = bounded(path, maxBytes);
    return read.kind === "ok" ? { kind: "ok", content: decoder.decode(read.bytes) } : read;
  },
  readBytesBounded: async (path, maxBytes) => bounded(path, maxBytes),
  writeFile: async (path, data) => {
    files.set(path, encoder.encode(data));
  },
  writeBytes: async (path, data) => {
    files.set(path, data);
  },
  createExclusive: async (path, data) => {
    if (files.has(path)) return false;
    files.set(path, encoder.encode(data));
    return true;
  },
  deleteFile: async (path) => {
    files.delete(path);
  },
};

files.set("/project/locales/en.json", encoder.encode('{ "greeting": "Hello, {{name}}!" }'));

const config = await loadConfig({
  cwd: "/project",
  fs: memoryFs,
  configOverride: {
    sourceLocale: "en",
    targetLocales: ["de"],
    format: "i18next-json",
    files: { pattern: "locales/{locale}.json" },
    provider: { id: "none" },
  },
});

const summary = await translate({ config, cwd: "/project" }, { fs: memoryFs });
const status = await check({ config, cwd: "/project" }, { fs: memoryFs });

console.log(summary.locales[0]?.unfilled, status.inSync); // [ 'greeting' ] false
console.log([...files.keys()]); // the source, verbatra.lock.json, .verbatra-local/run-status.json
```
