---
url: 'https://adk.nht.io/batteries/media/runtime.md'
description: >-
  Typed ConvertOptions augmentation, the BinaryExecutor and ScratchWorkspace
  contracts behind binary-backed engines, and writing your own engine.
---

# BYO Runtime & Custom Engines

## LLM summary — Media runtime contracts + custom engines

* `ConvertRequest.options` is the typed augmentable `ConvertOptions` interface (merging OcrConvertOptions `{languages}`, AsrConvertOptions `{lang, translate}`, ImagesConvertOptions `{format}`); consumers BYO keys via `declare module '@nhtio/adk/batteries/media/contracts'` — augmenting any other module silently never merges (the lint plugin catches it). Runtime stays open: multi-hop forwards one bag to every hop; engines ignore unknown keys.
* Binary-backed engines compose two BYO contracts: `BinaryExecutor { exec({cmd, args, timeoutMs?, signal?}) → {exitCode, stdout, stderr, failed} }` and `ScratchWorkspace { materialize(bytes, filename) → path; read(path); dir(); list(); dispose() }` (factory: `ScratchWorkspaceFactory` — one workspace per invocation). Bundled: `engines/execa_executor` (local child processes), `engines/fs_workspace` (`fsScratchWorkspace({ root })` — root REQUIRED). Executor and workspace MUST agree on path visibility — that pairing is the consumer's composition decision.
* Custom engine: implement `MediaEngine` (`{ id, converts?, mutates?, edits? }`), declare capabilities as plain data, supply in the engines array. Duck-typed guards validate at construction. PCM transport: `PCM_MIME` + `pcmToBytes`/`bytesToPcm`; pcm outputs must set `meta.sampleRate`. Generation: declare `from: [EMPTY_MIME]`.
* External services through a custom engine are possible BY DESIGN — visible at the composition site, never a default.

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](./engines); how engines are dispatched is [Engine Dispatch](./dispatch); the bundled implementations are [The Bundled Fleet](./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:

```typescript
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](../../developer-tools/eslint-rules#battery-scoped-plugins) 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:

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

```typescript
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:

```typescript
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](./generating) 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](./fleet) — what's already implemented before you write anything.
* [Agent Tools](./forge) — putting the pipeline in front of a model.
