Image Workflows

glove-image makes image generation a workflow rather than a single call. An agent gets durable characters and scenes, a prompt pipeline it does not have to assemble by hand, reference images with roles, deterministic assembly, eyes to check its own output, and spend accounting on every call. The image model itself is an adapter you bring.

terminalbash
pnpm add glove-image

If you would rather see it than read it, the image gallery is a worked campaign — every frame shown with the prompt that produced it, the pipeline trace, and what it cost, plus a canvas diagramming how one image was built.

Why not just one generate_image tool

The usual shape is a single tool that takes a prompt string and returns a URL. It works exactly once. The moment the work is a workflow, four things break:

  • Prompts are built, not typed. The prompt that actually works is the user's intent plus house style, plus the character's canonical description, plus the scene's palette, plus a model-specific rewrite. That is a pipeline with stages, and inlining it loses every intermediate state — including the reason the final prompt looks the way it does.
  • Characters drift. “Draw Mira again, but at the harbor” only works if Mira is a durable thing — a description, reference images, a negative list — and not a phrase the model half-remembers from six turns ago.
  • Scenes are settings, not sentences. The same neon market should look like the same neon market across ten generations.
  • Existing images are inputs. Users bring photos, earlier generations become references, results get composited into sheets and storyboards. Image bytes need a home that is not the context window.

Each of those becomes a named primitive here, with a storage seam, following the same posture as the rest of the stack: adapter contracts you implement, reference in-memory adapters for dev, one mountImage that folds the tools, Zod schemas throughout.

When to use it

  • The app generates images repeatedly with recurring subjects, styles, or settings — character art, storyboards, product shots, brand assets.
  • You want prompt construction to be inspectable and composable rather than a template string.
  • Users bring their own images in, and outputs feed back in as inputs.
  • You need to know what the generation actually cost.

If you need exactly one “make me a picture” tool, a hand-rolled glove.fold(...) around your provider is still simpler. glove-image earns its keep when generation is a workflow.

Mental model

Four pieces, deliberately separated:

PieceWhat it isContract
AssetsEvery image the workflow touches — imported, generated, edited, assembled — stored with metadata and lineage. Bytes never enter model context; the model works with asset ids.ImageAssetStore
LibraryDurable characters and scenes, curated by the agent or the host app, referenced by name in generation calls.ImageLibraryAdapter
PipelineAn ordered list of inbetweens that turn a raw intent into the final request — expanding characters and scenes, injecting style, running an LLM rewrite — each stage recorded in a trace.PromptEnhancer[]
ModelThe image model behind a capability-declaring adapter — generate, edit, variations.ImageModelAdapter
flowtext
intent + { characters, scene, refs }


  Prompt pipeline (inbetweens, in order)
    expandCharacters → expandScenes → styleDirective → llmEnhance → fitToModel
        │                                  each stage appends to draft.trace

  ImageModelAdapter.generate(request)


  candidates → ImageAssetStore (with Recipe lineage + usage)

Quickstart

Build the agent as normal, then mount the image surface before or after build() — same convention as mountMcp and mountMesh.

studio.tstypescript
import { Glove, Displaymanager, MemoryStore, createAdapter } from "glove-core";
import {
  mountImage,
  InMemoryImageAssetStore,
  InMemoryImageLibrary,
  expandCharacters,
  expandScenes,
  styleDirective,
  llmEnhance,
  openrouterImages,
} from "glove-image";

const glove = new Glove({
  store: new MemoryStore("studio"),
  model: createAdapter({ provider: "anthropic" }),
  displayManager: new Displaymanager(),
  systemPrompt: "You are an art director. Use the image tools to create and refine images.",
  compaction_config: { compaction_instructions: "Summarize the art direction so far." },
  serverMode: true,
});

