generate-consistent-og-images — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited generate-consistent-og-images (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Two techniques, either one valid: generate a wordless illustration with Ideogram (two variables per request — aspect ratio and a one-sentence prompt; everything else locked at theDESIGN.mdlevel), or rasterize the project's own hero/specimen component withqlmanage+sips. Illustration quality is stochastic — some runs land, some don't — while a hero rasterization is deterministic and on-message whenever the hero already says the thing. Pick per project, or per format, and let both coexist.
banner but now need banner_tall for WhatsApp / iMessage)DESIGN.md at allThe Lossless Group optimizes share imagery for mobile chat previews — iMessage and WhatsApp first — and treats LinkedIn / Twitter / Bluesky / Slack as concentric rings around that target.
In practice this means:
3x4 (WhatsApp / iMessage default) and 2x3 (extra-tall) variants are first-class assets, not afterthoughts.This shapes the aspect-ratio enum below and the naming convention.
There are two independent, coexisting techniques for producing a Lossless OG image. Neither is "the" canonical one — pick per project, or even per format, and keep both around rather than treating a later choice as an implicit rejection of the earlier one. This decision comes before any generation work starts.
A wordless spot-illustration generated per the recipe below, with title/eyebrow text composited on top afterward via the overlay-svg-text skill. Best fit when the project's pitch is editorial or metaphor-driven — there's no single UI element that says the thing, so a subject canon (a mailroom, a vault, a sprout) has to be invented to carry it.
Trade-off to plan around: quality is stochastic. Even with magic_prompt: OFF, a fixed seed, and every channel locked, some generations land exactly right and others fall flat — the only fix is generating multiple candidates and picking a winner (which is why the recipe defaults to num_images: 4). Budget for that variance; don't expect the first run to be the final one.
Skip illustration entirely and turn the project's own hero object — a credential-specimen card, a data mockup, a real UI element that already carries real content (real IDs, real copy, real brand tokens) — directly into the OG image. Best fit when the hero already says what the OG image needs to say: data-forward products, anything with a "here's the actual artifact" pitch, dashboards, specimen/credential UI. Reproducing that as a wordless illustration would throw away the information that makes it convincing.
How:
DESIGN.md tokens (don't re-derive them). Use real data where the project has it (a real UUID, a real timestamp, real copy already live on the page) rather than lorem-ipsum placeholders — the realism is the point. Give the focal content generous padding — see the gotcha below. qlmanage -t -s 1200 -o /tmp/og-render path/to/hero.html # or hero.svg
sips -c 630 1200 /tmp/og-render/hero.html.png --out /tmp/og-render/cropped.png
sips -s format jpeg /tmp/og-render/cropped.png --out public/og-banner.jpgqlmanage -t drives QuickLook's own generator for the file type — it has one for both .html and .svg, so raw HTML/CSS heroes work directly, not just vector art. Two verified gotchas: -s <size> always produces a square canvas — it does not respect the page's own declared width/height, so a 1200×630-styled page still comes out 1200×1200. And sips -z <height> <width> (resize) squashes non-uniformly rather than cropping — visibly distorts the content. Use sips -c <height> <width> (--cropToHeightWidth, a true center-crop) to reach the target aspect ratio instead. Because the crop centers on the square canvas rather than on your content, verify the output visually — CSS-centered content can still land off-center after the pad-to-square step. Full recipe + the test that surfaced both gotchas: lost-in-public/practices/Generating OG Images with qlmanage and sips.md.
page.screenshot() at a fixed viewport) instead of hand-authoring separate markup. This guarantees pixel-parity with the real rendered site — actual fonts, gradients, CSS masks/filters, JS-driven layout — at the cost of a Playwright dependency. Prefer this over the qlmanage route when the hero relies on CSS/JS features QuickLook's renderer may not reproduce faithfully (it's WebKit-based but not a full browser tab — untested against remote web-font loads, CSS filters/masks, and JS-driven layout).banner as a rasterized hero, banner_tall/portrait/square as illustrated variants for surfaces where a wordless scene works better. The naming convention already supports per-format independence; nothing about picking Technique B for one format obligates using it for all of them.og:image is a rasterized SPECIMEN credential-card hero (Technique B, from qlmanage+sips, 2026-07-06). A full illustrated safety-deposit-box-vault set (Technique A) was generated for the same project on 2026-07-07 and kept on disk as an available alternative rather than replacing the hero rasterization — see the Case protocol below for how that coexistence decision gets made.IDEOGRAM_API_KEY in ~/.secretsSame pattern as the crawl-fetch-ingest skill: keys never live in source.
# ~/.secrets (chmod 600)
export IDEOGRAM_API_KEY="..."# ~/.zshenv adds one line (already in place if other Lossless skills work)
[ -f ~/.secrets ] && source ~/.secretsNo MCP server is required. Ideogram's v3 endpoint accepts a direct HTTPS request with Api-Key header. Bash + curl is enough. If a future MCP wrapper is preferred, register it the same way the firecrawl/tavily wrappers are registered in crawl-fetch-ingest's setup.md — but it's not necessary for this skill.
The skill assumes the project has some established aesthetic. If it doesn't — brand-new site, no design decisions yet — that conversation has to happen before image generation. Use the astro-knots skill or pair with the user on a Brand & Style first pass before invoking this one.
Before generating anything — or before wiring newly-generated images into the site's actual meta tags — check what the project's SEO/meta component actually serves as og:image today. DESIGN.md's presence or absence of an imagery: block says nothing about what's live: a project can have a fully-authored imagery recipe and still ship a completely different, hand-crafted image in production, or vice versa. Conflating "DESIGN.md has no imagery: block yet" with "there's no OG image yet" is the mistake this protocol prevents.
Before Step 1 of the flow below, grep the site's meta-tag surface (MetaTags.astro, seo.ts, Layout.astro, or whatever the project calls it) for og:image, DEFAULT_OG, ogImage, or equivalent. Identify:
git log --follow -- <path>, git blame, the commit message) — this often reveals how it was made (a hand-authored SVG rasterized via a one-off tool, a prior Ideogram run, a screenshot, a design export) and why it looks the way it does.overlay-svg-text), or does it bake copy / a UI mockup directly into the image (a different, equally valid convention)?ogimage__{Project}--{Format}.{ext} naming pattern, or DESIGN.md's imagery: block clearly describes it). Treat this as a refresh — archive + replace per the Preservation Discipline below. No need to stop and ask.Generating the illustrated candidate set is cheap and can proceed regardless of the final wiring decision — the assets are useful independent of what ends up live. What must wait for explicit sign-off is the step that edits the site's actual meta-tag source (DEFAULT_OG, an ogImage prop default, etc.) to point at the new set. Ask, don't assume — offer options along these lines rather than picking one silently:
"The project already ships <X> as its OG image — a different design language (text-driven / hand-crafted / etc.) than the illustrated set. Replace it entirely, keep it as primary and use the new set only for formats that don't exist yet, or generate one candidate first so you can compare side-by-side before deciding?"This is a genuine judgment call about the project's visual identity, not a mechanical decision this skill can make on its own.
A user answering "replace it" is answering from memory of the old image against a description of the new one — not against the two rendered side by side in context. Don't burn the reversibility this buys you: archive the outgoing image, never delete it (same rule as the Preservation Discipline below), and keep the wiring change small and obviously reversible — one file swap plus one config edit, not a multi-file refactor. If the user reverses the decision after seeing the composited result live, that's not a failure of the protocol — it's exactly the case the protocol is designed to make cheap to recover from.
Precedent: id-didi-sh, 2026-07-07 — a fully-generated and text-overlaid illustrated set was wired in as the default `og:image` after the user said "replace it entirely," then reverted back to the original hand-rasterized banner once they saw the result live and preferred it. Because the original had been archived rather than deleted, the revert was a two-file copy and a one-line edit in `seo.ts` — not a redo.
Five steps, in order. Steps 1–2 are setup the first time the skill runs against a project; steps 3–5 are the per-image work that repeats. Run the Case protocol above first if there's any chance an OG image is already live — it determines whether Step 5's wiring is a free action or a gated one.
DESIGN.mdLook for DESIGN.md at the project root.
Brand & Style, Colors, Typography, Layout, Elevation & Depth, Shapes, Components).templates/design-md-scaffold.md). Once a first-pass DESIGN.md exists, return here to add the imagery: extension and proceed.The skill cannot proceed to image generation without a DESIGN.md because the imagery recipe (next step) references its color palette tokens.
imagery: block to DESIGN.mdThe imagery: block is outside the Google Stitch spec's standard token groups, but spec-compliant consumers preserve unknown top-level keys (per the spec's "Consumer Behavior for Unknown Content" table). It's safe to keep there as the single source of truth.
The canonical block lives at templates/imagery-block.yaml in this skill. Open that file, adapt three things to the target project, paste into the project's DESIGN.md frontmatter (after components:, before the closing ---):
| Field | What to adapt |
|---|---|
style_reference.path | The project's canonical hero/banner image. If the project has none yet, generate a one-off using a quick prompt + the project's colors: palette, save it, and use that as the reference for all subsequent runs. |
color_palette.members | Map from the project's colors: tokens to Ideogram's color_hex + color_weight shape. Weight the surface background highest (0.4–0.5) and brand accents lower. |
defaults.seed | Pick a fixed seed for the project. Any integer is fine; bump only when the canon shifts. |
Everything else in templates/imagery-block.yaml — style_type: AUTO, magic_prompt: OFF, rendering_speed: QUALITY, the aspect_ratios enum, the negative_prompt set, the prompt constraints — is project-agnostic and should be copied verbatim. The whole point of locking those is that they don't vary across projects either.
API constraint worth knowing:style_type: AUTOis required wheneverstyle_reference_imagesis uploaded. The v3 endpoint rejectsDESIGN/REALISTIC/FICTIONin that combination with the error "Please use AUTO or GENERAL style type with style_codes, style_reference_images or style_preset." The reference image carries the aesthetic;AUTOlets it through.
The matching prose section (## Imagery) belongs in the body of DESIGN.md immediately before ## Do's and Don'ts. The reference implementation in content-farm/splash/DESIGN.md is the canonical example.
Read the project's DESIGN.md imagery: block. The Ideogram request is built by:
imagery.defaults as-is.imagery.color_palette as-is.imagery.style_reference.path as style_reference_images.imagery.negative_prompt as-is.imagery.aspect_ratios for aspect_ratio — based on the user's stated target format (see § "The aspect-ratio enum" below).prompt field — see § "Prompt convention" below.No other field varies. If the user asks to vary something else (e.g., "use a brighter palette"), that's a DESIGN.md change, not a per-request override.
The canonical curl call lives at templates/ideogram-request.sh. Substitute {prompt} and {aspect_ratio} and run. The script reads IDEOGRAM_API_KEY from the environment (~/.secrets).
POST https://api.ideogram.ai/v1/ideogram-v3/generate
Content-Type: multipart/form-data
Api-Key: $IDEOGRAM_API_KEYThe response carries a url for each generated image. Images expire on the Ideogram side; download immediately to the target project's public/ (or equivalent static asset directory).
ogimage__{Site-Name}--{Format-Or-Variant}.{ext}Site-Name — the project's canonical name in PascalCase or Hyphen-Joined-PascalCase: Content-Farm, Reach-Edu-Hub, Calmstorm-Decks, Perplexed.Format-Or-Variant — one of: Default, Banner, BannerTall, BannerTallMax, Portrait, PortraitTall, Square. Default aliases whichever format is the canonical share image (typically Banner)..jpg per the open-graph-share-seo-geo skill's JPEG-over-WebP rule. Convert from Ideogram's PNG output with ffmpeg -i in.png -q:v 2 out.jpg (quality 2 is near-lossless and lands ~95 KB at 1200×630).Example set for content-farm:
public/ogimage__Content-Farm--Default.jpg # the canonical banner (also serves as style_reference)
public/ogimage__Content-Farm--Banner.jpg # alias of Default
public/ogimage__Content-Farm--BannerTall.jpg # WhatsApp / iMessage default tall
public/ogimage__Content-Farm--BannerTallMax.jpg # dramatic-tall variant
public/ogimage__Content-Farm--Portrait.jpg # LinkedIn portrait / IG feed
public/ogimage__Content-Farm--PortraitTall.jpg # Stories / Reels / TikTok
public/ogimage__Content-Farm--Square.jpg # avatars, square unfurlsIf the image is destined for a CDN (per the open-graph-share-seo-geo skill's "host the OG image remotely" rule), upload there as well; the same file naming travels.
| Format key | Ideogram aspect_ratio | When to use |
|---|---|---|
banner | 16x9 | Default share — OpenGraph, Twitter / X, Slack, generic |
banner_tall | 3x4 | WhatsApp & iMessage chat-preview cards (the Lossless default tall) |
banner_tall_max | 2x3 | Dramatic-tall variant; use when the subject genuinely benefits from height |
portrait | 4x5 | LinkedIn portrait, Instagram feed post |
portrait_tall | 9x16 | Instagram Stories, Reels, TikTok |
square | 1x1 | Avatars, square OG fallbacks, Discord embeds |
The keys live in imagery.aspect_ratios of the project's DESIGN.md; the values may differ if a project has a different target mix, but the format names stay the same across projects so cross-site automation can talk in one vocabulary.
The prompt is the only free-text channel in the request. Two ingredients per prompt, nothing else:
Bad framing (subject-first; produces 85%-tall subjects that swallow the overlay zone):
"An isometric quill pen with paper-leaf feathers, growing from a stack of paper-cut card tiles, in the lower third of the frame, top two-thirds open."
Good framing (empty-region-first, two clauses, explicit numeric split):
"Top 1/3 of frame is empty negative space, dark gradient sky. Bottom 2/3 contains an isometric quill pen with paper-leaf feathers, growing from a stack of paper-cut card tiles."
Three rules that survive aspect-ratio changes:
subject in top half). The negative prompt isn't a substitute for the positive empty-region declaration — it's a belt alongside the positive suspenders.The underlying principle: empty space won't be left as residue. It has to be declared, named, and given content — otherwise the subject expands to fill the canvas. This was discovered empirically over iter1 → iter2 → iter3 on the Perplexed OG set; iter1 and iter2 used subject-first framing and produced subjects at 75-85% canvas height; iter3 used empty-region-first framing and produced subjects at 40-65% canvas height with the upper region genuinely empty.
Target ≤220 characters total. Past that, hard composition asks start losing to subject elaboration.
Forbidden in the prompt (already encoded via locked channels, never repeat):
Lossless, Content Farm, plugin names)cyan, dark, warm)vibrant-minimal, build-in-public, monospace)paper-cut, isometric, atmospheric) — unless the subject genuinely requires them and the style reference image already establishes them as canonComposition templates that survive aspect-ratio shifts cleanly. Note the two-clause structure — empty-region declaration first (with concrete content), subject placement second:
Top 1/3 of frame is empty negative space, dark gradient sky. Bottom 2/3 contains {subject}.Left 1/3 of frame is empty negative space, soft atmospheric backdrop. Right 2/3 contains {subject}.Top half of frame is empty atmospheric gradient. Bottom half contains {subject}, centered horizontally.Top-right 2/3 of frame is empty negative space, dark muted sky. Bottom-left 1/3 contains {subject}.The locked recipe is for share imagery — the assets a third party sees when a link is unfurled. The user / team may also need:
These are legitimate departures from OG-image canon. They may use different palette weights, different aspect ratios, different style references, even a different style_type. When the user asks for one of these:
aspect_ratios enum (use Ideogram's resolution field instead of aspect_ratio for pixel-exact sizing).ogimage__ naming convention. illustration__Section-Name--Variant.jpg, hero__Page-Name.jpg, or whatever the project's local convention is — but never use the ogimage__ prefix, since that name implies the canonical recipe was followed.The point: don't pollute the OG-image set with one-off custom imagery, and don't constrain one-off custom imagery with OG-image rules.
User says: "Regenerate the WhatsApp tall card for content-farm."
# 1. Project: content-farm/splash. DESIGN.md exists, imagery: block exists.
# No setup work needed.
# 2. Pick aspect ratio: banner_tall (3x4)
# 3. Author one-sentence prompt with subject + composition:
PROMPT="Top 1/3 of frame is empty negative space, dark gradient sky. Bottom 2/3 contains an isometric sprout growing from a stack of paper-cut tiles."
# 4. Call Ideogram (see templates/ideogram-request.sh):
bash templates/ideogram-request.sh "$PROMPT" 3x4
# 5. Download response[0].url, convert PNG → JPEG, save as:
# public/ogimage__Content-Farm--BannerTall.jpgUser says: "I just scaffolded reach-edu-hub. Generate the OG banner."
# 1. Check for DESIGN.md at reach-edu-hub/ → absent
# 2. Pair with the user to author DESIGN.md from the spec:
# - Frontmatter token groups (colors, typography, rounded, spacing, components)
# - Eight prose sections in spec order
# Reference: github.com/google-labs-code/design.md and
# content-farm/splash/DESIGN.md as a shape example
# 3. No style_reference exists yet. Generate a one-off with only color_palette
# + style_type=AUTO to seed the canon, save as the reference, then add
# the imagery: block pointing at it.
# 4. Now run the canonical recipe across banner, banner_tall, portrait, square.Ideogram generations are non-deterministic in composition (even with seed + every other parameter locked) and expensive in attention to curate. When we land a "good" image after multiple iterations, the prior candidates and the prior canonical pick are historical record worth keeping — they justify the final pick, document what was tried, and give a baseline if a future run drifts.
Two preservation layers:
The templates/ideogram-request.sh script writes each run into a fresh timestamped directory:
<project>/.ideogram-candidates/<subject>-<aspect>-<YYYYMMDD-HHMMSS>/
candidate-0.png
candidate-1.png
candidate-2.png
candidate-3.png
response.json ← Ideogram's echo: actual prompt, seed used, resolution, expired URLThe dot-prefixed parent directory (.ideogram-candidates/) sits outside the project's static-asset directory (public/, static/, etc.) so Astro / 11ty / Next / any static-site framework doesn't deploy 1.4MB PNGs to production. Per-run subdirs are timestamped → never overwritten across runs. Two BannerTall passes done an hour apart produce two distinct subdirectories.
Decide per project whether to .gitignore .ideogram-candidates/. Pro keeping in git: auditable history of every gen pass. Con: PNGs bloat repo size. Lossless default: keep in git unless the repo is shared with non-team contributors who'd be confused by the byte size.
The canonical-named files (ogimage__{Site}--{Format}.jpg) live in public/ because the splash needs them at deploy time. When you re-run the recipe and pick a NEW winner for the same format, don't overwrite the existing canonical file directly. Instead:
ogimage__{Site}--{Format}.jpg to <project>/.ogimage-archive/ogimage__{Site}--{Format}--{YYYY-MM-DD}.jpg (creating the archive directory if needed — also dot-prefixed, also outside public/).# Pseudo-recipe — the actual ffmpeg step lives in your conversion script:
ARCHIVE="<project>/.ogimage-archive"
mkdir -p "$ARCHIVE"
if [[ -f "public/ogimage__Perplexed--BannerTall.jpg" ]]; then
mv "public/ogimage__Perplexed--BannerTall.jpg" \
"$ARCHIVE/ogimage__Perplexed--BannerTall--$(date +%Y-%m-%d).jpg"
fi
ffmpeg -y -i "<new-pick>.png" -q:v 2 "public/ogimage__Perplexed--BannerTall.jpg"The unfurler URL stays stable (it's still …/ogimage__Perplexed--BannerTall.jpg); only the bytes behind it change. Old bytes survive in .ogimage-archive/ with a date stamp so you can A/B which version unfurled how.
Without Layer 1, repeat runs erase candidates we considered. Without Layer 2, the splash's deployed history becomes irrecoverable as soon as you generate v2.
negative_prompt with the failure mode (subject in top half). Empty space won't be left as residue; it has to be declared, named, and given content. See the "Composition" subsection above for the full rule and the iter1→iter3 evidence that drove the refinement.banner_tall alongside banner.content-farm/splash/DESIGN.md is canonical (frontmatter groups, eight prose sections in order). The values (palette, fonts, sizes) belong to Content Farm and must be re-authored for each new project.public/ verbatim. ~16 MB of PNG candidates ships to GitHub Pages otherwise. Keep raw candidates at <project>/.ideogram-candidates/ — dot-prefixed, outside public/.AUTO whenever style_reference_images is set.DESIGN.md having (or lacking) an imagery: block doesn't tell you what's actually live in production. Run the Case protocol section above — a hand-crafted or text-driven image already shipping is a deliberate choice, not a stale placeholder, until proven otherwise.templates/imagery-block.yaml in this skill — the canonical imagery: recipe block, ready to drop into a new DESIGN.mdtemplates/ideogram-request.sh in this skill — the canonical curl invocationcontent-farm/splash/DESIGN.md — the canonical full-example of a Lossless DESIGN.md with both the standard Stitch sections and the imagery: recipemaintain-design-md skill — owns Step 1 of this flow when no DESIGN.md exists, plus the maintenance discipline (drift audits, when code changes should bounce back into the doc)open-graph-share-seo-geo skill — sibling skill on the delivery side: where to host the file, what HTTP headers it needs, JPEG-over-WebP rule, the og:image:type invariant, cache-busting recipescrawl-fetch-ingest skill — the precedent for the ~/.secrets pattern this skill borrowsastro-knots skill — when scaffolding a new site that needs a DESIGN.md from scratch~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.