---
title: "Write a custom format adapter"
description: "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."
type: "how-to"
---
# 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.

<AvailableFrom version="0.11.0" />

Use this when your locale files are in a format verbatra does not read out of the box (see [Formats](https://verbatra.kreitz-webdev.de/docs/formats.md)). 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](#what-you-are-trusting)), so a project with a custom format runs verbatra through the SDK.

## Name the format [#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:

```json title=".verbatrarc.json"
{
  "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_FORMAT`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#unknown_format). 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 [#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`](https://verbatra.kreitz-webdev.de/docs/sdk/extend.md#createflatfileadapter):

```ts title="toml-adapter.ts"
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`](https://verbatra.kreitz-webdev.de/docs/sdk/extend.md#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 `sniff` that 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 `locale` argument that `parseEntries` and `serializeEntries` receive.
- If a flat token list would lose your placeholder structure, add `comparePlaceholders`; if your format has plural or select branches, add `compareBranchArms`.
- If your format marks content untranslatable, return `{ entries, excludedLeafPaths }` from `parseEntries` rather than dropping it.
- Read and write through the `fs` port the factory hands you, [`nodeAdapterFs`](https://verbatra.kreitz-webdev.de/docs/sdk/extend.md#nodeadapterfs) by default, never `node:fs` directly. That keeps the bounded read and the atomic write, and lets you test the adapter without a disk.

## Register it and run [#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:

```ts title="translate.ts"
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_FORMAT`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#duplicate_format) and a malformed identifier such as `custom:TOML` with [`INVALID_FORMAT_ID`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#invalid_format_id), so two plugins that picked the same name fail at startup.

## Raise the right errors [#raise-the-right-errors]

A `custom:` adapter is wrapped when it is registered, so an unexpected throw from it surfaces as [`ADAPTER_FAILED`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#adapter_failed), 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 `AdapterError` you raise yourself keeps its code. Raise [`INVALID_STRUCTURE`](https://verbatra.kreitz-webdev.de/docs/error-codes.md#invalid_structure) for content your format cannot represent.
- An error carrying an errno code such as `ENOENT` keeps 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 [#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](https://verbatra.kreitz-webdev.de/docs/translation-safety.md) 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 [#related]

- [Adapters, providers, redaction](https://verbatra.kreitz-webdev.de/docs/sdk/extend.md): every factory option and export.
- [Format adapter error codes](https://verbatra.kreitz-webdev.de/docs/error-codes.md#adapter-error-codes): what each refusal means.
- [Formats](https://verbatra.kreitz-webdev.de/docs/formats.md): the formats verbatra ships.