await mountImage(glove, {
  // The image model — reads OPENROUTER_API_KEY, defaults to google/gemini-2.5-flash-image
  adapter: openrouterImages(),

  // Storage seams — swap for your own in production
  assets: new InMemoryImageAssetStore(),
  library: new InMemoryImageLibrary(),

  // An LLM slot the pipeline's rewrite pass uses
  model: createAdapter({ provider: "openrouter", model: "openai/gpt-4o-mini", stream: false }),

  // The middle of the pipeline. fitToModel() is always appended.
  pipeline: [
    expandCharacters(),
    expandScenes(),
    styleDirective("hand-painted gouache, muted palette, soft rim light"),
    llmEnhance({ instructions: "Tighten composition language." }),
  ],
});

glove.build();

await glove.processRequest(
  "Create a character called Mira — a wiry sky-courier in her 20s with a patched flight jacket. " +
    "Then draw her landing at a neon night market.",
);
// Agent: glove_image_character_save({ name: "mira", ... })
//        glove_image_scene_save({ name: "neon-market", ... })
//        glove_image_generate({ intent: "Mira landing", characters: ["mira"], scene: "neon-market" })

The prompt pipeline

This is the spine of the package. A generation call never sends the model's raw text to the image model. It builds a PromptDraft and runs it through the configured inbetweens in order. Each inbetween is a small named transform; each appends to a trace, so the final request is fully explainable — and every degradation is visible rather than silent.

typestypescript
interface PromptDraft {
  intent: string;                   // the original ask — never mutated
  positive: string;                 // the working prompt
  negative?: string;
  refs: RefImage[];                 // accumulated reference images
  params: GenerationParams;         // { size?, seed?, candidates?, extra? }
  requested: { characters: string[]; scene?: string };  // names from the call
  characters: CharacterDef[];       // resolved by expandCharacters()
  scene?: SceneDef;                 // resolved by expandScenes()
  trace: TraceEntry[];              // one entry per stage
}

interface PromptEnhancer {
  name: string;
  run(draft: PromptDraft, ctx: EnhancerContext): Promise<PromptDraft | void>;
}

interface EnhancerContext {
  library: ImageLibraryReader;           // read-only character/scene lookup
  assets: Pick<ImageAssetStore, "get" | "list">;
  model?: ModelAdapter;                  // the mount's LLM slot
  capabilities: ImageModelCapabilities;  // what the target model supports
  note(message: string): void;           // explain what this stage did
  recordUsage(usage: Partial<ImageUsage>): void;  // report model spend
  signal?: AbortSignal;
}

Built-in inbetweens

InbetweenWhat it does
expandCharacters()For each name in the call's characters, loads the library record, splices its canonical appearance block into the prompt, merges its negative, and attaches its reference images as identity refs. A missing name is a clear error naming what is available — never a silent skip.
expandScenes()The same for the call's scene — setting, palette, lighting, mood, plus composition and style refs.
styleDirective(text)Appends a fixed house-style clause. The dumb, reliable one.
negativeDefaults(list)Merges a standing negative list (“extra fingers, watermark”) without clobbering per-call negatives or duplicating entries.
llmEnhance({ model?, instructions? })One LLM rewrite pass over the working prompt. The contract is strict: preserve character-appearance wording verbatim (identity consistency dies in paraphrase) and return only the rewritten prompt. Uses the mount's model unless given its own, and skips with a trace note when neither exists.
fitToModel()Terminal, always appended automatically. Clamps the draft to the adapter's declared capabilities — folds negative into the prompt as an “Avoid:” clause when the model has no negative slot, drops refs whose roles are unsupported, clamps ref count (identity refs survive first), snaps size to a supported value, clamps candidates, drops an unsupported seed.

Ordering is yours. The default pipeline is [expandCharacters(), expandScenes()]; anything you pass to mountImage replaces the middle, and fitToModel() runs last whether or not you list it.

Every degradation is traced

A model that silently gets fewer reference images than it asked for produces a confusing result and no explanation. fitToModel() writes each adjustment into the trace, and the tool result hands those notes back to the agent as degradations:

tool result (data)json
{
  "assets": [{ "id": "img_124dea9c7c51", "width": 1024, "height": 1024 }],
  "degradations": [
    "expand-characters: Expanded 1 character(s).",
    "expand-scenes: Expanded scene \"neon-market\".",
    "llm-enhance: Rewritten by LLM pass.",
    "fit-to-model: No negative-prompt slot — folded into the prompt as an Avoid clause."
  ],
  "usage": { "requests": 1, "tokens_in": 9, "tokens_out": 1301, "cost_usd": 0.0387302 }
}

