---
title: "SDK-Referenz"
description: "Installiere @verbatra/sdk und finde die Referenzseite zu jedem Einstiegspunkt. Dateien, die nicht auf der Platte liegen, kommen über dein Dateisystem ins SDK."
type: "reference"
---
# SDK-Referenz

Installiere @verbatra/sdk und finde die Referenzseite zu jedem Einstiegspunkt. Dateien, die nicht auf der Platte liegen, kommen über dein Dateisystem ins SDK.

`@verbatra/sdk` ist der Motor, auf dem der Befehl `verbatra` läuft. Die CLI ist ein dünner Wrapper;
was die Kommandozeile kann, kannst du also auch im Code.

## Hier anfangen [#hier-anfangen]

Neu im SDK? Fang mit einer dieser Seiten an:

<Cards>
  <Card title="SDK-Schnellstart" href="https://verbatra.kreitz-webdev.de/de/docs/sdk-quickstart.md">Das SDK installieren, einen Lauf ohne Key vorab ansehen, aus deinem eigenen Skript übersetzen, Fehler behandeln und CI absichern.</Card>
  <Card title="SDK-Rezepte" href="https://verbatra.kreitz-webdev.de/de/docs/programmatic-api.md">Vollständige Skripte für Schätzungen, den Watch-Modus, einen Fake-Provider, die Review-Warteschlange und die Hin- und Rückwege über Arbeitsmappe und TMX.</Card>
</Cards>

## Referenzseiten [#referenzseiten]

Diese Seiten katalogisieren die gesamte öffentliche Oberfläche, gruppiert nach der Aufgabe jedes
Einstiegspunkts.

<SdkEntryPoints />

<SdkAnchorForward />

## Installation [#installation]

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

Benötigt Node.js `^22.22.0 || >=24`.

