Skip to content
4 min read · 835 words

Generation — Recipes

These are complete, working shapes for the ways this domain is meant to be wired in practice. None of them require touching adapter source — every seam here is a public constructor option or a public method.

Recipe A: a BYO Tool that generates and returns Media

The adapter returns plain bytes (GeneratedMediaOutput[]); turning those into a persisted, trust-tiered Media that a ToolCall result carries is your handler's job, using the same storeMediaBytes + Media.toolGenerated pair the BYO Tools guide already documents for a locally-rendered chart:

ts
import { Tool, Media } from '@nhtio/adk'
import { validator } from '@nhtio/validation'
import { OpenAIGenerationAdapter } from '@nhtio/adk/batteries/generation/openai'
import type { DispatchContext } from '@nhtio/adk/types'

const generator = new OpenAIGenerationAdapter({
  model: 'gpt-image-1',
  apiKey: process.env.OPENAI_API_KEY,
})

const generateImage = (ctx: DispatchContext) =>
  new Tool({
    name: 'generate_image',
    description: 'Generates an image from a text prompt.',
    inputSchema: validator.object({
      prompt: validator.string().description('What to draw').required(),
    }),
    async handler({ prompt }) {
      let outputs
      try {
        outputs = await generator.generate(prompt)
      } catch (err) {
        return `Error (GENERATION_FAILED): ${err instanceof Error ? err.message : String(err)}`
      }
      const [output] = outputs
      const reader = await ctx.storeMediaBytes(output.filename ?? 'generated.png', output.bytes)
      return Media.toolGenerated({
        kind: output.kind,
        mimeType: output.mimeType,
        filename: output.filename ?? 'generated.png',
        reader,
      })
    },
  })

ctx.storeMediaBytes(id, bytes) persists the bytes and resolves a MediaReader — it does not itself touch ctx.turnMessages/ctx.turnToolCalls, build a ToolCall, or fire any hooks. Your handler simply returns the Media instance; from there the executor (the Chat Completions battery, or whatever drives your tool loop) is what invokes the handler, takes that returned Media as the tool result, and persists the corresponding ToolCall via ctx.storeToolCall(). The Media lands on ToolCall.results unwrapped — no SpooledArtifact — exactly as byo-tools.md documents for locally-produced media. The adapter and this handler own producing the bytes and the Media; putting that result into a persisted ToolCall is the executor's job, not something storeMediaBytes or the return does on its own.

Recipe B: an edit-tool consuming an inbound Media attachment

GenerationImageInput accepts anything satisfying the GenerationMediaLike duck ({ mimeType, asBytes() }) — a real Media instance qualifies without either battery importing the other's types. That means an edit tool can resolve a Media the same way the specialists recipes resolve one for transcription, and hand it straight to adapter.edit():

ts
import { Tool, Media } from '@nhtio/adk'
import { validator } from '@nhtio/validation'
import { GeminiGenerationAdapter } from '@nhtio/adk/batteries/generation/gemini'
import type { DispatchContext } from '@nhtio/adk/types'

const generator = new GeminiGenerationAdapter({
  model: 'gemini-2.5-flash-image',
  apiKey: process.env.GEMINI_API_KEY,
})

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 editImage = (ctx: DispatchContext) =>
  new Tool({
    name: 'edit_image',
    description: 'Edits a previously attached image per a text instruction.',
    inputSchema: validator.object({
      media_id: validator.string().description('The id of an image attachment in this turn').required(),
      instruction: validator.string().description('What to change').required(),
    }),
    async handler({ media_id, instruction }) {
      const media = resolveMedia(ctx, media_id)
      if (!media) return `Error (MEDIA_NOT_FOUND): no image attachment with id "${media_id}" in this turn.`
      let outputs
      try {
        outputs = await generator.edit([media], instruction) // Media satisfies GenerationMediaLike directly
      } catch (err) {
        return `Error (EDIT_FAILED): ${err instanceof Error ? err.message : String(err)}`
      }
      const [output] = outputs
      const reader = await ctx.storeMediaBytes(`${media_id}-edited.png`, output.bytes)
      return Media.toolGenerated({
        kind: output.kind,
        mimeType: output.mimeType,
        filename: `${media_id}-edited.png`,
        reader,
      })
    },
  })

No adapter-side conversion is needed — generator.edit([media], instruction) passes media straight through toBytes/toGenerationBytes, which reads media.mimeType and calls media.asBytes().

Recipe C: a courtesy Media.stash caption

