BYO Runtime & Custom Engines
Everything on this page is a seam: the typed options bag your engines read, the two contracts that keep "run a binary" from hardcoding Node, and the engine contract itself. (The capability shapes are The Capability Model; how engines are dispatched is Engine Dispatch; the bundled implementations are The Bundled Fleet.)
Typed options, BYO keys
Capability-specific inputs — OCR languages, transcription language/translate, the preferred extraction encoding — ride ConvertRequest.options. The bag is one typed, augmentable interface, not a Record<string, unknown> free-for-all: a typo'd langauges at a literal call site is a compile error, and your own engine's options merge in through declaration merging:
declare module '@nhtio/adk/batteries/media/contracts' {
interface ConvertOptions {
watermark?: { text: string }
}
}
// now fully typed at every call site:
await mp.query(payload, 'convert to=pdf', /* options threaded by your step/engine */)The runtime stays open — multi-hop conversion forwards one options bag to every hop, and engines must ignore keys they don't understand. The namespace is flat and globally merged, so name BYO keys to avoid collisions (prefix by engine where ambiguous). The bundled conventions: languages (OCR), lang/translate (transcription), format (embedded-image extraction's native-encode hint). Augmenting any module other than …/contracts compiles and silently never merges — the battery's ESLint plugin catches exactly that.
The runtime contracts: BinaryExecutor and ScratchWorkspace
Binary-backed engines need two things no web standard provides: run a process and exchange bytes via paths that process can open. The lazy answer is to hardcode child_process and os.tmpdir() and quietly make the whole battery Node-only. We didn't, and not out of politeness — a hardcoded runtime assumption is an architectural decision made on your behalf, which is the exact thing this library exists to not do. So both are BYO contracts:
interface BinaryExecutor {
exec(invocation: { cmd: string; args: string[]; timeoutMs?: number; signal?: AbortSignal })
: Promise<{ exitCode: number; stdout: string; stderr: string; failed: boolean }>
}
interface ScratchWorkspace {
materialize(bytes: Uint8Array, filename: string): Promise<string> // → absolute path
read(path: string): Promise<Uint8Array>
dir(): string
list(): Promise<string[]>
dispose(): Promise<void>
}The bundled pair runs processes locally and stages bytes on the local filesystem. But the contracts admit anything whose paths line up: a remote runner with a shared volume, a sandbox, a container shim, a browser-side WASI executor if someone builds one. The executor and workspace must agree on path visibility — a workspace writing to /var/tmp paired with an executor running in a container that can't see /var/tmp will fail, the failure is yours, and no amount of library code can make two components you chose agree about a filesystem they don't share. That pairing is your composition decision. We say this bluntly because the alternative is you discovering it in production.
import { execaExecutor } from '@nhtio/adk/batteries/media/engines/execa_executor'
import { fsScratchWorkspace } from '@nhtio/adk/batteries/media/engines/fs_workspace'
import { sofficeEngine } from '@nhtio/adk/batteries/media/engines/soffice'
const soffice = sofficeEngine({
path: '/usr/bin/soffice',
executor: execaExecutor(),
workspace: fsScratchWorkspace({ root: '/var/tmp/adk-media' }),
})Writing your own engine
Declare what you do; implement it. The smallest useful example — OCR backed by something you already run:
import type { MediaEngine } from '@nhtio/adk/batteries/media/contracts'
const myOcr: MediaEngine = {
id: 'my-ocr',
converts: [
{
from: ['image/*'],
to: ['txt', 'json'],
async convert({ bytes, to, options }) {
const text = await myExistingOcrService.read(bytes, { languages: options?.languages })
const body = to === 'json' ? JSON.stringify({ text }) : text
return { outputs: [{ bytes: new TextEncoder().encode(body), mimeType: 'text/plain' }] }
},
},
],
}
const mp = await createMediaPipeline({ engines: [myOcr] })The same shape covers the other capabilities: declare mutates for fused image transforms, edits for structural document ops, or from: [EMPTY_MIME] converts to add a generation edge — a diffusion or TTS engine is exactly this example with a prompt read from options. PCM-producing engines use PCM_MIME + pcmToBytes/bytesToPcm and must report meta.sampleRate on their outputs.
Yes, that engine could call an external service. We're not going to stop you, and we're not going to pretend the seam can't be used that way — the data-sovereignty posture was never "external is impossible," it's "external is a decision you make, visibly, at the composition site, where a code review can ask you about it." The battery's defaults never will.
Read next
- The Bundled Fleet — what's already implemented before you write anything.
- Agent Tools — putting the pipeline in front of a model.