Mit pnpm installiert `pnpm add --save-dev @verbatra/sdk` korrekt, beendet sich aber mit `1` und `ERR_PNPM_IGNORED_BUILDS`: pnpm
sperrt die Installationsskripte des mitgelieferten Gemini-SDK und dessen `protobufjs` und
hinterlässt eine unbeantwortete `pnpm-workspace.yaml`, an der jeder spätere pnpm-Befehl im Projekt
genauso scheitert. Führe einmal `pnpm approve-builds` aus und bestätige keinen der beiden Einträge,
oder sieh dir in der [Fehlerbehebung](https://verbatra.kreitz-webdev.de/de/docs/troubleshooting.md) die nicht-interaktive Lösung an. Einen
`npx`-Umweg wie bei der CLI gibt es hier nicht, denn du importierst eine Bibliothek und startest
kein Binary.

<Callout title="API-Keys und die Umgebung">
Das SDK liest, hält oder akzeptiert nie einen API-Key. Der Provider liest seinen Key aus der
Umgebung (zum Beispiel `ANTHROPIC_API_KEY`), wenn er konstruiert wird. Anders als die CLI lädt das
SDK keine `.env`-Dateien: setze die Variable in deinem eigenen Skript selbst, zum Beispiel mit
`node --env-file=.env script.js`.
</Callout>

Jeder Einstiegspunkt nimmt ein Eingabeobjekt, dessen erstes Feld die validierte `config` (aus
`loadConfig`) ist, außer wo anders vermerkt. Die meisten akzeptieren zusätzlich ein optionales
zweites `deps`-Argument, das eine Registry, einen Provider-Builder oder ein Dateisystem
injiziert; im normalen Gebrauch kannst du es ignorieren. Siehe
[Die Abhängigkeits-Nahtstelle](#die-abhängigkeits-nahtstelle) dazu, was `deps.fs` erreicht.

## Die Abhängigkeits-Nahtstelle [#die-abhängigkeits-nahtstelle]

<AvailableFrom version="0.9.0" />

`deps.fs` ersetzt den Dateisystem-Port, über den die eigene I/O des SDK läuft, typisiert als
`SdkFs`. Die Nahtstelle ist vollständig: die Run-Status-Datei, die Lock-Datei, die Werkzeuge für die
Glossardatei, Arbeitsmappen- und Austausch-I/O sowie die Locale-Dateien selbst gehen alle darüber, denn die
Format-Adapter lesen und schreiben über einen Port, der aus demselben Objekt gebaut wird. Ein
kompletter Lauf lässt sich damit im Speicher halten. Genau so vermeiden die Tests des SDK die
Platte, und so kann eine einbettende Anwendung einen Teil eines Projekts auf etwas anderem als
einer lokalen Platte ablegen.

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

Zwei Vertragspunkte muss eine Implementierung einhalten. Lesevorgänge sind größenbeschränkt:
`readFileBounded` und `readBytesBounded` nehmen ein Byte-Limit entgegen und melden `missing` oder
`too-large` als Zustand, statt zu werfen. Das hält eine bösartige oder versehentlich riesige Datei
davon ab, den Speicher zu erschöpfen. Schreibvorgänge sollten atomar sein, damit ein Absturz
mitten im Schreiben nie eine halb geschriebene Datei hinterlässt, und `createExclusive` muss atomar
gegenüber anderen Prozessen sein, weil es das Primitiv hinter der Schreibsperre pro Locale ist. Das
SDK ruft vor dem Schreiben einer Locale-Datei, der Laufstatus-Datei oder einer Ausgabedatei wie
einer Arbeitsmappe das optionale `mkdir` mit dem übergeordneten Verzeichnis auf, `writeFile` und
`writeBytes` müssen es also nicht selbst anlegen; lass `mkdir` weg, wenn der Speicher keine
Verzeichnisse kennt. `createExclusive` bekommt keinen solchen Aufruf und legt ein fehlendes
übergeordnetes Verzeichnis selbst an. Die übrigen Pflichtmitglieder sind `fileExists` und
`deleteFile`, das auch gelingt, wenn die Datei schon fehlt.

`readDirectory` ist optional, damit eine Implementierung, die vor der Quellcode-Extraktion
geschrieben wurde, weiterhin kompiliert. [`extract`](https://verbatra.kreitz-webdev.de/de/docs/sdk/inspect.md#extract) ist der einzige Einstiegspunkt, der
es braucht, und meldet sein Fehlen als `EXTRACT_FS_UNSUPPORTED`. Es listet die direkten Einträge
eines Verzeichnisses als `{ name, kind }` auf, wobei `kind` `"file"`, `"directory"` oder `"other"`
ist. Ein symbolischer Link gehört zu `"other"`: Der Scan folgt ihm nie, und genau das hält ihn
innerhalb der übergebenen Roots.

`realpath` ist ebenfalls optional. Es löst jeden symbolischen Link in einem bestehenden Pfad auf,
und die Ausgabe-Guards von [`generateTypes`](https://verbatra.kreitz-webdev.de/de/docs/sdk/run.md#generatetypes), [`exportTmx`](https://verbatra.kreitz-webdev.de/de/docs/sdk/exchange.md#exporttmx),
[`exportWorkbook`](https://verbatra.kreitz-webdev.de/de/docs/sdk/exchange.md#exportworkbook) und [`pseudolocalize`](https://verbatra.kreitz-webdev.de/de/docs/sdk/run.md#pseudolocalize) sehen damit, wo ein Ausgabepfad wirklich landet, sodass ein Link eine geschriebene Datei weder aus `cwd`
hinaus noch auf eine Datei tragen kann, von der das Projekt abhängt. Ohne es vergleichen diese
Guards die Pfade so, wie sie geschrieben sind. Ein Link am Ausgabepfad, dessen Ziel nicht existiert,
lässt sich nicht auflösen, daher gehen die Guards davon aus, dass `writeFile` und `writeBytes`
einen Link dort ersetzen, statt durch ihn hindurch zu schreiben, so wie es der atomare Standard-Schreibvorgang tut.

`rename` ist ebenfalls optional.

<AvailableFrom version="0.12.0" />

Es verschiebt eine Datei in einem atomaren Schritt unter einen neuen Namen im selben Verzeichnis,
ersetzt dabei eine dort schon liegende Datei und scheitert mit einem Fehler, dessen `code` `ENOENT`
ist, wenn die Quelle fehlt. Die Schreibsperre nutzt es, um eine Sperre oder einen Übernahme-Guard
eines beendeten Prozesses zu entfernen und um ihre eigene Sperre freizugeben: Die Datei wird unter
einem eindeutigen Namen `<name>.<pid>.<tag>.<uuid>.stale` beiseitegelegt und nur gelöscht, wenn sie
noch der Eintrag ist, den die Sperre erwartet. Alles andere, etwa eine Sperre, die ein anderer
Prozess inzwischen genommen hat, wird mit `createExclusive` zurückgelegt. Scheitert das Umbenennen
mit `EPERM`, `EBUSY` oder `EACCES`, wie Windows es für eine Datei meldet, die ein anderer Prozess
offen hat, gilt das als "in dieser Abfrage nicht übernommen", und die Sperre fragt bis zu ihrem
Timeout weiter ab. Scheitert das Umbenennen beim Freigeben einer Sperre aus irgendeinem Grund, liest
die Freigabe stattdessen die Sperrdatei und löscht sie, solange sie noch das Token des Halters
enthält, und versucht einen Fehler mit `EPERM`, `EBUSY` oder `EACCES` ein paar Mal im Abstand einer
Abfrage erneut. Ein Eintrag, der zurückgelegt werden müsste, aber nicht zurückgelegt werden kann,
weil ein dritter Prozess den Pfad inzwischen genommen hat, wird gelöscht, da die Token-Prüfung
seines Halters ab dann ohnehin scheitert. Ohne `rename` liest die Sperre die Datei und löscht sie, wenn sie unverändert
ist. Das lässt ein breiteres Zeitfenster offen, in dem eine gerade genommene Sperre eines anderen
Prozesses gelöscht wird.

Beiseitelegen und Zurücklegen sind zwei Schritte, daher können in seltenen Abläufen zwei Prozesse
kurz beide glauben, dieselbe Sperre zu halten. Jede Sperre hält deshalb ein zufälliges Besitz-Token
fest, und vor jedem Schreibvorgang, den die Sperre schützt (die Locale-Datei, die Lock-Datei, die
Provenance-Datei, das Glossar), prüft der Halter, ob die Sperrdatei noch sein Token enthält. Ist das
nicht so, bricht die Operation mit `LOCK_CONTENDED` ab und schreibt nichts. Eine Sperrdatei, die
beim Freigeben fehlt oder ein anderes Token enthält, wird genauso gemeldet, wenn die Operation
erfolgreich war, denn der andere Prozess kann dieselben Dateien seitdem geändert haben; eine
gescheiterte Operation wirft ihren eigenen Fehler. Prüfung und Schreiben bleiben zwei Schritte, und
nichts begrenzt die Zeit dazwischen: Ein Halter, der direkt nach der Prüfung angehalten wird (eine
schlafende Maschine, ein gestoppter Prozess, ein Debugger, ein Sprung der Uhr oder eine lange
blockierte Event-Loop), setzt seinen Heartbeat aus. Sobald seine Sperre drei Intervalle alt ist
(standardmäßig 30 Sekunden), übernimmt ein anderer Prozess auf derselben Maschine sie, und der
angehaltene Halter kann beim Weiterlaufen noch einmal schreiben. Die Token-Prüfung setzt
voraus, dass `createExclusive` eine Datei anlegt, die `readFileBounded` zurückliest; eine eigene
Implementierung muss das einhalten.

`touch` und `mtimeMs` sind ebenfalls optional.

<AvailableFrom version="0.12.0" />

`touch(path)` setzt die Änderungszeit einer vorhandenen Datei auf jetzt, und `mtimeMs(path)` gibt
sie in Millisekunden zurück, oder `undefined`, wenn die Datei fehlt. Mit `touch` hält ein
Sperr-Halter ein Heartbeat-Intervall von 10 Sekunden in seiner Sperre fest und frischt die
Änderungszeit der Datei in diesem Takt auf, solange er die Sperre hält. Direkt nach dem Anlegen
setzt er sie ebenfalls, damit das Alter der Datei nach der Uhr dieses Prozesses gemessen wird und
nicht nach der des Dateiservers. Mit `mtimeMs` behandelt ein
wartender Prozess eine Sperre auf seiner eigenen Maschine als verwaist, deren Prozess noch läuft,
deren Datei aber seit drei der festgehaltenen Intervalle (standardmäßig 30 Sekunden) nicht
aufgefrischt wurde. Das deckt einen Sperreintrag ab, der seinen Halter überlebt hat, etwa einen, der
für einen lange laufenden Studio-, `watch`- oder MCP-Server-Prozess zurückgelegt wurde, der ihn
schon freigegeben hatte. Ein Übernahme-Guard hält keinen Heartbeat fest: Er wird abgeräumt, sobald
sein Prozess nachweislich nicht mehr existiert und er über zwei Abfragen unverändert gesehen wurde. Ohne diese
Member gilt eine Sperre nur dann als verwaist, wenn ihr Prozess nachweislich nicht mehr existiert.

Ein Prozess, der abstürzt, während er eine Sperre beiseitegelegt hat, kann eine `.stale`-Datei in
`.verbatra-local/locks/` hinterlassen. Solche Dateien kannst du gefahrlos von Hand löschen, und der
nächste Erwerb einer Sperre löscht sie, sobald der Prozess, der sie verschoben hat, nicht mehr
existiert oder, wenn `touch` implementiert ist, sobald sie älter als die Veraltungsschwelle sind,
gezählt ab dem Beiseitelegen. Ein Dateisystem ohne `touch` behält sie, bis ihr Verschieber nicht
mehr existiert.

`deps.fs` erreicht keine vom Aufrufer übergebene `deps.adapterRegistry`.
Diese Adapter hat der Aufrufer konstruiert, ihr Dateizugriff ist also genau das, was der Aufrufer
darin verdrahtet hat. Beides zu übergeben heißt, dass der Aufrufer diese Verdrahtung besitzt.

Auch das Laden der Konfiguration liegt außerhalb der Nahtstelle. `loadConfig` und
`loadConfigWithMeta` nehmen keine `deps`: sie lesen die Konfigurationsdatei selbst von der echten
Platte und eine Glossardatei, die die Konfiguration nennt, über ihre eigene Option `fs`, das
`deps.fs` eines späteren Flows erreicht also keinen der beiden Lesevorgänge. Um ein ganzes Projekt
im Speicher zu halten, übergib dasselbe `SdkFs` als `loadConfig({ fs })` und reich die
Konfiguration als `configOverride` statt als Datei herein. Die Flows lösen Pfade gegen ihr eigenes
`cwd` auf, gib es also auch dort mit.

Das Beispiel hält ein ganzes Projekt im Speicher und kommt mit den Pflichtmitgliedern aus. Mit dem
Provider `none` braucht der Lauf keinen Schlüssel und lässt `greeting` für einen Menschen zum
Übersetzen übrig.

```ts title="Beispiel"
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
```
