Docs / Guides
Write a custom format adapter
Ship a format verbatra does not: name it with a custom: identifier, build it on an adapter factory, and learn what you trust when you install one.
Use this when your locale files are in a format verbatra does not read out of the box (see Formats). You build the adapter in your own package, name the format with a custom: identifier, and hand verbatra a registry that holds it. Nothing is published to verbatra and no release is involved.
You need @verbatra/sdk in the project. The verbatra command line loads no plugin by design (see What you are trusting), so a project with a custom format runs verbatra through the SDK.
Name the format
A format from outside verbatra is custom: followed by a lowercase, hyphen-separated name, such as custom:toml or custom:my-format. No built-in name contains a colon, so it can never shadow one. Put it in the config like any built-in name:
{
"sourceLocale": "en",
"targetLocales": ["de"],
"format": "custom:toml",
"files": { "pattern": "locales/{locale}.toml" },
"provider": { "id": "gemini", "options": { "model": "gemini-2.5-flash", "maxOutputTokens": 4096 } }
}The config only checks the identifier's shape, because the adapter lives in your package. If nothing supplies an adapter for it, the run fails with UNKNOWN_. Studio and the MCP server's project.snapshot report the identifier verbatim, so an agent can tell a custom format from a built-in one.
Build the adapter
Do not implement the adapter contract by hand. Two factories supply the bounded read, the atomic write, extension detection and structured errors, and leave you only your format's parsing.
If every entry is addressed by one key with no nesting, use createFlatFileAdapter:
import { createFlatFileAdapter, type FormatAdapter } from "@verbatra/sdk";
const TOKEN = /\{[a-z]+\}/g;
const tokensIn = (value: string): readonly string[] =>
[...value.matchAll(TOKEN)].map((match) => match[0]);
export const tomlAdapter: FormatAdapter = createFlatFileAdapter({
format: "custom:toml",
extensions: [".toml"],
parseEntries: (content, namespace) => parseToml(content, namespace),
serializeEntries: (entries) => serializeToml(entries),
extractPlaceholders: tokensIn,
});If entries live at paths through nested objects, use createTreeFileAdapter, which takes parse and serialize over a tree plus a deriveEntry for each leaf.
Then, as your format needs:
- If your extension is a generic one, give the factory a
sniffthat checks a content sample. Without it your adapter claims every file with that extension, and detection reports an ambiguity. - If one file holds both source and target text, or records its locale, read the extra
localeargument thatparseEntriesandserializeEntriesreceive. - If a flat token list would lose your placeholder structure, add
comparePlaceholders; if your format has plural or select branches, addcompareBranchArms. - If your format marks content untranslatable, return
{ entries, excludedLeafPaths }fromparseEntriesrather than dropping it. - Read and write through the
fsport the factory hands you,nodeAdapterFsby default, nevernode:fsdirectly. That keeps the bounded read and the atomic write, and lets you test the adapter without a disk.
Register it and run
Start from the built-in registry so your format joins the others, then pass it as the adapterRegistry dependency that every SDK flow accepts:
import { createDefaultRegistry, translate, loadConfig } from "@verbatra/sdk";
import { tomlAdapter } from "./toml-adapter.js";
const config = await loadConfig({ cwd: process.cwd() });
const adapterRegistry = createDefaultRegistry().register(tomlAdapter);
const summary = await translate({ config }, { adapterRegistry });register returns the registry, so registrations chain. It refuses a format the registry already holds with DUPLICATE_ and a malformed identifier such as custom:TOML with INVALID_, so two plugins that picked the same name fail at startup.
Raise the right errors
A custom: adapter is wrapped when it is registered, so an unexpected throw from it surfaces as ADAPTER_, naming your format and the method rather than blaming verbatra. The message never repeats yours, which could quote the file; read your original error from cause.
Two kinds of error pass through unchanged:
- An
AdapterErroryou raise yourself keeps its code. RaiseINVALID_for content your format cannot represent.STRUCTURE - An error carrying an errno code such as
ENOENTkeeps it, so a missing file is still reported as one. The check looks at the error's shape only, so do not put an errno code on your own validation errors.
What you are trusting
A format adapter is fully trusted code, at the same trust level as any package you install. verbatra does not sandbox it.
An adapter can read process.env, where every provider key lives, read and write any file the process can, and open network connections. The fs port is a convention, not a boundary.
The adapter also decides what counts as a placeholder. One that reports none makes the placeholder integrity check pass for its format, so a translation that mangled an interpolation ships silently, whether the adapter is buggy or hostile.
What verbatra does guarantee is that loading is never implicit: it discovers no plugin, scans no directory, installs nothing and follows no naming convention. An adapter runs only because your code imported it and handed verbatra the registry. Treat one like any dependency that reads your secrets: read it, pin it, and review each update.
Related
- Adapters, providers, redaction: every factory option and export.
- Format adapter error codes: what each refusal means.
- Formats: the formats verbatra ships.