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.
pnpm add glove-imageIf 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.
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:
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.
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.
Four pieces, deliberately separated:
| Piece | What it is | Contract |
|---|---|---|
| Assets | Every 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 |
| Library | Durable characters and scenes, curated by the agent or the host app, referenced by name in generation calls. | ImageLibraryAdapter |
| Pipeline | An 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[] |
| Model | The image model behind a capability-declaring adapter — generate, edit, variations. | ImageModelAdapter |
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)Build the agent as normal, then mount the image surface before or after build() — same convention as mountMcp and mountMesh.
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" })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.
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;
}| Inbetween | What 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.
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:
{
"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 }
}It is a two-property object. A brand-system lookup, a watermark policy, a translation pass, a seasonal palette — all the same shape:
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.
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.
A character is a durable identity: wording that must stay stable, images that anchor likeness, and negatives that fence off drift.
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.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.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.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.
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.
Three distinct doors, because “use this image” means three different things:
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.RefImage with a role. Adapters declare which roles they honour and fitToModel() reconciles.| Role | Meaning |
|---|---|
identity | This face / this likeness. Survives ref clamping first. |
style | This look — brushwork, grade, era. |
composition | This framing and layout. |
content | Image-to-image base to transform. |
mask | Edit region — white is editable, black stays. |
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:
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.
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:
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
}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.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.
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:
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.
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:
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:
| Scope | Where |
|---|---|
| Per call | data.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 asset | Recipe.usage, so any image can answer “what did this cost to make” forever. glove_image_describe includes it. |
| Per session | A 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 accounting | The onUsage callback fires on every spend event — wire it to store.addTokens(...), a billing table, or metrics. |
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.
| Tool | Input | Behaviour |
|---|---|---|
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 / remove | Library CRUD. Writes only folded when curate is true (the default). |
glove_image_scene_* | save / get / list / remove | Same, 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.
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.
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/openrouterThe reference adapter. Plain fetch, no SDK dependency. Drives image-output models through OpenRouter's chat endpoint, defaulting to google/gemini-2.5-flash-image:
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.
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.
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.
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.
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.
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:
glove-image/react) and a multi-candidate picker slot.from_message import, pulling image parts straight off the user's message.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.