Lith renders a short brief into a style-locked image-generation prompt, calls xAI, OpenAI, or MiniMax through one inspectable provider layer, then validates and publishes the image that comes back.
What lith is. Seven fixed visual style families in
styles.json, each a prompt template with a few
substitution slots. A JSON recipe format that makes a brief re-runnable. A
poster spec β headline, subtitle, sections, diagram, footer β serialized into
the prompt as a literal copy block the model is ordered to reproduce character
for character, so no shipped word was invented by the model. Fifteen named
layouts and a resolution chain that picks one from the shape of your content
and the shape of the frame. Three console scripts, eight root-level convenience
functions, and a typed lith.call API. Standard library only, no vendor SDKs or
external binaries.
What lith is not. It is not a vendor SDK or an autonomous publisher.
lith-press performs generation, but it does not score or rank candidates, post
to any platform, do video, or turn an existing post into a brief. Candidate
selection and publication remain explicit handoffs; lith-print with no image
source prints its plan and exits 0.
New here? Work through Tutorial: your first announcement image β a brief to a finished image in six steps, with a path that needs no API key.
Where the pipeline draws its lines, and why it now makes the call itself: About the pipeline. How the same spec renders seven ways: About output styles. How panels get arranged: About layouts. The palette and composition rules underneath: About the design language.
Driving lith from a Hermes session: skills/lith/SKILL.md,
and how to install it.
- Why & use cases
- Install
- Install the Hermes skill
- CLI reference β
lith-plateΒ·lith-pressΒ·lith-print - Python API β full detail in the API reference
- Recipe format
- Style families
- Layouts
- Aspect ratios
styles.jsonschema- Output paths
- Tests
- Status
Plain prompting gets you an image. It does not reliably get you that image again, in your frame, saying the words you wrote.
| Prompting a model directly | lith |
|---|---|
| The model paraphrases your headline | The poster copy is serialized into the prompt as a literal block the model is ordered to reproduce character for character |
| "same style as last time," from memory | Seven fixed families in styles.json β template, negative prompt, palette, default aspect, all data |
| You get whatever ratio the provider felt like | Aspect resolves explicit β content shape β family default β per-model clamp, and lith-print --strict measures the delivered file and exits nonzero when it drifted |
| The prompt lives in chat scrollback | A JSON recipe re-renders byte-identically forever, to a deterministic output path |
| One vendor's SDK and payload shape | One ImageRequest across xAI, OpenAI and MiniMax, with the exact body inspectable before you spend |
That last row is not hypothetical. Two sweeps of the 34-recipe testbed once ran
through a bridge that took prompt and silently dropped model, n, and
aspect_ratio. Every image came back, every frame was wrong, and nothing in
the returned bytes said so β
the full story.
Ways to leverage it:
- From an agent or harness. Three commands, stable exit codes, and
--emit-jsonon both planning steps.lith-press --checkverifies the route before spending;lith-print --strictturns a silently substituted ratio into a nonzero exit a sweep script can actually catch. - From a Hermes session.
skills/lith/SKILL.mdships in the repo β the session picks a family, generates, and hands you candidates without improvising a prompt. - From Python. Eight root-level functions plus the typed
lith.callAPI, for wiring image generation into an existing app. The prompt-side modules import no network code at all, so you can render and diff prompts in a unit test. - As a prompt compiler for a provider lith doesn't support.
lith-plate --recipe R --press --emit-jsongives you the envelope; take it to any API you like and skiplith-pressentirely. - In batch and in CI. Recipes are files, so a content calendar is a
directory. Every preview mode β
--press,--dry-run,--check,--auth, andlith-printwith no image β is offline and free.
Not what you want if you need one-off concept art, or a tool that picks the best candidate and posts it for you. Selection and publication stay yours.
Requirements:
| Python | 3.10 or newer (the library uses PEP 604 union syntax) |
| uv | brew install uv on macOS |
As a tool:
uv tool install git+https://github.com/funsaized/lith
lith-plate --helpAs a library in another project:
uv add git+https://github.com/funsaized/lithAs a contributor:
git clone https://github.com/funsaized/lith.git
cd lith
uv sync --extra testuv sync --extra test creates .venv/, resolves pyproject.toml, and installs
the project editable, which places lith-plate, lith-press, and lith-print
in the venv.
Verify:
uv run lith-plate \
--topic "test" --style B --aspect 16:9 --headline "32 LANGS" --icon "globe"
uv run lith-press --help
uv run python -c "from lith import render_prompt"All three print and exit 0.
skills/lith/SKILL.md lets a Hermes session drive the
three CLIs on your behalf. It is a workflow wrapper only β every deterministic
behavior lives in the lith package, which the skill assumes is already
installed. Install the package first; the skill is useless without it.
Hermes discovers skills at ~/.hermes/skills/<name>/SKILL.md. Put lith's there.
Symlink β the right choice from a checkout, since edits to the repo copy take effect on the next Hermes restart. Run it from the repository root:
mkdir -p ~/.hermes/skills
rm -rf ~/.hermes/skills/lith
ln -s "$PWD/skills/lith" ~/.hermes/skills/lithThe rm -rf is load-bearing. If a real directory is already at that path β a
copy install, or a version from before the skill shipped in this repository β
ln -s puts the link inside it at ~/.hermes/skills/lith/lith and exits 0.
Nothing errors, and Hermes goes on loading the stale SKILL.md. Adding -fn
does not help; macOS cannot unlink a real directory that way.
Copy β for a checkout you intend to delete:
mkdir -p ~/.hermes/skills/lith
cp skills/lith/SKILL.md ~/.hermes/skills/lith/SKILL.mdIf you installed lith with uv tool install and have no checkout, clone the
repository for the skill file alone:
git clone --depth 1 https://github.com/funsaized/lith.git /tmp/lith
mkdir -p ~/.hermes/skills/lith
cp /tmp/lith/skills/lith/SKILL.md ~/.hermes/skills/lith/SKILL.mdRestart Hermes. Its session-start loader reads the skills directory once at startup, so a skill added or edited mid-session is not picked up.
Verify:
test -f ~/.hermes/skills/lith/SKILL.md && echo "skill resolves"
test ! -e ~/.hermes/skills/lith/lith && echo "not nested"
lith-plate --help >/dev/null && lith-press --help >/dev/null \
&& lith-print --help >/dev/null && echo "cli ok"All three lines must print. The second is what catches the nesting trap above β the first passes either way.
Then ask the session for something the skill covers β "generate a family B announcement image for X" β and confirm it checks the route before generating rather than improvising a prompt.
To update the skill after pulling: symlink installs need nothing but a Hermes
restart; copy installs need the cp re-run. To uninstall,
rm -rf ~/.hermes/skills/lith.
Worth knowing before you hand a session the keys β the file is short and worth reading in full:
- Run
lith-press --check, generate with the selected route, then finish throughlith-print --strict. - Use absolute paths for recipes and images.
- Ask the user to pick when candidate selection is subjective.
- Never publish, post, or upload without separate authorization.
- Write the full poster spec into the brief, because every word in it is printed into the image verbatim.
The skill declares platforms: [linux, macos] and needs nothing beyond a
Python install on either.
Renders a brief into a prompt. Prints a human-readable summary by default;
with --press, prints a press envelope instead.
lith-plate --recipe PATH [options]
lith-plate --topic TEXT --style {A..G} --headline TEXT [options]
| Flag | Type | Default | Description |
|---|---|---|---|
--recipe |
path | β | Recipe file. Supplies the brief, model, and n. |
--topic |
str | β | One-sentence brief. Required without --recipe. |
--style |
AβG |
β | Style family. Required without --recipe. |
--headline |
str | β | In-image headline. Required without --recipe. |
--aspect |
provider ratio union | family default | One of 1:1 3:4 4:3 9:16 16:9 2:3 3:2 9:19.5 19.5:9 9:20 20:9 1:2 2:1 21:9 auto. Use a concrete ratio for lith-press. |
--icon |
str | gear |
Motif substituted into {icon}. |
--n |
int | 4 |
Candidate count recorded in the envelope. |
--seed |
int | None |
Seed recorded in the envelope. |
--model |
see Aspect ratios | grok-imagine-image-2.0 |
Model recorded in the envelope for a later lith-press. |
--out |
path | derived stem | Output path recorded in the envelope, verbatim. Without it, the derived value carries no extension. |
--press |
flag | off | Emit the envelope instead of the summary. |
--emit-json |
flag | off | With --press, emit JSON rather than key=value lines. |
With --recipe, the recipe's model and n win and --model / --n are
ignored; --seed, --out, --press, and --emit-json still apply.
Envelope fields, in order: prompt, negative_prompt, aspect_ratio,
model, n, seed, output_path, style, aspect_note, copy_note,
limit_notes.
aspect_note is null unless the model forced a different ratio. copy_note
is null unless the copy block is too thin for the template around it β a
brief with no sections renders about sixteen characters of copy against
fifteen hundred of instructions, and the model starts lettering the
instructions instead. limit_notes names model-specific n or prompt-length
violations without changing the prompt.
uv run lith-plate --recipe recipes/live_test_recipe.json --press --emit-jsonExit codes: 0 on success, 2 on an argparse error (including a missing
--topic/--style/--headline when --recipe is absent).
Turns a rendered recipe into real candidate bytes through xAI, OpenAI, or MiniMax. Inspect the route and exact provider payload before spending money.
lith-press --recipe PATH [--out DIR] [--n N] [--resolution {1k,2k}]
[--quality {low,medium,high}] [--seed N]
[--dry-run | --check | --auth] [--emit-json]
| Flag | Type | Default | Description |
|---|---|---|---|
--recipe |
path | β | Recipe to render and call. Required except with --auth. |
--out |
directory | recipe's output directory | Candidate directory. Files are {family_key}_{slug}-c{index}.{ext}. |
--n |
int | recipe n |
Overrides the candidate count for this call. Provider maxima are xAI/OpenAI 10 and MiniMax 9. |
--resolution |
1k 2k |
β | xAI resolution. Other adapters report it in unsupported. |
--quality |
low medium high |
β | OpenAI quality. Other adapters report it in unsupported. |
--seed |
int | β | MiniMax seed. Other adapters report it in unsupported. |
--dry-run |
flag | off | Print the exact provider URL, redacted headers, request body, and unsupported fields; make no call. |
--check |
flag | off | Print whether Hermes image_generate or lith-press preserves the recipe's model and aspect; make no call. |
--auth |
flag | off | Report each provider's resolving credential tier and short fingerprint, never its value. --recipe is optional. |
--emit-json |
flag | off | Emit JSON for --auth, --check, or a live result. --dry-run is always JSON. |
--dry-run, --check, and --auth are mutually exclusive. A live call writes
one file per CallResult.candidates entry, choosing the extension from the
bytes rather than the model id. Human output also prints any reported model,
aspect, revised prompt, cost, and every unsupported field; JSON output carries
the same metadata plus the provider's raw response.
Credentials resolve in this order: shell environment, the recipe repository's
.env, ~/.hermes/.env, then compatible OAuth entries in
~/.hermes/auth.json. The strict variable names are XAI_API_KEY,
OPENAI_API_KEY, and MINIMAX_API_KEY; copy .env.example for
a repo-local setup. A repo .env is ignored by Git. lith-press --auth shows
which tier won.
MiniMax is implemented but its 1500-character prompt cap is lower than every current integration recipe. The adapter raises before any network call and names the measured length and cap; compact templates are a separate design task.
uv run lith-press --check --recipe recipes/integration/24-aspect-ultrawide.json
uv run lith-press --dry-run --recipe recipes/integration/01-stack-A.json
uv run lith-press --recipe recipes/integration/01-stack-A.json --n 2 --emit-jsonExit codes: 0 on success and 2 on an argparse error. Credential, request,
transport, or provider failures terminate nonzero without writing candidates.
Validates a generated image and publishes it under the recipe's deterministic path. With no image source, it prints its plan and exits.
lith-print --recipe PATH [--image-url URL | --image-file PATH] [options]
| Flag | Type | Default | Description |
|---|---|---|---|
--recipe |
path | required | Recipe file. |
--image-url |
url | β | HTTP(S) URL of the generated image. Mutually exclusive with --image-file. |
--image-file |
path | β | Local generated image. Mutually exclusive with --image-url. |
--output-dir |
path | beside the recipe | Directory for the published file. Defaults to the recipe's sibling outputs/, not the cwd. |
--strict |
flag | off | Exit 1 when the delivered frame does not match the request. The file is still published. |
Two modes:
| Condition | Behavior |
|---|---|
No --image-url and no --image-file |
Prints recipe, family, style, aspect, model, prompt, and output path; exits 0. Nothing is written. |
| Image source | Writes the image to the recipe's output path, extension sniffed from the bytes; exits 0, or 1 under --strict if the frame drifted. |
lith-print is the only step that compares the delivered frame against the one
the prompt was composed for, so it is the only place a silently substituted
ratio becomes visible. Without --strict that comparison is a [warn] line on
stdout and the command still exits 0 β fine when a person is reading the
output, useless to a sweep script scraping exit codes. Pass --strict in
batches. The image is published either way, because the bytes are the evidence
you need to diagnose the substitution.
--image-url fetches under four guards: HTTP(S) schemes only, re-checked after
redirects; 30-second timeout; 25 MB ceiling enforced while streaming; and a
structural validation for JPEG, PNG, or WebP before any write. --image-file skips
the network guards, keeps structural validation, and no-ops the copy if source
and destination resolve to the same path.
Bytes are staged as <stem>.part and renamed once the format is known, because
the extension cannot be chosen before the bytes are inspected. The published
extension follows the image, not the recipe: Grok returns JPEG, gpt-image-1
returns PNG. Nothing is re-encoded.
uv run lith-print \
--recipe recipes/live_test_recipe.json \
--image-file outputs/B_brutalist_32_langs_raw.jpgExit codes: 0 on success, 2 on an argparse error. A failed download or a
non-image body raises and terminates with a traceback.
from lith import ... exposes eight names. Full signatures, return shapes,
exception tables, and internals for every module β
Python API and implementation reference.
| Name | Signature | Purpose |
|---|---|---|
render_prompt |
(style, brief=None) -> dict[str, str] |
Substitute a brief into a family template. Returns prompt, negative_prompt, aspect_ratio, style. |
load_recipe |
(path) -> Recipe |
Read and validate a recipe file. |
recipe_from_brief |
(brief, *, style, model, n, ...) -> Recipe |
Validate generated data and construct a recipe. |
validate_brief |
(brief) -> dict |
Validate brief shape and authored field types. |
expand_brief |
(topic, llm_cmd, ...) -> dict |
Expand a topic into a brief using an LLM command you supply. |
parse_brief_response |
(text) -> dict |
First decodable JSON object in an LLM reply. |
output_path |
(out_dir, family_key, headline, ext) -> Path |
Derive an artifact path. |
slug |
(text) -> str |
Filename-safe slug; "untitled" when empty. |
from lith import load_recipe, render_prompt
recipe = load_recipe("recipes/live_test_recipe.json")
rendered = render_prompt(recipe)Recipe, FAMILY_KEYS, REQUIRED_BRIEF_KEYS, load_styles, get_family, and
DEFAULT_PROMPT are not in __all__ but are importable from their modules and
used by the console scripts.
Provider calls live in the separate lith.call API:
from lith import load_recipe, render_prompt
from lith.call import ImageRequest, generate
recipe = load_recipe("recipes/integration/01-stack-A.json")
rendered = render_prompt(recipe)
result = generate(ImageRequest(
prompt=rendered["prompt"],
model=recipe.model,
aspect=rendered["aspect_ratio"],
n=recipe.n,
negative_prompt=rendered["negative_prompt"],
))result.candidates always contains decoded bytes. result.unsupported makes
every supplied field the selected provider could not accept visible; no adapter
appends such a field to prompt. See the
full lith.call reference.
A recipe is a JSON object. See
recipes/live_test_recipe.json.
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
style |
"A"β"G" |
yes | β | Style family letter. |
brief |
object | yes | β | Substitution values; see below. |
name |
string | no | file stem | Recipe identifier. |
description |
string | no | null |
Free text; not used at runtime. |
model |
string | no | grok-imagine-image-2.0 |
Must be a model in the capability table below. |
n |
int | no | 4 |
Candidate count; must be within the selected model's provider limit. |
brief keys:
| Key | Required | Used for |
|---|---|---|
topic |
yes | Validation and human context; not substituted into any template. |
headline |
yes | The spec's TITLE: line and the output filename. |
icon |
yes | {icon} slot. |
aspect |
no | Pins the ratio. Omit to derive it from content shape, then the family default. |
volume |
no | {volume} slot; family C only. Defaults to "1". |
title |
no | Overrides headline in the spec's TITLE: line only; the filename still uses headline. |
subtitle |
no | Spec SUBTITLE: line, and a subtitle zone in {layout}. |
sections |
no | List of {heading, lines} objects β the section panels. heading is required on each; lines is 2β4 strings. |
diagram |
no | One sentence naming every label in a simple drawing; adds a drawing zone. Described, not lettered β only the labels it names appear as text. |
diagram_position |
no | below (default) Β· above Β· beside Β· center. radial forces center. |
layout |
no | Arrangement for the section panels; see below. Omit to derive it from section count and frame shape. |
footer |
no | One short line under a horizontal rule. |
base_color |
no | Overrides the family palette's background in {base_color}. |
accent |
no | Overrides the family palette's accent in {accent}. |
All seven families carry {spec} and {layout}, so spec keys reach every one
of them. A brief with no sections degrades to a title-only spec, which is
what every pre-spec recipe produces.
Recipes are validated before rendering: style and model must be known, n
must fit the model limit, authored fields must be non-empty strings, sections
must contain headings and string lists, layout keys must be known, and aspect
must be auto or a positive W:H ratio. validate_brief and
recipe_from_brief expose the same boundary for data returned by
expand_brief.
{
"name": "live_test_recipe",
"style": "B",
"brief": {
"topic": "Dill Pickles and all things great about them",
"headline": "DILL PICKLES",
"icon": "lightning",
"aspect": "1:1",
"sections": [
{"heading": "01 - COLD CRUNCH", "lines": ["Firm cucumbers snap with every bite"]}
]
},
"model": "grok-imagine-image-2.0",
"n": 1
}Seven families, defined in src/lith/data/styles.json.
That file is authoritative for prompt text; this table is the index.
Every family carries {spec} and {layout}. The copy path is identical
across all seven β the brief supplies every word, the template supplies only
how those words are drawn. "Extra slots" below lists what a family uses
beyond those two.
| Letter | Key | Name | Default aspect | Extra slots | Best for |
|---|---|---|---|---|---|
| A | A_sticker |
Sticker / whisper-joke infographic | 16:9 | {accent} |
Quick reactions, ship announcements, POV jokes |
| B | B_brutalist |
Sci-fi brutalist UI | 16:9 | {icon} |
Feature flagships, capability reveals |
| C | C_patent |
Vintage technical manual / patent diagram | 2:3 | {icon} {volume} |
How-it-works posts, educational threads |
| D | D_manga |
Manga tape-insert / risograph | 2:3 | {base_color} |
Release announcements, chapter framing |
| E | E_screenshot |
Editorial screenshot polish | 16:9 | β | UI demos, product launches |
| F | F_woodcut |
Woodcut / analog engraving | 2:3 | {icon} |
Sponsor announcements, team memos |
| G | G_log |
Role-log / status dashboard | 16:9 | {icon} |
Operational posts, build-in-public stats |
Slot resolution, per render_prompt:
| Slot | Source | Fallback |
|---|---|---|
{headline} |
brief["headline"] |
"NEW" |
{icon} |
brief["icon"] |
"gear" |
{volume} |
brief["volume"] |
"1" |
{base_color} |
brief["base_color"], else palette["background"] |
"#000000" |
{accent} |
brief["accent"], else palette["accent"] |
"#00E5FF" |
{spec} |
the brief's copy fields, serialized | title-only block |
{layout} |
the zones the brief has copy for | title zone alone |
A palette field holding a list is joined with " | " β for example
A_sticker's four accents render as
#FF2E88 | #00E5FF | #F2FF00 | #FF6B35. An empty list falls back to the
default. The brief wins over the palette, so a family listing three
backgrounds needs the recipe to name one, or the prompt asks for a "single
flat background" and then lists three colors.
brief.layout selects how section panels are arranged. Omit it and lith derives
one from the panel count and the frame's orientation.
| Key | Arrangement |
|---|---|
stack |
One full-width column |
two-column Β· three-column |
Balanced columns |
grid-2x2 Β· grid-2x3 Β· grid-3x2 Β· grid-3x3 |
Strict grids |
hero |
First panel full width at double height, rest in a grid beneath |
sidebar |
First panel a tall left rail, rest stacked to its right |
timeline |
Vertical sequence on a spine, each panel stepped right |
radial |
Panels ringed around a centred drawing on leader lines |
masonry |
Two columns of unequal height, no two tops aligned |
zigzag |
Alternating left/right, offset and rotated a degree or two |
split |
Two facing groups either side of one strong rule |
diagonal |
Stepping upper-left to lower-right, corners overlapping |
Derived when layout is absent:
| Panels | Portrait | Landscape |
|---|---|---|
| 1 | stack |
stack |
| 2 | two-column |
two-column |
| 3 | hero |
three-column |
| 4 | grid-2x2 |
grid-2x2 |
| 5 | hero |
hero |
| 6 | grid-2x3 |
grid-3x2 |
| 7β9 | two-column |
grid-3x3 |
Column counts cap at two in portrait: three narrow columns of body copy in a tall frame is where legibility goes first.
Why the families exist, what they have in common, and how to rotate them: About the design language.
brief.aspect pins a ratio. Omit it and lith resolves one, in this order:
brief.aspect, when set- content shape β 3+ sections resolve portrait
2:3, 1β2 resolve1:1 - the family's
default_aspect 16:9
Whatever those choose is then clamped to what the recipe's model can actually
produce. A model does not reject a ratio it lacks; it silently substitutes one,
so lith substitutes first and says so.
| Model | Capability variant | Limit |
|---|---|---|
grok-imagine-image-2.0 (default) |
ratio enum | 1:1 3:4 4:3 9:16 16:9 2:3 3:2 9:19.5 19.5:9 9:20 20:9 1:2 2:1 (auto is recorded but never sent) Β· n β€ 10 |
grok-imagine-image-quality, grok-imagine-image |
ratio enum | 1:1 16:9 9:16 4:3 3:4 3:2 2:3 2:1 1:2 Β· n β€ 10 |
gpt-image-2, gpt-image-2-2026-04-21 |
constrained pixel range | edges divisible by 16 and β€3840, ratio 1:3β3:1, 655,360β8,294,400 pixels Β· n β€ 10 |
gpt-image-1.5, gpt-image-1, gpt-image-1-mini |
fixed pixel sizes | 1024x1024 1536x1024 1024x1536 (auto is recorded but never sent) Β· n β€ 10 |
image-01 |
ratio enum | 1:1 16:9 4:3 3:2 2:3 3:4 9:16 21:9 Β· n β€ 9 Β· prompt β€1500 characters |
MODEL_ASPECTS stores a ModelCapability for each row rather than a set.
Exactly one of ratio_enum, pixel_sizes, or pixel_range is populated;
n_max and optional prompt_max_chars travel with it. For OpenAI models,
pixel_size(model, aspect) translates a ratio to WIDTHxHEIGHT: the 1.x line
uses its fixed lookup, while gpt-image-2 searches the constrained range.
The table above is about frames. It says nothing about whether a model can letter forty lines of authored copy correctly, and they differ sharply. Measured across the integration testbed on 2026-08-16, one candidate per recipe:
| Model | Dense spec copy |
|---|---|
grok-imagine-image-2.0, grok-imagine-image-quality, grok-imagine-image |
reliable β 16 of 17 rows letter-perfect |
gpt-image-2, gpt-image-2-2026-04-21 |
reliable |
gpt-image-1.5 |
reliable |
gpt-image-1, gpt-image-1-mini |
not suitable |
The 1.0 tier fails structurally rather than cosmetically: whole sections vanish,
headings desync from the bodies beneath them, and panels duplicate. One row
rendered 01 - THE MESH above section 01's content and dropped section 02
entirely; another printed MagicONS for MagicDNS and lost 04 - INGRESS.
The frames were exact and lith-print --strict exited 0 for every one of them β
this is a copy-fidelity property, and no exit code detects it.
Use gpt-image-1 and gpt-image-1-mini for a title-and-subtitle poster, or not
at all. For anything with sections, prefer gpt-image-2, gpt-image-1.5, or
the Grok line.
When a clamp happens, lith-plate prints warning: ... on stderr and sets
aspect_note in the envelope; lith-print prints it as a [warn] line.
lith-press prints the same warnings on stderr and carries aspect_note,
copy_note and limit_notes in its --emit-json payload, including under
--check and --dry-run.
lith-print also compares the published image's real dimensions against the
request and warns when they differ by more than 2%, which catches a provider
that ignored the field entirely.
version string schema version ("1.0.0")
description string free text
families object family key -> family object
rules object authoring constraints; advisory, not enforced at runtime
Family object:
| Field | Type | Read by | Description |
|---|---|---|---|
name |
string | render_prompt |
Human-readable name returned as style. |
prompt_template |
string | render_prompt |
str.format template; slots per the table above. |
negative_prompt |
string | render_prompt |
Returned verbatim. |
default_aspect |
string | render_prompt |
Used when the brief omits aspect. |
palette |
object | render_prompt |
Only background and accent are substituted; other keys document the family. |
best_for |
string[] | β | Documentation. |
iconography |
string[] | β | Documentation; suggested icon values. |
rules β max_accent_colors, max_words_in_image,
always_oversize_headline, always_one_decorative_motif,
prefer_asymmetric_composition, always_one_idea_per_image β are an authoring
checklist. No code reads them. They describe the sparse families (A, B, C, E,
F, G); a spec-driven family carrying {spec} and {layout} deliberately
overrides max_words_in_image and always_one_idea_per_image, since a dense
poster is many ideas and a hundred-odd words on purpose.
Pass an alternate file with load_styles(path); the CLIs always use the
bundled copy.
The filename derives from output_path(dir, family_key, headline, ext):
| File | Pattern | Example |
|---|---|---|
| Published image | {family_key}_{slug(headline)}{ext} |
outputs/B_brutalist_32_langs.jpg |
The directory is --output-dir for lith-print, defaulting to the recipe's
sibling outputs/; lith-plate derives the same directory from --recipe,
and falls back to ./outputs in flag mode where there is no recipe to anchor to. lith-print sniffs ext from the image bytes β
.jpg, .png, or .webp. lith-plate has no bytes yet, so a path it
derives is a bare stem and both commands print it the same way, as
{stem}.<jpg|png|webp>. An explicit --out is recorded verbatim instead. The
file is overwritten without prompting when a recipe is re-run.
outputs/B_brutalist_32_langs_raw.jpg is a committed legacy Grok artifact used
as complete JPEG bytes by the tutorial and smoke test. The opt-in provider
canaries use the dill-pickle brief in recipes/live_test_recipe.json.
uv run pytest386 passed, 3 skipped
No credentials, no network. The three skips are live provider canaries, which run only when you explicitly authorize the spend β a populated API key does not enable them.
Full details, including the markers, the live canaries, and the branch-aware capability gates: Testing reference.
To retain live canary images for viewing, set an output directory and disable pytest capture so each saved path is printed:
LITH_LIVE_OUTPUT_DIR=outputs/canaries \
LITH_RUN_LIVE_PROVIDER_CANARIES=1 \
uv run pytest -m live_provider -s| Component | State |
|---|---|
Prompt rendering from styles.json |
done |
| Recipe loader and dry-run driver | done |
| Validate-and-publish driver | done |
Spec-driven poster copy ({spec} / {layout}) |
done, all seven families |
| Layout vocabulary (15 arrangements) | done |
| Aspect resolution and per-model clamping | done |
| Published-image aspect check | done |
| Direct xAI/OpenAI/MiniMax provider layer | done β lith-press / lith.call |
| Credential inspection and request dry-run | done |
Topic expansion (expand_brief) |
done, library only β no CLI |
Hermes SKILL.md wrapper |
done, shipped in skills/; installed manually |
| Candidate scoring | not built |
| Video augmentation | out of scope; rationale |
| Post β brief ingestion | out of scope; rationale |
| Calendar rotation tool | not built |
| Document | Type | Read it when |
|---|---|---|
| Tutorial: your first announcement image | Tutorial | Learning the pipeline by running it once |
| Python API and implementation reference | Reference | Calling the library, or reading the internals |
| Testing reference | Reference | Running the suite, markers, live canaries, coverage gates |
| About the pipeline | Explanation | Understanding what's built, what isn't, and why |
| About layouts | Explanation | Choosing an arrangement, or understanding the one lith derived |
| About output styles | Explanation | Choosing a family, and how one spec renders seven ways |
| About the design language | Explanation | The palette, typography and composition rules underneath |
skills/lith/SKILL.md |
Agent instructions | Checking what a Hermes session will do on your behalf |
| Contributing | How-to | Changing the CLI, adding a provider, or authoring a style |
| This README | Reference | Looking up a flag, a field, or a signature |
MIT licensed. See LICENSE.