No work step here. This is probably a skill that reads or coordinates, not one that produces something.
.md file to compare - side-by-side diff against image
image
What it does for you
Creates a polished image and publishes it ready to use.
What it produces
A recent result, so you can see the kind of work it returns.
loading…
How to get it
These run inside the Snappy workspace. Want this working in your business? I set skills like this up with you, in one focused week.
For developers how this skill is built, graded, and how it runs
at a glance- the short version
what's inside - the parts that make up a skill 3/4 present
A skill is just a few plain-text files. Only the main one is required. The rest are optional, added as the work needs them. This is what the skill is made of; how it runs is just below.
state/skills/image/SKILL.md
present
state/lib/image.ts
present
state/bin/image/
not present
state/skills/image/AGENTS.md
present
how it's graded - what counts as a good run 3 criteria · 2 deterministic · 1 judge
Each row is one thing a good run has to get right. deterministic means a quick check decides, pass or fail. judge means the AI reads the result and rates it. Grading each piece on its own (instead of one overall score) shows exactly where a run fell short, so the fix is obvious.
how it runs - the shared frame every skill uses 2/5 present
Every skill runs the same way. One part does the work, a separate part checks it, and a short loader hands the AI exactly what it needs for the job. Anything this skill doesn't use shows a one-line note saying why, on purpose, not by accident.
No separate check found. Without one, the part that makes the work could end up approving its own work, worth a closer look.
This skill doesn't fix its own gaps yet.
state/log/evals.ndjson what it has learned - fixes written back in over time sample
When a run hits something this skill didn't handle, the fix gets written back into the skill so it doesn't happen again. FIXED means it was corrected on the spot. LOGGED means it's queued for a bigger rewrite. Either way, the skill gets a little better and never makes the same mistake twice.
- Loading feedback rows…
how the work flows- step by step
what this step does
what this step does
what this step does
what this step does
SKILL.md- the skill, written out in plain English
image
When NOT to use this skill
For "image gallery", "N images of X", "small gallery", "make me N images", "show me N images" intents in the cockpit chat: use the generate_image Mutation directly, fan out the requested number of calls, then compose the result with the gallery pattern in state/config/prompt-fragments/image-gallery.md.
For "show me my images", "generated images", or saved gallery inspection, read the artifact-backed media index through /images/gallery or /media/recent?kind=image. Do not create new images for inspection turns.
What this skill does
Produce one or more image artifacts with fetchable URLs. The runtime source of truth is the generated artifact URL returned by GenerateImage / generate_image, usually /artifacts/<id>/file or a durable https URL. Local filesystem paths are only temporary adapters for tools that cannot render URLs.
Inputs
| Key | Required | Default | Purpose |
|---|---|---|---|
prompt | yes | - | The visual prompt (subject, style, constraints) |
slug | no | prompt-derived | Optional artifact/app slug |
count | no | 1 | Number of images to generate |
Steps
1. Scope
Confirm the prompt, count, and any requested style or aspect ratio. If the user is asking to inspect existing images, stop and read /images/gallery instead.
2. Generate
Call GenerateImage / generate_image once per requested image. Each result must return a fetchable artifact or https URL. Keep the subject literal unless the user explicitly requests abstraction or a style study.
3. Return artifact URLs
Return { ok: true, images: [{ url, prompt, artifactId? }] }. For a single image, also include url at the top level for legacy callers.
4. Local renderer adapter
If a downstream renderer requires local file paths, download the returned artifact URL to /tmp and pass that temporary path to the renderer. Do not make /tmp paths the user-facing answer.
Eval
The actor is the image generator behind GenerateImage / generate_image. The auditor is an independent artifact or visual verifier. The caller appends the eval row to state/log/evals.ndjson; the image generation path itself is not the grader.
| Outcome | Score | Meaning |
|---|---|---|
| Artifact URL returned and verifier passes | 1.0 | Clean generation |
| Artifact URL returned but verifier flags a correctable issue | 0.5 | Usable with follow-up |
| Generation failed or no fetchable URL returned | 0.0 | Ship nothing |
Gotchas
- Use artifact or https URLs in cockpit surfaces. Local paths render blank in
WKWebView.
- For galleries, use the gallery fragment and compose an
ImageGalleryfrom
the returned URLs.
- For Remotion or other local renderers, download artifact URLs to
/tmponly
when the renderer cannot consume URLs directly.
- Empty or underspecified prompts should be clarified before generation.
Rubric
criteria:
- name: artifact_url_returned
kind: deterministic
check: "The skill returns a fetchable artifact or https URL for every generated image."
- name: gallery_route_uses_media_index
kind: deterministic
check: "Inspection flows read /images/gallery or /media/recent?kind=image rather than a legacy generation ledger."
- name: auditor_independent
kind: judge
check: "A verifier independent from the generator evaluates whether the generated image satisfies the prompt."AGENTS.md- what the AI loads when this skill comes up
image - loader
Per-turn rules for the image skill. Full reference: state/skills/image/SKILL.md. Do not skip these.
Scope boundary
This loader is for making or revising an image through the registered GenerateImage / generate_image capability and returning a fetchable artifact or https URL.
For cockpit gallery intents (a chat-injected prompt asks for an image gallery, "make me images of X", "generate a small image gallery", etc.), the image-gallery.md phrase fragment is the authoritative path. Use generate_image N times in parallel and compose the returned URLs with ImageGallery.
If the prompt is primarily about inspecting saved images, generated-image galleries, prompt variants, queue state, or next controls around image work, this loader is the wrong one. Stop before generating. Read the artifact-backed media index through GET /images/gallery or GET /media/recent?kind=image. Gallery inspection intents include "show me my images", "image gallery", "generated images", and "what images have I made".
Critical Rules
- Use artifact-backed generation. Call
GenerateImage/generate_image;
the returned URL is the source of truth for cockpit rendering.
- Never answer with local image paths.
/tmppaths are only temporary
adapters for local renderers such as Remotion when they cannot consume URLs.
- Gallery requests fan out direct generation calls. Do not delegate the
simple N-image gallery case to a background agent.
- Actor != auditor. The image generator creates the artifact; an
independent artifact or visual verifier scores the result. The caller appends the eval row.
- Keep prompts literal. Preserve the requested subject unless the user asks
for abstraction, surrealism, or a specific style study.
Commands
| action | path |
|---|---|
| generate | GenerateImage / generate_image with {prompt, slug?} |
| gallery compose | compose_inline with root = ImageGallery([{src, alt, details}, ...]) |
| list existing | GET /images/gallery or GET /media/recent?kind=image |
| local renderer adapter | download the returned artifact URL to /tmp, then pass that temporary path to the renderer |
| eval log | caller appends {ts, run_id, skill:"image", score, actor_session_id, auditor_session_id} to state/log/evals.ndjson |
Known Pitfalls
- Local files are not a user-visible gallery answer. The WKWebView needs an
artifact or https URL.
- Inspection turns should not generate new images.
- If a renderer needs a path, download from the artifact URL at the last
possible moment and keep the artifact URL in the response.
- Empty or vague prompts should be clarified before spending a generation.
Self-Test
An agent reading this should correctly:
- [ ] Use
GenerateImage/generate_imagefor new image work? - [ ] Use
/images/galleryor/media/recent?kind=imagefor saved image inspection? - [ ] Compose galleries with
ImageGalleryfrom fetchable URLs? - [ ] Treat
/tmpfiles as local renderer adapters only? - [ ] Keep actor and auditor separate when logging evals?
Found a gap? Edit this file. <!-- footer-injection-point -->
api.ts- the code it can call
#!/usr/bin/env npx tsx
/**
* state/lib/image.ts -- Image generation and CDN upload client.
*
* Same shape as xano.ts, github.ts, pipeline.ts: typed functions, one import.
* Wraps the shell scripts in state/bin/image/ under a clean interface.
*
* import { generate, upload } from "../lib/image.ts";
* const result = await generate({ illustration: "...", text: "Title", slug: "my-img", format: "skool-course" });
* console.log(result.cdnUrl);
*
* CLI:
* npx tsx state/lib/image.ts generate --illustration "..." --text "Title" --slug test --format linkedin-post
* npx tsx state/lib/image.ts upload --file /tmp/img.png --context course-imagery --slug my-slug
*/
import { execSync } from "child_process";
import { readFileSync, existsSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "image");
// ── Types ──
export type ImageFormat =
| "linkedin-post"
| "linkedin-carousel"
| "skool-course"
| "blog-hero"
| "video-slide"
| "thumbnail"
| "instagram-feed"
| "instagram-story";
export interface GenerateOptions {
/** Visual concept for the illustration area (use this OR topic+council) */
illustration?: string;
/** Topic for art council to debate (use with council) */
topic?: string;
/** Title text rendered in the lower third */
text: string;
/** Short slug for filenames and CDN path */
slug: string;
/** Target platform format (default: linkedin-post) */
format?: ImageFormat;
/** Output directory (default: /tmp/snappy-image) */
outDir?: string;
/** Max self-inspection edit rounds (default: 1) */
maxAutoTurns?: number;
/** Optional reference image path for style transfer */
ref?: string;
/** Art council panel to use (e.g. "art", "textbook", "whimsy") */
council?: string;
/** Fan out all council proposals in parallel (default: false) */
matrix?: boolean;
}
export interface GenerateResult {
/** Local path to final image */
path: string;
/** CDN URL of final image */
cdnUrl: string;
/** Inspector verdict: "pass" or "fix" */
verdict: string;
/** Eval score: 1.0 if pass, 0.5 if fix-applied, 0.0 if error */
score: number;
/** Number of versions generated (1 = no edits needed) */
versions: number;
/** All CDN URLs for each version */
allUrls: string[];
/** Matrix results (only when council + matrix mode) */
matrix?: { expert: string; url: string }[];
}
export interface UploadOptions {
/** Local file path */
file: string;
/** CDN context/folder (default: images/generated) */
context?: string;
/** Slug for the CDN key */
slug?: string;
}
// ── Functions ──
/**
* Generate an image with self-inspecting loop.
* Calls generate-iterate.sh under the hood.
*/
export async function generate(opts: GenerateOptions): Promise<GenerateResult> {
if (!opts.illustration && !opts.topic) {
throw new Error("generate requires either illustration or topic");
}
const outDir = opts.outDir ?? "/tmp/snappy-image";
const format = opts.format ?? "linkedin-post";
const maxTurns = opts.maxAutoTurns ?? 1;
const args: string[] = [];
// Council mode: pass topic + council panel, let experts debate the concept
if (opts.topic && opts.council) {
args.push(`--topic ${q(opts.topic)}`);
args.push(`--council ${q(opts.council)}`);
if (opts.matrix) args.push("--matrix");
} else if (opts.illustration) {
args.push(`--illustration ${q(opts.illustration)}`);
}
args.push(
`--text ${q(opts.text)}`,
`--slug ${q(opts.slug)}`,
`--out-dir ${q(outDir)}`,
`--format ${format}`,
`--max-auto-turns ${maxTurns}`,
);
if (opts.ref) args.push(`--ref ${q(opts.ref)}`);
const stdout = run(`bash ${BIN}/generate-iterate.sh ${args.join(" ")}`, 600_000);
// Parse CDN URLs from stdout (lines like "v1: https://...")
const urls = [...stdout.matchAll(/v\d+:\s+(https:\/\/\S+)/g)].map((m) => m[1]);
// Parse matrix results if present (lines like " Steinberg: https://...")
const matrixMatches = [...stdout.matchAll(/^\s+(\w+):\s+(https:\/\/\S+)/gm)];
const matrix =
matrixMatches.length > 0 ? matrixMatches.map((m) => ({ expert: m[1], url: m[2] })) : undefined;
// Read state.json for structured result
const stateFile = `${outDir}/${opts.slug}/state.json`;
let verdict = "unknown";
let versions = urls.length;
let finalPath = `${outDir}/${opts.slug}/v${versions}.png`;
if (existsSync(stateFile)) {
const state = JSON.parse(readFileSync(stateFile, "utf-8"));
const frames = state.frames ?? [];
const last = frames[frames.length - 1];
if (last) {
verdict = last.verdict ?? "unknown";
}
versions = frames.length;
finalPath = `${outDir}/${opts.slug}/v${versions}.png`;
}
const score = verdict === "pass" ? 1.0 : versions > 1 ? 0.5 : 0.0;
return {
path: finalPath,
cdnUrl: urls[urls.length - 1] ?? "",
verdict,
score,
versions,
allUrls: urls,
matrix,
};
}
/**
* Upload a local file to the DO Spaces CDN.
* Returns the public HTTPS URL.
*/
export async function upload(opts: UploadOptions): Promise<string> {
const args = [`--file ${q(opts.file)}`];
if (opts.context) args.push(`--context ${q(opts.context)}`);
if (opts.slug) args.push(`--slug ${q(opts.slug)}`);
const stdout = run(`bash ${BIN}/cdn-upload.sh ${args.join(" ")}`);
// Last line of stdout is the https URL
const lines = stdout.trim().split("\n");
const url = lines.find((l) => l.startsWith("https://")) ?? lines[lines.length - 1];
return url.trim();
}
// ── Helpers ──
function q(s: string): string {
return `"${s.replace(/"/g, '\\"')}"`;
}
function run(cmd: string, timeout = 300_000): string {
return execSync(cmd, {
encoding: "utf-8",
timeout,
stdio: ["pipe", "pipe", "pipe"],
});
}
// ── CLI ──
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("image.ts")) {
const [, , command, ...rest] = process.argv;
function cliArg(name: string): string | undefined {
const idx = rest.indexOf(`--${name}`);
return idx >= 0 ? rest[idx + 1] : undefined;
}
(async () => {
switch (command) {
case "generate": {
const illustration = cliArg("illustration");
const topic = cliArg("topic");
const text = cliArg("text");
const slug = cliArg("slug");
if ((!illustration && !topic) || !text || !slug) {
console.error(
"usage: image.ts generate --topic '...' --council art --text '...' --slug <slug> [--format <fmt>] [--matrix]",
);
console.error(
" or: image.ts generate --illustration '...' --text '...' --slug <slug> [--format <fmt>]",
);
process.exit(2);
}
const result = await generate({
illustration: illustration || undefined,
topic: topic || undefined,
council: cliArg("council") || undefined,
matrix: rest.includes("--matrix"),
text,
slug,
format: (cliArg("format") as ImageFormat) ?? "linkedin-post",
outDir: cliArg("out-dir"),
maxAutoTurns: cliArg("max-auto-turns") ? Number(cliArg("max-auto-turns")) : undefined,
ref: cliArg("ref"),
});
console.log(JSON.stringify(result, null, 2));
break;
}
case "upload": {
const file = cliArg("file");
if (!file) {
console.error("usage: image.ts upload --file <path> [--context <ctx>] [--slug <slug>]");
process.exit(2);
}
const url = await upload({ file, context: cliArg("context"), slug: cliArg("slug") });
console.log(url);
break;
}
default:
console.log("usage: image.ts <generate|upload> [options]");
console.log("");
console.log(" generate --illustration '...' --text '...' --slug <slug> [--format <fmt>]");
console.log(" upload --file <path> [--context <ctx>] [--slug <slug>]");
}
})();
}
scripts- helper scripts it can run
prose-only skill - 1 inline code block live in SKILL.md above (no state/bin/ sidecar yet).
how we check it- the checks, plus the last 10 runs
no recent runs logged - the eval contract is declared but nothing has been graded yet