Skip to content
7 min read · 1,363 words

Specialist Batteries

An agent that can only read text is blind and deaf to everything else a user hands it — a voice memo, a scanned form, a photo. The obvious fix is a multimodal LLM, and that's a fine answer when you have one. But it is not the only answer, and it is not always the right one: multimodal weights are bigger, harder to run on-device, and not every model family ships one. This domain exists for the other answer — a small, narrow model that turns one modality into text, handed to whatever text-only LLM you're already running.

The thesis

1. Multimodality doesn't have to come from a multimodal LLM. A speech-to-text model, an OCR engine, and an image captioner are each far smaller and far more mature at their one job than a general-purpose multimodal chat model is. Chain one in front of a text-only LLM and the agent perceives that modality — the LLM never needs to have been trained on pixels or waveforms at all.

2. Runtime-matched. If your LLM runs in the browser, these run in that same browser tab — no separate service, no second deployment target. All three adapters are proven in Node (native ONNX / WASM Tesseract) and in a real, headed Chromium session on real WebGPU (tests/functional/batteries/specialists/specialists.webgpu.spec.ts). That's not an aspiration — it's the same cross-environment property the Transformers.js LLM battery and the embeddings batteries already commit to.

3. On-device only, ever — and not merely for data sovereignty. Every batteries domain in this section that touches the network (the LLM batteries, the Vector Storage adapters) does so because there's a real, converged wire contract worth abstracting: an OpenAI-compatible chat endpoint, a vector-database query API. The cloud speech-to-text and vision landscape has no such convergence — every vendor's STT/OCR/vision API has its own auth model, its own request shape, its own SDK, and none of them agree on enough to be worth a shared abstraction. So this domain draws the boundary at on-device, full stop: no cloud engine, no HTTP client, no API-key option, on any of the three adapters. That's also what makes this the cost-less path to a fully local agent — zero network calls, zero third-party API bills, for the multimodal half of your agent's perception.

4. No agent integration ships, deliberately. Same posture as the embeddings batteries: the adapter is the whole product. There is no SpecialistTool class, no auto-wiring into TurnRunnerConfig, no forged tool the runner mints on your behalf. You decide whether a specialist becomes a tool the model calls, a step in your own pipeline the model never sees, or a courtesy stash entry another battery reads. The recipes below cover all three.

The three adapters, at a glance

ModalityClassSubpathEngineVerified model(s)
Speech-to-textTransformersJsSttAdapter@nhtio/adk/batteries/specialists/stt/transformers_js@huggingface/transformers automatic-speech-recognitiononnx-community/whisper-base (Node), onnx-community/whisper-tiny.en (browser)
OCRTesseractJsOcrAdapter@nhtio/adk/batteries/specialists/ocr/tesseract_jstesseract.js (WASM, no native binary)eng language pack
Image captioningTransformersJsCaptionAdapter@nhtio/adk/batteries/specialists/caption/transformers_js@huggingface/transformers image-to-textXenova/vit-gpt2-image-captioning

All three: construct-once, preload()/reset()/dispose() lifecycle, eager .unknown(false) option validation, static + instance isAvailable() (always true — none of these have a WebGPU or platform requirement), and the shared BatteryLifecycleHooks boot-progress contract. See each adapter's own page for its full option table, method signature, and engine-specific notes.

Agent-integration recipes

Because no agent wiring ships, here are the three ways a real deployment actually uses one of these adapters.

(a) A BYO tool over the adapter

Wrap the adapter in a Tool (the Bring your own tools pattern) so the model decides when to invoke it — e.g. the model was handed an audio attachment and chooses to transcribe it before answering. This mirrors the media battery's own forge pattern for resolving a media_id to a Media value already visible in the turn:

ts
import { Tool, Media } from '@nhtio/adk'
import { validator } from '@nhtio/validation'
import { TransformersJsSttAdapter } from '@nhtio/adk/batteries/specialists/stt/transformers_js'
import type { DispatchContext } from '@nhtio/adk/types'

const stt = new TransformersJsSttAdapter({ model: 'onnx-community/whisper-base' })

// The same default-resolver convention `forgeMediaTools` uses: scan turn attachments, then
// prior tool-call results, for a Media with the given id.
const resolveMedia = (ctx: DispatchContext, mediaId: string): Media | undefined => {
  for (const message of ctx.turnMessages) {
    for (const attachment of message.attachments ?? []) {
      if (attachment.id === mediaId) return attachment
    }
  }
  for (const toolCall of ctx.turnToolCalls) {
    const results = toolCall.results
    if (Media.isMedia(results) && results.id === mediaId) return results
  }
  return undefined
}