After generating an image, recording the prompt that produced it as a caption on the Media itself lets a later turn (or a different tool) recover why the image exists without re-reading the tool call history — the same pattern the specialists battery documents for a transcription result:

ts
const reader = await ctx.storeMediaBytes(filename, output.bytes)
const media = Media.toolGenerated({ kind: output.kind, mimeType: output.mimeType, filename, reader })
media.stash.set('text:caption', {
  value: prompt,
  trustTier: 'first-party',
  derivedFromMedia: media.id,
})
return media

media.stash is a Registry instance, not a plain object — write entries with .set(key, entry), never by assigning or spreading stash itself. 'text:caption' is one of the default keys the LLM batteries' 'fallback-stash' UnsupportedMediaPolicy mode already walks (alongside 'text:transcript'/ 'text:description'), so a model that can't natively see images can still be told what one depicts.

Recipe D: running the on-device engine behind isolation

The transformers.js engine's ~2GB download and minutes-per-image latency are exactly what the isolation battery exists to move off your main process. The wiring mirrors the isolation battery's own embeddings recipe verbatim — construct the real, unmodified adapter inside the guest; expose a thin facade host-side:

ts
// generation_isolation_spec.ts
import { defineIsolatedService, method } from '@nhtio/adk/batteries/isolation'
import type { GeneratedMediaOutput } from '@nhtio/adk/batteries/generation/transformers_js'

export const GenerationService = defineIsolatedService({
  name: 'generation',
  methods: {
    generate: method<[prompt: string], GeneratedMediaOutput[]>(),
  },
})
ts
// generation_guest.ts
import { serveIsolated } from '@nhtio/adk/batteries/isolation'
import { TransformersJsGenerationAdapter } from '@nhtio/adk/batteries/generation/transformers_js'
import { GenerationService } from './generation_isolation_spec'

const adapter = new TransformersJsGenerationAdapter({ model: 'onnx-community/Janus-Pro-1B-ONNX' })

serveIsolated(GenerationService, () => ({
  generate: (prompt) => adapter.generate(prompt),
}))
ts
// generation_isolated_pipeline.ts
import { forkIsolated } from '@nhtio/adk/batteries/isolation/child_process'
import { GenerationService } from './generation_isolation_spec'

export const createIsolatedGenerationPipeline = () => {
  const service = forkIsolated(GenerationService, { modulePath: './generation_guest.js' })
  return {
    generate: (prompt: string) => service.api.generate(prompt),
    dispose: () => service.dispose(),
  }
}

The model download, the ONNX runtime, and every byte of compute live entirely in the child process — a crash there never touches the host, and service.onCrash(...)/autoRespawn apply exactly as they do for any other isolated service. edit() is deliberately omitted from GenerationService's method surface here since this engine always throws on it — expose it only if your guest facade wraps a different engine.

Same seam, no adapter changes

As with the isolation battery's embeddings recipe, the isolation boundary sits entirely outside TransformersJsGenerationAdapter's own code — new TransformersJsGenerationAdapter(options) then .generate() is unchanged whether it runs in-process or inside a forked child.

Recipe E: live-testing against real engines

The domain's own .cross.spec.ts live specs are gated on environment variables (see .env.test.example):

VariableEngine
TEST_GENERATION_OPENAI_API_KEY / _BASE_URL / _MODELOpenAI-shaped engine
TEST_GENERATION_GEMINI_API_KEY / _BASE_URL / _MODELGemini engine

LB polyglot routing

Both live specs point _BASE_URL at this repo's own LLM-LB gateway rather than the vendor's API directly, and both request a Gemini-family model (gemini-2.5-flash-image) even through the OpenAI-shaped spec — the gateway translates the OpenAI-shaped request into whatever the underlying model needs. Auth in that topology goes through headers: { Authorization: 'Bearer ' + apiKey } rather than each adapter's own apiKey option (x-goog-api-key for Gemini, Authorization: Bearer natively for OpenAI) — the LB expects one uniform Bearer scheme regardless of which adapter is calling it.

The OpenAI-shaped edit() gateway gap

The OpenAI live spec only exercises generate() — probing this gateway's /v1/images/edits route returned a 404 (the gateway does not implement OpenAI-shaped image edits, only generations). The Gemini live spec exercises both generate() and edit() successfully through the same gateway. If you need edits through a gateway, verify the OpenAI-shaped route directly against your gateway before depending on it, or route edits through the Gemini engine instead.

Where to go next