Writing your own inbetween

It is a two-property object. A brand-system lookup, a watermark policy, a translation pass, a seasonal palette — all the same shape:

brand.tstypescript
import type { PromptEnhancer } from "glove-image";

export function brandPalette(brand: string): PromptEnhancer {
  return {
    name: "brand-palette",
    async run(draft, ctx) {
      const tokens = await lookupBrandTokens(brand);   // your system
      draft.positive = `${draft.positive}\n\nPalette: ${tokens.palette.join(", ")}`;
      ctx.note(`Applied ${brand} palette.`);
    },
  };
}

Enhancer names must be unique — mountImage throws on duplicates rather than letting two stages quietly share a trace line.

Why arguments, not inline syntax

Characters and scenes are referenced through tool arguments (characters: ["mira"]), never parsed out of prose. @ is already Glove's subagent routing signal, / is the extension trigger, and inline {{character:mira}} templating puts a parser between the model and its own prompt. The tool schema is the interface; the model reads the library with the list tools and passes names.

Characters

A character is a durable identity: wording that must stay stable, images that anchor likeness, and negatives that fence off drift.

typestypescript
interface CharacterDef {
  name: string;              // library key, kebab-case ("mira")
  display_name?: string;
  /** One-paragraph canonical appearance. Spliced VERBATIM into prompts. */
  appearance: string;
  /** Non-visual notes for the agent. NEVER sent to the image model. */
  notes?: string;
  negative?: string;         // e.g. "no goggles, never smiling"
  ref_images?: Array<{ asset: string; label?: string }>;  // identity anchors
  tags?: string[];
  created_at: string;
  updated_at: string;
}

Three rules the tools enforce:

  • appearance is prompt text, owned by the library. expandCharacters() splices it verbatim and llmEnhance is instructed not to reword it. Consistency comes from repetition, not from the model remembering.
  • Ref images are assets. A character's reference images live in the asset store like everything else, so promoting a good generation to a character ref is one glove_image_character_save call with the asset id — the canonical “lock in this look” move.
  • notes never reach the image model. Personality belongs to the agent's reasoning, not the prompt.
agent transcripttext
User: Mira should always have a scar over her left eyebrow.

Agent → glove_image_character_save({
  name: "mira",
  appearance: "a wiry sky-courier in her mid-20s, short windswept black hair,
    a thin scar over the left eyebrow, patched olive flight jacket with brass buckles",
  negative: "no goggles",
})

# Every later generation that names "mira" now carries the scar, word for word.

Scenes

Same shape, pointed at settings. A scene holds the location, era, palette, lighting and mood as one prompt-ready block, plus optional style and composition references.

typestypescript
interface SceneDef {
  name: string;
  display_name?: string;
  /** Canonical setting block. Prompt-ready, spliced verbatim. */
  setting: string;
  negative?: string;
  ref_images?: Array<{ asset: string; role: "style" | "composition"; label?: string }>;
  tags?: string[];
  created_at: string;
  updated_at: string;
}

Characters and scenes are orthogonal: any character can appear in any scene, and both splice into the same draft. Negatives from both merge without duplicating entries.

Bringing images in

Three distinct doors, because “use this image” means three different things:

  1. Import. glove_image_import takes an http(s) URL, a data: URL, or raw base64 and lands it in the asset store as a first-class asset. Format and dimensions are sniffed from the bytes (PNG, JPEG, GIF, WebP) — no image library needed to catalog it.
  2. Reference. Any asset can ride a generation call as a RefImage with a role. Adapters declare which roles they honour and fitToModel() reconciles.
  3. Assemble. Deterministic compositing of existing assets into one image, with no model call at all — see below.
RoleMeaning
identityThis face / this likeness. Survives ref clamping first.
styleThis look — brushwork, grade, era.
compositionThis framing and layout.
contentImage-to-image base to transform.
maskEdit region — white is editable, black stays.