const transcribeAudio = (ctx: DispatchContext) =>
  new Tool({
    name: 'transcribe_audio',
    description: 'Transcribes a previously attached audio clip to text.',
    inputSchema: validator.object({
      media_id: validator.string().description('The id of an audio attachment in this turn').required(),
    }),
    async handler({ media_id }) {
      const media = resolveMedia(ctx, media_id)
      if (!media) return `Error (MEDIA_NOT_FOUND): no audio attachment with id "${media_id}" in this turn.`
      try {
        const { text } = await stt.transcribe(media) // Media satisfies SpecialistMediaLike directly
        return text
      } catch (err) {
        return `Error (STT_FAILED): ${err instanceof Error ? err.message : String(err)}`
      }
    },
  })

The readable Error (CODE): … string convention matches the media battery's forged tools — a failure the model can read and, where sensible, react to (ask the user to re-record, try a different attachment) rather than a thrown exception that aborts the turn.

(b) Direct use outside the tool-call loop

Not every use needs the model in the loop at all. Call the adapter directly from your own dispatchInputPipeline middleware, or from app code before the turn even starts — e.g. transcribe every incoming voice message up front and hand the LLM plain text from the start, with no tool round-trip:

ts
import { TransformersJsSttAdapter } from '@nhtio/adk/batteries/specialists/stt/transformers_js'

const stt = new TransformersJsSttAdapter({ model: 'onnx-community/whisper-base' })

async function ingestVoiceMessage(bytes: Uint8Array): Promise<string> {
  const { text } = await stt.transcribe({ bytes, mimeType: 'audio/wav' })
  return text // hand this straight to your text-only LLM as the user's message content
}

This is the right call whenever the model doesn't need to decide whether to transcribe — the answer is always yes, so skip the round-trip.

(c) A courtesy write to Media.stash

Media.stash is a free-form register (Record<string, MediaStashEntry>, no framework-reserved keys) that typically holds a text description/transcript/caption for a piece of media. The LLM batteries' own UnsupportedMediaPolicy ('fallback-stash' mode) walks a default key list — ['text:transcript', 'text:caption', 'text:description'] — and renders whatever it finds there as the model's substitute for media it can't natively consume:

ts
import { Media } from '@nhtio/adk'
import { TransformersJsCaptionAdapter } from '@nhtio/adk/batteries/specialists/caption/transformers_js'

const caption = new TransformersJsCaptionAdapter({ model: 'Xenova/vit-gpt2-image-captioning' })

async function attachWithCaption(bytes: Uint8Array, mimeType: string): Promise<Media> {
  const { text } = await caption.describe({ bytes, mimeType })
  const media = Media.userAttachment({ kind: 'image', mimeType, filename: 'photo.png', /* … */ })
  media.stash.set('text:caption', { value: text, trustTier: 'first-party', derivedFromMedia: media.id })
  return media
}

media.stash is a Registry instance, not a plain object — it's a controlled-mutation, dot-path key-value store (deep-clone-isolated on every read/write), so entries are written with .set(key, entry), never by assigning or spreading stash itself. Do this once, when the media is first attached, and any downstream text-only battery configured with unsupportedMediaPolicy: 'fallback-stash' picks up the caption automatically — no per-turn tool call, no model decision. See the shared LLM contract for the exact UnsupportedMediaPolicy shapes.

Model choice

  • STT: whisper-tiny/whisper-base are the practical browser picks — small enough for a one-time download, proven in the headed WebGPU matrix. In the browser, pin dtype: 'fp32': the auto-selected quantization fails ONNX session creation for these small Whisper graphs (Missing required scale … DequantizeLinear, from MatMulNBits weights) — fp32 is the known cross-execution-provider combination that works, at the cost of a larger download (tiny/base run ≈150-500MB at fp32 vs. much smaller quantized).
  • Caption: Xenova/vit-gpt2-image-captioning — around 85MB at 8-bit quantization, or noticeably larger (fp32-only in practice for the browser, same reason as STT above) on first download. Produces short, generic captions; see the caption page for why Florence-2-class unified vision models are explicitly out of scope for this adapter's single-purpose describe(): { text } contract, and the createPipeline escape hatch if you need one anyway.
  • OCR: tesseract.js language packs download on first use per language — languages has no default so a deployment never silently fetches a pack it didn't plan for.

Composition: what the pattern is for

A specialist adapter's job ends at text. What that text is for is composition: hand it to a separate, text-only model as ordinary conversation input, and that model grounds its answer on the specialist's output despite never having seen the original bytes. This isn't hypothetical — it's a proven receipt (tests/functional/batteries/specialists/specialist_compose.node.spec.ts):

  • Whisper transcribes speech.wav"The quick brown fox jumps over the lazy dog."
  • Tesseract OCRs sample_ocr.png"HELLO OCR\n123"
  • A separate text-only Llama-3.2-1B, given only the transcript and asked "what animal is mentioned?", answers "Fox" — it never touched the audio; the specialist's text is the only channel it had.

This is the same pattern the on-device batteries showcase calls "senses by committee": three genuinely different models, each blind to what the others do, giving a blind-and-deaf reasoner senses it doesn't natively have. The specialists domain is the dedicated-adapter version of that same idea — narrower, purpose-built classes instead of driving the media pipeline's engines directly.

Where to go next