---
url: 'https://adk.nht.io/batteries/media/dispatch.md'
description: >-
  Ordered-array supply, the one selection rule, multi-hop conversion
  pathfinding, the no-lossy-intermediates guardrail, and selection middleware.
---

# Engine Dispatch

## LLM summary — Media engine dispatch

* Supply: `createMediaPipeline({ engines: [resolverOrInstance, …] })` — a flat ORDERED array. Resolvers run EAGERLY at construction (declarations drive verb narrowing); bundled engines stay cheap because heavy peers lazy-load inside capability methods. Guard failures throw `E_INVALID_MEDIA_PIPELINE_CONFIG` naming the index. Empty array is legal (zero capabilities).
* Selection rule everywhere: capability filter (declarations match the request) → optional `selection: EngineSelectionMiddlewareFn[]` onion (stages may EXCLUDE or REORDER `ctx.candidates`, never add — survivors re-filtered; `ctx.kind` is 'convert' | 'mutate' | 'edit'; ctx.request includes bytes) → first survivor in array order wins.
* Convert additionally computes multi-hop paths (BFS ≤3 hops over the declared format graph, `EXT_TO_MIME` chaining) when no direct edge exists; `convertTargets()` reports everything reachable. Terminal tokens (txt/json/hocr/srt/vtt/md/csv) and virtual tokens (`pcm`, `images`) are path endpoints, never intermediates — a ROUTING rule only, NOT a creation/mutation/advertising gate (`empty:txt` is legal; txt can be appended to and patched; `convert to=txt` works).
* Verbs follow capability supply: no convert provider ⇒ no `convert` verb advertised, `mp.compile('convert to=pdf')` throws do-not-retry, no granular `convert` tool; `sheet.*` verbs require an edit provider (`requires: { capability: 'edit' }`) — installing exceljs is NOT enough, register `exceljsEngine()` (or `sheetjsEngine()`) in the engines array.
* `mp.capabilities` (hasConvert/hasMutate/hasEdit/convertTargets/convert/mutate/edit — the full EngineRegistry) and `mp.engines` make the active set inspectable; `buildEngineRegistry` is exported for standalone dispatch.
* Composition mistakes are machine-checkable: the battery ships an ESLint plugin at `@nhtio/adk/batteries/media/lint` (four rules: shadowed engines, static engine imports, mis-targeted ConvertOptions augmentation, and an uninstalled engine peer dependency).

Engines [declare what they can do](./engines); this page is how the pipeline decides who actually does it.

## Supplying engines: an ordered array

```typescript
const mp = await createMediaPipeline({
  engines: [
    () => import('@nhtio/adk/batteries/media/engines/jimp').then((m) => m.jimpEngine()),
    () => import('@nhtio/adk/batteries/media/engines/tesseract_js').then((m) =>
      m.tesseractJsEngine({ languages: ['eng'] })
    ),
    () => import('@nhtio/adk/batteries/media/engines/soffice').then((m) =>
      m.sofficeEngine({ path: '/usr/bin/soffice', executor, workspace })
    ),
  ],
})
```

The array is **priority order**: when several engines can perform the same transform, the earliest capable one wins. Instances work too (handy in tests).

**Resolvers run eagerly, at construction.** Capability declarations drive which verbs the deployment advertises, so they must be known up front. This costs you nothing real: every bundled engine imports its heavy peer (sharp, tesseract, transformers) lazily *inside* its capability methods, so constructing the pipeline loads only thin wrapper modules. What it buys you: every configuration error surfaces at `createMediaPipeline`, with the index named — not three turns into an agent run.

## How dispatch picks an engine

One rule, applied everywhere:

1. **Capability filter.** Only engines whose declarations match the request are candidates — `from`/`over` match the input MIME, `to`/`encodes` cover the requested output, `ops` cover the requested operations. A declaration can't be argued with and can't be conscripted around.
2. **Selection middleware** (optional, below) may exclude or reorder the candidates.
3. **Array order among survivors.** Always the final word. You can read the engines array and know the default outcome.

And when no single engine has a direct convert edge, the registry **computes a path**: if engine A does ODT→DOCX and engine B does DOCX→PDF, then `convert to=pdf` on an ODT input runs A then B — shortest path wins, capped at three hops, array order breaking ties. `convertTargets()` reports everything *reachable*, so error messages reflect multi-hop reality, not just direct edges.

One guardrail you should know exists: **lossy and virtual formats are path endpoints, never stepping stones.** The pathfinder will not route `docx → txt → anything` (that "conversion" would launder a document through plain text and call the result a document), and it will not auto-chain `audio → pcm → txt` (the transcribe step owns that composition, because a 16 kHz resample has to happen between the two legs). If you're wondering why your creative conversion chain isn't being found — it's probably this rule, and it's protecting you. To be equally clear about what the rule is *not*: it gates routing only. A lossy format as the *destination* is still valid media — `convert to=txt` works, `empty:txt` is creatable, txt can be appended to and patched.