Assembly

Contact sheets, storyboard grids, side-by-sides and layered comps are not generation problems — they are compositing problems, and a model should not be guessing at them. glove_image_assemble paints existing assets onto a canvas deterministically:

tool calltypescript
glove_image_assemble({
  canvas: { width: 2100, height: 1100, background: "#111111" },
  layers: [
    { asset: "img_124dea9c7c51", x: 20,   y: 50, width: 1000, height: 1000, fit: "contain" },
    { asset: "img_2a5f08e54493", x: 1060, y: 50, width: 1000, height: 1000, fit: "contain" },
  ],
  name: "before-after",
})

Layers paint in order (first at the bottom) and support fit, rotate and opacity. Backed by sharp as an optional peer — the tool refuses with an install hint when sharp is absent and the rest of the package keeps working. Apps already running glove-working-environment with env:images can do arbitrarily fancier pixel work there; AssemblySpec covers the declarative 90% in one call.

Generative assembly — “put Mira into this photo” — is not assembly. That is glove_image_edit with content and mask refs.

Lineage and regeneration

Every generated, edited or assembled asset records how it was made. That record is what makes “same but at dusk” a single call rather than a re-derivation:

typestypescript
interface Recipe {
  kind: "generated" | "edited" | "assembled";
  intent?: string;          // the raw ask, untouched
  finalPrompt?: string;     // what actually went to the model
  negative?: string;
  params?: GenerationParams;
  adapter?: string;         // which ImageModelAdapter
  characters?: string[];    // library names as requested
  scene?: string;
  refs?: Array<{ asset: string; role: RefRole }>;
  trace?: TraceEntry[];     // the full pipeline trace
  parent?: string;          // for "edited": the source asset
  spec?: AssemblySpec;      // for "assembled"
  usage?: ImageUsage;       // what this asset cost to make
}
tool calltypescript
glove_image_regenerate({ asset: "img_124dea9c7c51", tweak: "at dusk" })
// Replays the recipe through the CURRENT pipeline, with the tweak appended
// to the original intent. Library edits since the first run are picked up —
// fix a character's appearance once and regenerate everything that used it.

Giving the agent eyes

A model cannot see what it generated unless you hand it a vision model. mountImage takes one in the review slot — any vision-capable ModelAdapter, the same type as the agent's own model — and it powers two things.

studio.tstypescript
await mountImage(glove, {
  adapter: openrouterImages(),
  assets, library,
  review: {
    vision: createAdapter({ provider: "openrouter", model: "openai/gpt-4o-mini", stream: false }),
    rounds: 1,                    // max refine rounds after the first generation; 0 = critique off
    rubric: "The character must match the appearance block. Flag anatomy errors.",
  },
});

1. glove_image_describe gains a visual description. The context-safe way to look at an asset — bytes stay in the store, a paragraph comes back:

describe resulttext
visual_description: "A confident young woman with majestic wings walking through a
vibrant, bustling street market illuminated by neon signs. She wears a green jacket
adorned with patches, jeans, and boots, and carries a bag slung over her shoulder..."

2. Generations can self-check. With rounds > 0, each generation is critiqued against the intent, the character appearance blocks, and your rubric. On PASS it is done; on FAIL the critique is appended to the draft as revision notes (traced as the review stage) and one more round runs, bounded by rounds. Every critique lands in the final recipe — inspectable like the rest of the pipeline.

Vision is opt-in because it costs real tokens per look — an image is roughly 25k input tokens on a small vision model. Without it, describe returns metadata and lineage only, and generations are not reviewed. Spend from vision calls is attributed separately in the usage report (describe and review buckets), so you can see exactly what looking cost.

Usage and cost tracking

Generation spends real money, so every model-touching path is metered — image generations and edits, the llmEnhance rewrite pass, vision review rounds, and vision describes. The unit is:

typestypescript
interface ImageUsage {
  requests: number;
  tokens_in: number;
  tokens_out: number;
  cost_usd?: number;   // when the provider reports real spend
}

