colophon can generate images from a text prompt with an AI provider, anywhere it
accepts an image: a post's hero:/image: frontmatter and Markdown body embeds. A
generated image is cached on disk (and committed with your content), so it is produced
once and then ships through the normal asset pipeline
like any other image — including routing to an object store.
The feature is opt-in and off until you configure a provider.
Writing a gen: reference
Anywhere you'd put an image path, write gen: followed by the prompt.
In frontmatter:
---
title: On Patience
hero: "gen:a weathered fisherman waiting by a still lake at dawn, photoreal"
hero_alt: "A fisherman waiting by a calm lake at first light"
image: "gen:a minimalist book cover, muted teal palette"
---
In the body — wrap the prompt in <…> whenever it contains spaces (plain Markdown
image syntax stops at the first space):

The alt text works exactly as for any image — write it for meaning, leave it empty for purely decorative banners.
Tuning a single image
Append query-style parameters to the prompt:

aspect (e.g. 1:1, 16:9, 9:16) is the common one; available parameters depend on
the provider. Per-reference parameters override the defaults: in your config.
Reuse
Two references with the same prompt, model and parameters resolve to the same generated file — so a hero and an in-body embed sharing one prompt are generated once. Change the prompt, the model, or a parameter and you get a new image.
Configuration
Add a generation: block to colophon.yaml:
generation:
image:
provider: minimax # which service to use (see table below)
model: "" # optional — defaults per provider
defaults: # optional — params applied to every prompt
aspect: "16:9"
output_dir: "" # optional — default: content/assets/generated
concurrency: 5 # optional — max images generated at once
system_prompt: "" # optional — house style; overrides the theme's (see below)
# api_key: "{env:MINIMAX_API_KEY}" # optional — see "API keys" below
Providers
provider |
Default model | API key (environment variable) |
|---|---|---|
google |
gemini-3.1-flash-image |
GEMINI_API_KEY (or GOOGLE_API_KEY) |
minimax |
image-01 |
MINIMAX_API_KEY |
openai |
gpt-image-1 |
OPENAI_API_KEY |
xai |
grok-imagine-image-quality |
XAI_API_KEY |
together |
(set model:) |
TOGETHER_API_KEY |
deepinfra |
(set model:) |
DEEPINFRA_API_KEY |
custom |
(set model:) |
set api_key: |
custom targets any OpenAI-compatible images endpoint — also set base_url: and
api_path: (e.g. /images/generations).
xai is Grok Imagine: xAI's OpenAI-compatible images endpoint. Set
model: grok-imagine-image for the cheaper tier, and use aspect as usual (it is
sent as xAI's aspect_ratio):
generation:
image:
provider: xai # Grok Imagine — reads XAI_API_KEY
defaults:
aspect: "16:9"
API keys
Keys are read from the environment, never required in the file:
- If you set
api_key:(typically"{env:VAR}"), that value is used. - Otherwise colophon reads the provider's default environment variable from the table.
Either way the secret stays in your shell / .env / CI secrets, not in colophon.yaml.
House style (theme system prompt)
So you don't repeat the same look in every prompt, a house style is applied to every generated image — describe only the subject in each post and let the style come from the theme. The style is resolved in this order (first wins):
- Per-reference
?systemprompt=…(below) - Site config —
generation.image.system_prompt - Theme —
image.genai.system_promptin the theme'stheme.yaml - Theme — the theme's
description(a gentle fallback)
A theme sets its style in theme.yaml:
# themes/<name>/theme.yaml
description: "A clean editorial broadsheet theme."
image:
genai:
system_prompt: "editorial illustration, muted palette, soft depth, no text or lettering"
So hero: "gen:a lighthouse on a rocky coast" is generated in the theme's style with no
extra wording in the post.
Per image, you can override or switch it off with the reserved systemprompt parameter:
?systemprompt=bold woodcut print, two-tone— use this style instead for this image?systemprompt=none(ornil, or empty) — no house style for this image

The house style is part of the cache identity, so changing it (in the theme, the config, or a post) regenerates the affected images.
Audio readings (podcasts)
A post can carry an audio reading — generated by AI or a file you recorded yourself. Both
become a player in the theme and a podcast <enclosure> in the feeds (RSS/Atom/JSON).
# pre-recorded — no AI, just attach a file (copied like a hero image)
audio_file: episode.mp3
# AI text-to-speech: omit to use the site default; set true/false to force per-post
audio: true
audio_voice: "English_Graceful_Lady" # optional; else the author's/persona's voice, else the default
audio: is optional and three-state: omit it to follow the site default (read aloud when
a speech provider is configured), or set true/false to force it for this post. audio_file
wins if both are set. The reading voice resolves: post audio_voice → the
author's (or persona's) voice: → the generation.speech default.
Configure the speech provider alongside the image one:
generation:
speech:
provider: minimax # MiniMax t2a (more providers later)
model: speech-2.6-hd
voice: "English_Graceful_Lady"
format: mp3 # default — the podcast-portable, now-patent-free choice
Authors/personas can carry a default voice (e.g. a cloned voice id):
# authors/sam.yaml
id: sam
name: Sam Avery
voice: "English_Graceful_Lady"
Themes get has_audio (bool) and audio (URL) to render a player or filter audio posts. The
press theme shows a themed play/pause + scrubbable waveform player under the byline.
Waveform. The player derives the waveform from the audio itself, in the browser (Web Audio
decodeAudioData), on first play — so generated readings cost a single TTS call, not a second one,
and the computed peaks are cached in localStorage for instant redraws. For a recorded file you
can still ship a precomputed episode.mp3.json of the form {"peaks":[0.1, 0.7, …]} (values 0–1)
next to it — e.g. produced with ffmpeg/audiowaveform — and it renders instantly (paused,
pre-play); a .wav is read directly at build. Until peaks exist, the player shows a resting shape
or a live Web Audio visualiser (same-origin). When audio is routed cross-origin to an object store,
the in-browser decode and any peaks fetch need CORS (a GET policy) on the bucket.
What gets read aloud
Blocks that don't translate to speech — code, math, tables, diagrams — aren't read verbatim (reading code or LaTeX aloud is gibberish). By default each is replaced with a short spoken cue ("Here, the post shows a Go code example."), the first cue adds "Visit the post to view it.", and a closing note is appended. Prose, headings, lists, blockquotes and callouts are read normally. Tune it per type:
generation:
speech:
transcript:
blocks: # cue | drop (silent) | keep (read the text)
code: cue
math_display: cue
math_inline: drop
table: cue
diagram: cue
inline_code: spell # spell | keep | drop — "spell" voices symbols ("/etc" → "slash etc")
wrap_up: true # append the closing "visit the post" note
Acronyms in your glossary are read as their expansion so they're
spoken as words, not letters — SSH → "Secure Shell", DDD → "Domain Driven Design". Only
entries that look like acronym expansions qualify (an all-caps term with a short Title-Case
definition whose letters spell the acronym), so an ordinary term like Rust is left alone.
Turn it off with generation.speech.transcript.expand_acronyms: false.
For finer control, wrap content in the body:
<notts>…</notts>— shown on the page but never spoken (e.g. an aside, a pronunciation- hostile string).<tts>…</tts>— always read verbatim, overriding the rules above (e.g. a short code snippet you do want spoken).
Both the injected speech (cues, hint, wrap-up, inline-code symbol names) and the player UI
(the "Listen" caption and Play/Pause labels) are localised to the post's language (lang:
frontmatter, else the site lang). English, Spanish, French, German, Italian and Mandarin ship
built in; add or override any language with a project i18n/tts/replacements.json (same shape
as the built-in table). A missing language or field falls back to English.
(No LLM is involved — cues are fixed text. An abstractive summary reading would need a text-LLM provider, which colophon doesn't have yet.)
Turning generation on/off
Three nested switches, all defaulting on, all behaving the same way: when off, no new
assets of that kind are generated (no provider/API calls) even with --generate-ai, while
everything already generated and committed keeps being served.
generation:
enabled: true # master — turns ALL generation off in one line
image:
enabled: true # just images
speech:
enabled: true # just audio (also the per-post audio default — see below)
generation.speech.enabled does double duty: it's the audio guard and the default for a
post's audio: field. With a speech provider configured and speech enabled, every post
reads aloud by default; a post opts out with audio: false. Without a provider, audio is
off regardless (so speech effectively self-disables). Image generation has no per-post
equivalent (images are explicit gen: references), so image.enabled is purely the guard.
Cleaning up the cache
colophon doctor reports cached generated assets that no content references any more (after
an edited prompt, a changed style/model/voice, or a deleted post). colophon doctor --prune
deletes them (and their sidecars).
Image post-processing
Some providers bake black letterbox bars into images. colophon removes them deterministically
after generation (generation.image.postprocess.trim_letterbox, on by default): it crops
solid black borders and re-frames to the requested aspect. It's a no-op on clean images and
needs no configuration; set it to false to disable.
Generating images
Generation is a separate, explicit step — ordinary builds never call the provider:
colophon build --generate-ai
This produces only the images that aren't already cached, writing each into
output_dir alongside a .json sidecar that records the prompt, provider, model,
parameters and date. Already-cached images are reused with no API call. Generations run
in parallel (up to concurrency, default 5 — lower it if your provider rate-limits you).
--generate-ai is also a flag on colophon publish, so a deploy can generate any
still-uncached media first: colophon publish --env production --generate-ai. (The same
applies to TTS audio readings — without the flag, an uncached reading is skipped with a
build --generate-ai to create it hint.)
Then commit the generated files (content/assets/generated/ by default). The cache
is part of your content: it makes builds reproducible and means a plain colophon build
or colophon publish — including in CI — needs no API key and incurs no cost.
If a gen: reference has no cached image and you build without --generate-ai (or the
provider errors, or no provider is configured), colophon warns and skips it — the
build still succeeds, the image is just absent, exactly like any other missing asset.
colophon doctor will not flag gen: references as broken.
Good to know
- File format follows the provider. colophon names each file by its actual content
(e.g. MiniMax returns JPEG, so you get
.jpg), so the servedContent-Typeis always correct. - Changing the model or a default reuses nothing — it's a new prompt identity, so the old image stays in the cache until you delete it. There is no automatic pruning yet.
- Provider model names change. Pin
model:if you need stability; the defaults track each provider's current recommended image model.
Generated from docs/image-generation.md — edit it there.