---
url: 'https://adk.nht.io/batteries/generation/recipes.md'
description: >-
  End-to-end recipes for wiring the Generation battery into a real deployment:
  BYO Tool generate, edit consuming inbound Media, Media.stash captions,
  isolation, live-testing.
---

# Generation — Recipes

## LLM summary — Generation recipes

* (a) BYO Tool that generates an image: adapter `.generate()` → `ctx.storeMediaBytes(id, bytes)` →
  `Media.toolGenerated({...})` → return the `Media` from the `Tool` handler. The executor invokes the
  handler and persists the `ToolCall` (via `ctx.storeToolCall()`); the returned `Media` lands on
  `ToolCall.results` unwrapped, no `SpooledArtifact` — same contract as [byo-tools.md](/assembly/byo-tools).
* (b) An edit-tool that resolves an inbound `Media` attachment by id from `ctx.turnMessages`/
  `ctx.turnToolCalls`, passes it straight to `adapter.edit([media], prompt)` — a real `Media` instance
  satisfies `GenerationImageInput`'s `GenerationMediaLike` duck (`{ mimeType, asBytes() }`) with zero
  import coupling.
* (c) A courtesy `media.stash.set('text:caption', {...})` write recording the prompt that produced an
  image — mirrors the specialists battery's stash-write recipe; `stash` is a `Registry`, mutate only via
  `.set()`.
* (d) Running the transformers.js engine behind `forkIsolated` — construct the real
  `TransformersJsGenerationAdapter` inside the guest, expose a thin `generate`/`edit` facade host-side;
  zero adapter changes, same shape as the isolation battery's own embeddings recipe.
* (e) Live-testing: `TEST_GENERATION_OPENAI_*`/`TEST_GENERATION_GEMINI_*` env vars, and the LB-gateway
  auth-header pattern (`Authorization: Bearer` instead of the adapter's native `apiKey`/`x-goog-api-key`).

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](/assembly/byo-tools) 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](/batteries/isolation/) 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.

::: tip 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`):

| Variable | Engine |
| --- | --- |
| `TEST_GENERATION_OPENAI_API_KEY` / `_BASE_URL` / `_MODEL` | OpenAI-shaped engine |
| `TEST_GENERATION_GEMINI_API_KEY` / `_BASE_URL` / `_MODEL` | Gemini engine |

::: tip 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.
:::

::: warning 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

* [Index](./) — the shared contract and engine matrix.
* [OpenAI](./openai) / [Gemini](./gemini) / [Transformers.js](./transformers_js) — full per-engine reference.
* [BYO Tools](/assembly/byo-tools) — the `Tool` construction contract these recipes build on.
* [Isolation battery](/batteries/isolation/) — the wrapper Recipe D depends on.
* [Assembly → Generation batteries](/assembly/batteries-generation) — full option/exception surface, all three engines.