cost_usd is filled when the provider reports actual spend — the OpenRouter adapter asks for it with usage: { include: true }, so a generation comes back with its true dollar cost rather than an estimate. Adapters that report nothing still count { requests: 1 }, so request counts stay honest either way.

Spend surfaces in four places:

ScopeWhere
Per calldata.usage on generate / edit / regenerate results — the whole call, including the enhance pass and any review rounds. The model sees what each action cost.
Per assetRecipe.usage, so any image can answer “what did this cost to make” forever. glove_image_describe includes it.
Per sessionA UsageMeter with per-source attribution (generate, edit, enhance, review, describe). Read it host-side, or let the agent read it via glove_image_usage.
Your accountingThe onUsage callback fires on every spend event — wire it to store.addTokens(...), a billing table, or metrics.
metering.tstypescript
import { UsageMeter } from "glove-image";

const meter = new UsageMeter();

await mountImage(glove, {
  adapter: openrouterImages(),
  assets, library,
  usage: meter,
  onUsage: (source, u) => metrics.increment(`image.${source}`, u.cost_usd ?? 0),
});

// ...later, host-side:
meter.report();
// {
//   total: { requests: 3, tokens_in: 51186, tokens_out: 1388, cost_usd: 0.0387653 },
//   by_source: {
//     generate: { requests: 1, tokens_in: 51,    tokens_out: 1310, cost_usd: 0.0387653 },
//     describe: { requests: 1, tokens_in: 25516, tokens_out: 77 },
//     review:   { requests: 1, tokens_in: 25619, tokens_out: 1 },
//   },
// }

Note what that report makes obvious: the two vision calls cost five hundred times more input tokens than the generation prompt. That is the kind of thing you want to see before the invoice.

The tools

ToolInputBehaviour
glove_image_generate{ intent, characters?, scene?, refs?, negative?, size?, seed?, candidates?, name?, tags? }Builds the draft, runs the pipeline, calls the adapter, stores every candidate with its recipe and usage.
glove_image_edit{ asset, instruction, mask?, refs?, name? }Edit or inpaint an existing asset. Records parent in the recipe. Errors if the adapter lacks an edit mode.
glove_image_regenerate{ asset, tweak? }Replays a generated asset's recipe through the current pipeline.
glove_image_import{ url? | data?, mime?, name?, tags? }Lands an external image in the store; format and dimensions sniffed from the bytes.
glove_image_describe{ asset }Metadata, lineage and cost at zero model cost; adds a visual description when vision is configured.
glove_image_asset_list{ source?, tags?, name_contains? }Browse the store — ids, names, dimensions, sources, tags.
glove_image_assemble{ canvas, layers, name? }Deterministic compositing. Needs the optional sharp peer.
glove_image_usage{}Session spend: total and per-source.
glove_image_character_*save / get / list / removeLibrary CRUD. Writes only folded when curate is true (the default).
glove_image_scene_*save / get / list / removeSame, for scenes.

Tool-result discipline throughout: data (model-facing) carries asset ids, dimensions, trace summaries and usage; renderData (client-only, and stripped by model adapters) carries thumbnail data-URLs for renderers. Bytes never enter data, so context cost is flat no matter how many images a session touches.

Model adapters

The image model sits behind a small interface that declares its capabilities up front, so fitToModel() can reconcile requests before they are sent. Adapters may assume every request they receive is already in-capability.

typestypescript
interface ImageModelCapabilities {
  modes: Array<"generate" | "edit" | "variation">;
  maxRefs: number;              // 0 = text-only
  refRoles: RefRole[];          // which roles it honours
  sizes: string[] | "flexible";
  negativePrompt: boolean;
  seed: boolean;
  maxCandidates: number;
}

interface ImageModelAdapter {
  name: string;
  capabilities: ImageModelCapabilities;
  generate(req: ImageGenerateRequest, signal?: AbortSignal): Promise<ImageModelResult>;
  edit?(req: ImageEditRequest, signal?: AbortSignal): Promise<ImageModelResult>;
}

The mount resolves reference bytes before calling the adapter, so adapters stay storage-agnostic — they receive materialised bytes and never touch the store. Returning usage on the result is how an adapter participates in cost tracking.

