OR Key
drop another .md file to compare - side-by-side diff against image

image

Creates a polished image and publishes it ready to use.
personal 2 files

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.

Work with me
For developers how this skill is built, graded, and how it runs

at a glance- the short version

eval modeauto
categoryContent
stages4
dependsgenerate_image, artifacts

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.

The skill
state/skills/image/SKILL.md present
the skill itself, in plain text
The main file. It says what the skill is and lays out the steps in plain English.
Code
state/lib/image.ts present
code the skill can run
Reusable code this skill can call when it needs to.
Scripts
state/bin/image/ not present
helper scripts
Optional. Added when a skill has a few commands to run.
Loader
state/skills/image/AGENTS.md present
what the AI loads on the fly
Loaded automatically the moment this skill is needed. Kept short on purpose.

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.

name
kind
check
artifact_url_returned
deterministic
The skill returns a fetchable artifact or https URL for every generated image.
gallery_route_uses_media_index
deterministic
Inspection flows read /images/gallery or /media/recent?kind=image rather than a legacy generation ledger.
auditor_independent
judge
A verifier independent from the generator evaluates whether the generated image satisfies the prompt.

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.

makes the work The worker
not present

No work step here. This is probably a skill that reads or coordinates, not one that produces something.

checks the work The reviewer
not present

No separate check found. Without one, the part that makes the work could end up approving its own work, worth a closer look.

frame
learns Self-correction
not present

This skill doesn't fix its own gaps yet.

tidies up Background fixes
present
queued for rewrite runs in the background
Bigger fixes that can't be made on the spot get queued and rewritten in the background later.
remembers Run history
present
state/log/evals.ndjson auto runs
Every run is written down here, so the next time this skill is used it already knows how the last runs went.
Critical rules the things this skill must not get wrong
No must-not-break rules called out for this skill. Anything important lives in the writeup below.

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.

  1. Loading feedback rows…

how the work flows- step by step

inputs generate_imageartifacts
1 control
Scope
Confirm the prompt, count, and any requested style or aspect ratio. If the user
what this step does
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 generator
Generate
Call GenerateImage / generate_image once per requested image. Each result
what this step does
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 stage
Return artifact URLs
Return { ok: true, images: [{ url, prompt, artifactId? }] }. For a single
what this step does
Return { ok: true, images: [{ url, prompt, artifactId? }] }. For a single image, also include url at the top level for legacy callers.
4 generator
Local renderer adapter
If a downstream renderer requires local file paths, download the returned
what this step does
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.

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

KeyRequiredDefaultPurpose
promptyes-The visual prompt (subject, style, constraints)
slugnoprompt-derivedOptional artifact/app slug
countno1Number 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.

OutcomeScoreMeaning
Artifact URL returned and verifier passes1.0Clean generation
Artifact URL returned but verifier flags a correctable issue0.5Usable with follow-up
Generation failed or no fetchable URL returned0.0Ship 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 ImageGallery from

the returned URLs.

  • For Remotion or other local renderers, download artifact URLs to /tmp only

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

  1. Use artifact-backed generation. Call GenerateImage / generate_image;

the returned URL is the source of truth for cockpit rendering.

  1. Never answer with local image paths. /tmp paths are only temporary

adapters for local renderers such as Remotion when they cannot consume URLs.

  1. Gallery requests fan out direct generation calls. Do not delegate the

simple N-image gallery case to a background agent.

  1. Actor != auditor. The image generator creates the artifact; an

independent artifact or visual verifier scores the result. The caller appends the eval row.

  1. Keep prompts literal. Preserve the requested subject unless the user asks

for abstraction, surrealism, or a specific style study.

Commands

actionpath
generateGenerateImage / generate_image with {prompt, slug?}
gallery composecompose_inline with root = ImageGallery([{src, alt, details}, ...])
list existingGET /images/gallery or GET /media/recent?kind=image
local renderer adapterdownload the returned artifact URL to /tmp, then pass that temporary path to the renderer
eval logcaller 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:

  1. [ ] Use GenerateImage / generate_image for new image work?
  2. [ ] Use /images/gallery or /media/recent?kind=image for saved image inspection?
  3. [ ] Compose galleries with ImageGallery from fetchable URLs?
  4. [ ] Treat /tmp files as local renderer adapters only?
  5. [ ] 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

rubric auto no rubric declared
recent no runs actor/auditor: unverifiable
deps generate_image artifacts

no recent runs logged - the eval contract is declared but nothing has been graded yet