The composition mistakes this model makes possible — a broad engine ahead of a narrow one silently dead-coding it, static engine imports bloating bundles, `ConvertOptions` augmentation aimed at the wrong module, an engine composed without the peer dependency it needs to actually run — are machine-checkable, so the battery ships an [ESLint plugin](../../developer-tools/eslint-rules#battery-scoped-plugins) at `@nhtio/adk/batteries/media/lint` that catches all four at lint time instead of at 2 a.m.

## Selection middleware: when capability isn't the whole story

Here is the problem the middleware exists to solve, made concrete. Register both spreadsheet engines — `exceljsEngine()` and `sheetjsEngine()` — and ask to edit an `.xlsx`. Both engines *truthfully* declare `sheet.update_cells` over xlsx. Both are capable. But they are not interchangeable: ExcelJS preserves the workbook's fonts, fills, and comments through the edit; SheetJS CE strips them (styling is a Pro feature — see [the fleet's two-spreadsheet story](./fleet#two-spreadsheet-engines-the-consumer-picks-the-trade-off)). Which one is "right"?

**There is no right answer the library can pick for you.** If the workbook is a human-authored quarterly report, stripping its formatting is corruption and ExcelJS must win. If it's a machine-generated data dump with no styling to lose, SheetJS's wider format reach is pure upside and there's nothing to protect. The correct engine is a property of *your workload and your bytes*, which the registry cannot see and has no business guessing at. Array order gives you a static default (earliest wins); but when the choice is *content-dependent* — "styled workbooks to ExcelJS, everything else fine on SheetJS" — that's policy, and policy gets the battery's standard seam: a middleware onion.

```typescript
const mp = await createMediaPipeline({
  engines: [exceljs, sheetjs],
  selection: [
    async (ctx, next) => {
      // The decision the library refuses to make for you, made explicitly here:
      // styled workbooks must keep their styling, so they never reach the CE editor.
      if (ctx.kind === 'edit' && looksStyled(ctx.request.bytes)) {
        ctx.candidates = ctx.candidates.filter((e) => e.id !== 'sheetjs')
      }
      await next()
    },
  ],
})
```

This is the canonical reason the seam is shaped the way it is. The registry will not silently route your styled workbook to the styling-stripper, and it will not silently route your plain data dump away from the engine with the wider format support — because both of those "smart defaults" are wrong for someone, and a library that guesses wrong about your data has made a decision you'll discover in production. So it doesn't guess. It hands you the candidates and the bytes, and you encode the rule that's right for *your* workload.

Stages receive the dispatch kind (`'convert' | 'mutate' | 'edit'`), the request (including **bytes** — content-dependent rules need them), and the capable candidates in array order. They may **exclude and reorder. They may not add**: survivors are re-filtered against the original capable set, so a stage cannot conscript an engine the capability filter rejected. Excluding everyone is an honest, named failure ("all capable engines (exceljs, sheetjs) were excluded by selection middleware"), not a silent fallback.

Be clear-eyed about the trade: with stages configured, priority is logic, not data — you can no longer read the engines array alone and know who wins. Most deployments need **zero** stages — if you only register one spreadsheet engine, there's nothing to arbitrate, and array order is a complete answer. Write a stage when you have a real quality or override rule like the one above, keep it cheap (it runs per dispatch, bytes in hand), and keep it deterministic.

## Verbs follow capability supply

With no convert provider, the `convert` verb isn't advertised to models, `mp.compile('convert to=pdf')` throws with a do-not-retry message, and the granular forge surface simply doesn't mint a `convert` tool. Add an engine that declares the capability and all three appear. The `sheet.*` edit verbs follow the same rule, which catches some people coming from earlier versions:

::: info `sheet.*` edits require a registered edit engine
Installing the `exceljs` peer is necessary but not sufficient: the `sheet.*` verbs are advertised only when an edit-capable engine is **registered** in the engines array. This is the same rule every other capability follows — `convert` needs a convert engine in the array, OCR needs tesseract in the array, and editing workbooks needs `exceljsEngine()` (or `sheetjsEngine()`) in the array. There is no special-case "if exceljs is installed, light up sheets automatically" path, because that would be the one capability that bypasses the registry, and a consistent model is worth more than the convenience. Register the engine you installed; the failure message names this fix if you forget. (In earlier internal builds workbook editing was a hidden lazy import that lit up on install alone — if you're carrying that assumption forward, this is the line that changed.)
:::

`mp.capabilities` and `mp.engines` make the active set inspectable, and `buildEngineRegistry` is exported if you want the same dispatch machinery outside a pipeline.

## Read next

* [The Bundled Fleet](./fleet) — who's actually in the array.
* [Generating Media](./generating) — the `EMPTY_MIME` edge dispatch treats like any other conversion.