glove-image/openrouter

The reference adapter. Plain fetch, no SDK dependency. Drives image-output models through OpenRouter's chat endpoint, defaulting to google/gemini-2.5-flash-image:

adapter.tstypescript
import { openrouterImages } from "glove-image/openrouter";

const adapter = openrouterImages({
  apiKey: process.env.OPENROUTER_API_KEY,  // this is the default
  model: "google/gemini-2.5-flash-image",  // also the default
  referer: "https://my-app.example.com",   // optional attribution
  title: "My Studio",
});

It supports generate and edit, passes references as labelled image inputs (each one told what it is for), fans candidates out as parallel requests, and aggregates their usage. Bring your own for Stability, Replicate, fal, ComfyUI, or anything running locally — the interface is two methods and a capability object.

Storage seams

Two contracts, both with in-memory reference implementations that are process-local and lose everything on restart. Production swaps them for object storage and a database; neither is more than a handful of methods, and neither pulls in an SDK.

typestypescript
interface ImageAssetStore {
  identifier: string;
  put(bytes: Uint8Array, meta: Omit<ImageAsset, "id" | "created_at">): Promise<ImageAsset>;
  get(id: string): Promise<ImageAsset | null>;
  bytes(id: string): Promise<Uint8Array>;
  list(filter?: AssetFilter): Promise<ImageAsset[]>;
  remove(id: string): Promise<void>;
  /** Optional — downscaled bytes for renderData. Falls back to full bytes. */
  thumbnail?(id: string, maxEdge: number): Promise<Uint8Array>;
}

interface ImageLibraryAdapter extends ImageLibraryReader {
  identifier: string;
  saveCharacter(def: CharacterDef): Promise<void>;   // upsert by name
  removeCharacter(name: string): Promise<void>;
  saveScene(def: SceneDef): Promise<void>;
  removeScene(name: string): Promise<void>;
}

Apps already running glove-memory can back the library onto the entity graph — the contracts stay independent so neither package requires the other.

Permissions and spend control

requirePermission: true marks glove_image_generate, glove_image_edit and glove_image_regenerate as gated, riding the existing (tool, input) permission flow — so hosts get per-call consent with the standard store keying.

candidates is clamped to the lower of the adapter's maxCandidates and the mount's own candidates config, so a model cannot fan out spend on its own initiative.

mountImage reference

configtypescript
await mountImage(glove, {
  adapter,             // ImageModelAdapter                              (required)
  assets,              // ImageAssetStore                                (required)
  library,             // ImageLibraryAdapter                            (required)
  pipeline,            // PromptEnhancer[] — default [expandCharacters(), expandScenes()]
  model,               // ModelAdapter for llmEnhance — usually the agent's own
  review,              // { vision, rounds?, rubric? } — vision off unless set
  usage,               // UsageMeter — pass your own to read it host-side
  onUsage,             // (source, usage) => void
  curate,              // default true; false folds read-only library tools
  candidates,          // default 1; clamped to capabilities.maxCandidates
  requirePermission,   // default false
});

Async and non-chainable, callable before or after build(). It validates the pipeline, appends fitToModel(), and folds the tools.

Status and what is next

Draft v0.1. The core contracts, pipeline, tool surface, in-memory adapters, OpenRouter adapter, vision paths and cost tracking are implemented and tested, including live end-to-end runs. Planned:

  • React renderers (glove-image/react) and a multi-candidate picker slot.
  • Direct OpenAI (gpt-image-1) and Gemini adapters.
  • System-prompt priming — today the tool descriptions carry that context.
  • from_message import, pulling image parts straight off the user's message.
  • Bridges to scratchpad (assets as queryable tables) and working environment (assets mounted for scripted post-processing).

Deliberately out of scope for now: generative video (the natural next surface, and a separate package when it lands), fine-tuning and LoRA training, upscaling models, CDN and serving concerns, and estimating costs for providers that do not report them.

Related reading: Working Environment for pixel-level batch work (env:images) and video rendering (env:motion) · Core API for the tool and permission model · Glovebox for shipping an image agent as a sandboxed service.