flora-mockup-deck
Hosted MCP generation inputs are plural: call flora_create_generations with { "generations": [{ "workspace_id": "ws_…", "project_id": "prj_…", "type": "image", "prompt": "…" }] } (1–20 items). Put per-generation fields, including optional model, params, and reference_node_ids, inside each item. Read generations[] in the response; retain successful entries' run_id and handle failures individually. Poll flora_list_generations with { "run_ids": ["run_…"] }, even for one run; add technique_id for technique runs. Never retry successful items because another item failed.
Use dedicated tools for this workflow, including batches. execute is deprecated; use it only for an SDK operation without a dedicated tool. SDK examples below describe orchestration: use the corresponding dedicated tools, issue independent calls concurrently, retain every run id, and poll in later calls. Do not choose execute just to combine calls.
Attribution. Pass
skill: "flora-mockup-deck"on every FLORA call you make while running this skill —executeincluded — along with askill_run_idyou invent once when the run starts and reuse for the rest of it. Both are reporting only: they change nothing about the call or its result.
What it is
Not an image generator. A placement engine. By the time the placements fire, the creative is finished and frozen — the only job is putting it into the world convincingly, four ways, without altering it.
Usually the creative arrives finished. It can also be written: a line of text buys one generation, and that output becomes the master creative, from which point it is handled exactly like a supplied file. See "Two ways in".
The thing that makes a mockup fail is never the photograph. It is the artwork drifting: recoloured, recropped, re-lettered, a word dropped. So the whole skill is built around one invariant.
Two ways in
ARTWORK a url or an attached file -> 4 generations
WRITTEN a line of text, no artwork -> 5 generations (master + 4 placements)
/flora-mockup-deck followed by a description and nothing attached is the WRITTEN path:
create a campaign deck of a flower, a poster for a late-night ramen bar, out-of-home
for a running shoe launch. Nothing else about the skill changes. The text buys exactly
one image, that image becomes the master creative, and the law closes over it.
Making the master
One flora_create_generations, text-to-image, same model family as the placements:
{
"generations": [{
"workspace_id": "ws_…",
"project_id": "prj_…",
"type": "image",
"model": "t2i-gpt-image-2-t2i",
"prompt": "A finished out-of-home poster, flat artwork filling the frame. Apply the user's subject, headline and brand direction.",
"params": { "aspect_ratio": "3:4", "resolution": "4k", "quality": "high" }
}]
}
Prompt it as a printed poster, not as a photograph of a thing. The text names a subject, and a subject is not an ad — "a flower" un-elaborated returns a stock botanical photograph, which then gets pasted onto a gable end and reads as a picture of a flower on a wall rather than as a campaign. Build the master prompt as:
A finished out-of-home poster. Flat artwork, square on, filling the frame edge to
edge. Print-quality graphic design — NOT a photograph of a poster and NOT a mockup:
no wall, no frame, no shadow, no room, no perspective, no torn edges, no border.
THE SUBJECT — <the request, expanded into one concrete image>
THE TYPE — <the headline, or "no type at all">
Then the house grade clause, verbatim, exactly as the placements get it. The master and the four placements have to be graded the same or the deck reads as two different shoots.
The shape is yours to choose here, so choose portrait. A supplied creative forces the
site-matching problem below; a written one does not. aspect_ratio: "2:3" makes all four
default sites valid and skips the trap entirely. Go landscape only if the request names a
billboard or a landscape medium.
Type, when the request gives you no copy. An ad usually has words and a one-line request usually has none. Never generate a real company's logo or wordmark. Either write one short headline of your own or generate the poster with no type at all — then say in one line which you did, so the user can hand you copy and re-run instead of wondering why the poster is silent.
The gate — show the master, then fire
This is the one place the skill stops, and it is not a draft round. The master is not a preview of the deck; it is the thing the entire deck reproduces. Every placement carries it unchanged and nothing downstream is re-rollable, so a wrong master is four wrong placements with no way back — about $3.50 spent reproducing the wrong picture perfectly.
Fire the master. Report the url. Get a yes. Then fire the four placements in one pass and run to the end without stopping, exactly as the ARTWORK path does.
Re-rolling the master is allowed and bills again — say so before the second one. Once the user says yes the master is frozen and the law applies to it in full: from that point it is a supplied file that happens to have come from FLORA.
One shot, minimal outputs
The skill runs once and emits exactly the deliverable. No draft round, no preview pass, no variant sprawl.
1 generation the master creative — WRITTEN path only, skipped when artwork is given
4 generations the placements — one per site, not four angles on one site
3 resizes 1:1, 4:5, 9:16 — NOT generations, see below
1 contact sheet the four placements in one grid — needs the actions entitlement
1 PDF the annotated deck — free, and reachable on every surface
Four generations — five from a line of text — three deterministic resizes, one contact sheet. Nothing is repaired, re-rolled or replaced, and nothing is measured. Generate the four, build the resizes, package everything, ship. If a placement is visibly wrong, say so in a sentence.
The resizes are not generations
Regenerating a resize re-letters the type — the exact failure this skill exists to prevent. So the resizes are done with prebuilt FLORA actions, which are deterministic, credit-free, and never touch the pixels of the plate itself.
Run each with flora_run_action, passing the master creative's url as the image
input — never a placement url. These are social crops of the ad itself, which is what
the SOCIAL page delivers; cropping a placement photograph would deliver a picture of a
bus shelter at 9:16.
1:1 and 9:16 change-image-ar-browser { aspect_ratio, fit: "pad",
background_mode: "blur" }
4:5 resize-image-browser { mode: "exact", width, height,
fit: "contain" }
4:5 is not in change-image-ar-browser's aspect list, which is why it goes through
the resize action with explicit dimensions instead.
Always fit: "pad", never "crop". Cropping to a new ratio trims the plate, which
breaks the law as surely as regenerating does. Padding leaves every pixel of the artwork
untouched and adds ground around it.
What this does not do. It does not reconstruct the ground the artwork sits on. A
seamless extension — measuring the edge band, estimating its colour and grain, and
continuing it past the plate — is not something these actions perform;
background_mode: "blur" derives a soft field from the image, which reads convincingly
on a photographic ground and less so on flat colour or full-bleed type. When the result
needs to be a true extension rather than a pad, that is a signal to build it as a FLORA
action or technique, not to reach for a generation.
The law
The artwork is reproduced. The world is generated. Any change to the creative — colour, crop, wording, letterforms, logo — is a fail.
The division is between what is made and what is carried: FLORA makes the place, and the artwork passes through it untouched.
Inputs
CREATIVE the ad itself required — an HTTPS url, an attached file,
or a line of text describing it
PLACES 4 named placements optional, default: the four archetypes
BRIEF one line on where/what mood optional
CREATIVE is the only required input and it takes either form. Text alone is not a
missing input — it is the WRITTEN path, and you generate the master before anything else.
Ask for artwork only when the request names a specific existing creative you have no url
for.
Getting the creative in. Files attached in ChatGPT are already hosted on
files.openai.com or cdn.openai.com, both allowlisted — pass that url straight to
flora_create_asset as source and FLORA fetches it server-side. A FLORA output at
media.flora.ai works the same way — including the master you just generated, which
arrives as a url and needs no upload step. Never base64-encode the file and never try to
upload bytes; if a supplied artwork genuinely has no url, say so and ask the user to add
it to their FLORA project.
You do not wire the artwork to anything. Each placement is an item in one
flora_create_generations batch, carrying the artwork URL in that item's
params.image_url, along with its workspace_id, project_id, type, model and prompt.
Default placements, chosen because they are four genuinely different media buys:
| tag | what it is | why it earns a slot | plate shape |
|---|---|---|---|
gable |
flat brick end wall of a terrace | scale against windows and downpipes | any |
transit |
flat underground platform panel | interior, artificial light, close viewing | any |
shelter |
backlit 6-sheet in a bus shelter | street furniture, lit from within | portrait |
hoarding |
plywood construction hoarding | pavement level, read close and fast | portrait |
The plate-shape column is not decoration. Read it against the creative before firing — see "Match the site to the creative's shape" below. Never a corner wrap: it bends the artwork across two planes and was the worst result of the whole test set.
Resolve the ids once, before you spend anything
flora_create_generations requires project_id. It is not optional and there is no default.
An agent that has not decided on a project has to produce one at fire time, and what it
produces is a guess — the most recently touched project from flora_list_projects, or a
plausible-looking prj_ string. Both are wrong, and neither errors in a way that looks
like a mistake. This is the single most common defect in a run of this skill, and
everything below exists to stop it.
So resolve two ids once, at the top of the run, and thread the same two through every call — placements, resizes, contact sheet, and the deck's footer:
WORKSPACE ws_... flora_list_workspaces
PROJECT prj_... flora_list_projects, or one the user named
Copy ids verbatim. Never retype, shorten, or reconstruct one. They are long opaque strings with no checksum, so a transposed character produces a valid-looking id that fails somewhere else entirely.
The two must belong together. A project from one workspace paired with another workspace's id fails at fire time with a 400 that names the problem exactly:
input_validation_error "Project does not belong to the specified workspace."
Measured, on an account with two workspaces — which is the ordinary case, since a
personal workspace and a team workspace is the default shape. Taking workspaces[0] and
a project the user mentioned is precisely how this happens. Confirm the project appears
in flora_list_projects for the workspace you are billing before the first generation.
One free call; four billable ones ride on it.
Ask which project when there is any doubt, and quote the name back. Projects are
overwhelmingly called Untitled, and duplicates of a real name are common — an account
here had two live projects both named Perfume Bottle Photography. Name-matching picks
one at random and there is no way to tell from the result which one it picked. origin
separates them a little: canvas means a human made it in the UI, chat means an agent
did.
flora_create_project works, but not in every workspace. Measured on one account: it
creates cleanly in one workspace and fails reproducibly in another with
400 input_validation_error whose message is the literal string Server Error plus a
request id — no field named, nothing wrong with the payload. It tracks the workspace,
not the request: the workspace that refuses to create is the same one that returns 403
for actions. Not a project cap either — the failing workspace held 4 projects, the
working one 839.
So creating a project is worth trying and is not worth debugging. If it 400s, do not reshape the payload and do not retry with a different name — fall back to a project that already exists, and say which one you picked and why.
What the project link actually contains
the project https://app.flora.ai/projects/<project_id>
one node https://app.flora.ai/projects/<project_id>?focus=<node_id>
?focus= opens the canvas centred on a single node — use it in the run report to
point at a specific placement instead of making the reader hunt. Node ids come from
flora_list_canvas_nodes. It does not go in the PDF: the deck is client-facing, and a
workspace link is dead to anyone outside it.
Only the placements are on the canvas. flora_create_generations writes to the project;
flora_run_action does not. The tool says so and it measures true — an action's output
lands under media.flora.ai/code-sandbox/... and never appears in
flora_list_canvas_nodes for the project it was scoped to. On an action run project_id
buys authorization and a generation-history row, nothing more.
So the resizes and the contact sheet are not in the project, and a link that implies
otherwise sends the user looking for files that were never there. Either say what the
link contains — "the four placements; the resizes and the sheet are urls below" — or put
them on the canvas deliberately with flora_add_action then flora_run_canvas_action.
Match the site to the creative's shape
On the WRITTEN path this is already solved — you chose the master's shape, so a 2:3
master and the four defaults never conflict. Read the rest of this section when the
creative was supplied.
Read the creative's aspect ratio before choosing sites. The defaults are not all the same shape: a 6-sheet and a hoarding are portrait media, a gable end and a platform panel take anything. Give a landscape creative to a portrait site and the model resolves the conflict the only way it can — by recropping the artwork. That is a silent breach of the law, and it is the likeliest way this skill fails.
Measured, a 3:2 landscape creative against the four defaults unchanged: gable and transit came back with the plate intact, shelter and hoarding both came back recropped to portrait with a third of the composition gone. Nothing errored. The deck looked finished.
creative is landscape gable · transit · billboard · a landscape hoarding panel
creative is portrait the four defaults as written
creative is square any of them
Then state the plate's shape in the prompt, so the site is built around the artwork instead of the artwork being trimmed to the site:
THE PRINTED PLATE IS :, THE SAME SHAPE AS THE SUPPLIED ARTWORK. The panel, board or pasted area is built to that shape. Do not trim the artwork to fit a differently-shaped surface — size the surface to the artwork.
params.aspect_ratio sets the shape of the PHOTOGRAPH, not of the plate inside it. Both
matter: "3:2" or "16:9" gives a street scene that tiles into an even contact sheet,
while the clause above governs the plate.
Model routing
GPT Image 2 at resolution: "4k" — for every placement. This is the only model.
Tested head to head against Nano Banana Pro on identical prompts and grade. GPT wins on all four sites, and not on pixel count:
- The fine detail of the creative survives. With a type-heavy or textured artwork, NBP mushes the fine structure into a flat texture at distance; GPT still resolves it. That structure is usually the artwork's identity, so it is the thing that matters most.
- The environments are better observed — real street furniture, believable bystanders, correct light spill from a lit panel onto its surroundings.
A single generation is ~107s and 4k is no slower than 2k. Fired concurrently, four placements land in about five minutes — measured in live use, not estimated. Earlier figures of 15–20 minutes were staggered firing plus a repair round, not the model. Do not add a fast draft pass.
resolution: "4k" lowercase for GPT Image 2. (Nano Banana Pro also accepts "4K"
uppercase — it is simply not the right model here.)
i2i-gpt-image-2-i2i for the placements, t2i-gpt-image-2-t2i for the master on the
WRITTEN path. Same model, same grade language, same lowercase resolution values — which
is why the master and the four placements hold together as one set.
Never Krea. It reinterprets what you wire it, which is the one thing that must never happen to the creative.
Speed — fire everything at once, then wait
Measured, three generations fired at the same instant:
Nano Banana Pro 2K 63s
GPT Image 2 2k ~107s
GPT Image 2 4k ~107s <- 4k costs nothing over 2k
But GPT Image 2 throughput swings hard. That best case is real and repeatable; so is a batch of four taking 10–15 minutes with the identical setup. Do not promise a runtime. What you control is not adding delay of your own:
- Fire all placements in one pass with no gap between them. One
flora_create_generationscall with agenerationsitem per placement, each with workspace_id, project_id, type: "image", its full placement prompt,model: "i2i-gpt-image-2-i2i"andparams: { image_url, resolution: "4k" }. Do not stagger on a timer — that converts a one-generation wait into an N-generation wait for nothing. - Fire the resizes in the same pass if any need generating. They share no dependency.
- Only the contact sheet waits, because it needs the images.
- Poll once, centrally. One
flora_list_generationscall withrun_idscontaining the successful submissions' IDs covers every placement at once. Do not poll each run separately — that turns one wait into N waits for nothing.
The honest expectation to set with the user: a couple of minutes if GPT is quick, fifteen if it isn't. Say that up front rather than predicting a number.
The one hard rule: Krea rate-limits. Firing many Krea calls at once returns
GENERATION_DOWNSTREAM_SERVICE_ERROR and loses most of the batch — pace those at ~5s.
GPT Image 2 and Nano Banana Pro need no pacing.
"One shot" means one deck, not one generation round — and you do not stop to repair. Stated plainly because the ambiguity itself cost time in live use: a run hesitated over whether it was allowed to fix a bad placement. It is not.
The master gate on the WRITTEN path is the single exception, and it resolves before any placement exists. Once the four are firing, nothing stops.
Prompt architecture
One invariant block, four scene lines. Only the scene changes.
Place the supplied artwork into a real photograph of the world as an out-of-home
advertisement.
THE ARTWORK IS REPRODUCED EXACTLY. Its composition, colours and type come through
unchanged. Do not recolour, recrop, redraw or re-letter it, do not add or remove a
word, do not add a logo. It reads clearly at a glance.
THE ARTWORK SITS ON THE SURFACE CORRECTLY. It takes the perspective of the surface
it is printed on, takes that scene's daylight and shadow, and picks up the surface
texture underneath. Printed material in a real place, never a flat rectangle pasted
onto a photo.
The photograph around it is real, candid and unstyled — ordinary weather, ordinary
light, ordinary passers-by. Full-frame camera, natural depth of field, no HDR, no
gloss, no lens flare, no CGI sheen.
THE WHOLE ARTWORK IS VISIBLE. Every edge of it sits inside the photograph — nothing
is cropped by the frame, cut off by a pole, hidden behind a tree or run off the top of
the wall. Every line of type in the artwork reads complete.
No extra text anywhere beyond the artwork itself and signage that genuinely belongs
to that street.
THE PLACEMENT — <scene>
The house grade
Extracted by measuring the 36-frame colour-grading field on the canvas, not by eye. This is the skill's style, and it goes in every placement prompt verbatim. It is described in words, never wired as a reference — wiring a graded still drags its composition in with it.
measured reads as
shadows neutral, black pt 9 deep, never crushed
midtones R-8.3 G+6.9 B+1.5 green-cyan cast
highlights R-21.3 G+16.8 B+4.5 strongly green-cyan
white point 143 / 255 rolls off early — nothing reaches white
contrast 41 (std luma) flat curve, no punch
saturation 48% rich but never vivid
The clause:
THE HOUSE GRADE — the whole photograph is graded this way, and this matters as much as the composition.
Shot on film and printed slightly flat. The tonal range is COMPRESSED: shadows deep and neutral but never crushed, and the highlights ROLL OFF EARLY — nothing reaches paper white, not the sky, not a lit sign. The brightest thing in frame sits well below white. Low contrast, gentle S-curve, no punch.
A GREEN-CYAN CAST runs through the midtones and especially the highlights — skies, pale walls, concrete and daylight lean eucalyptus and sea-green rather than blue or warm. Reds and skin pulled back and desaturated. There is no orange-and-teal, no warm/cool split. The cool green IS the light.
Colour moderately rich, never vivid. Fine film grain. Slight halation on the brightest edges. No HDR, no clarity, no glow, no saturation boost.
A strongly-coloured LIGHT direction overrides the grade. Measured: a placement directed "blue hour, pavement wet" came back blue, not green-cyan, while the three daylight and interior sites in the same batch held the grade exactly. Time-of-day words carry their own colour and they win. Keep the set to overcast, flat grey, daylight and interior artificial light; if a brief genuinely needs dusk, restate the green-cyan cast inside that placement's LIGHT line rather than trusting the shared clause to hold.
The trap this avoids. Writing "ordinary weather, ordinary light, ordinary passers-by" produces exactly that — flat, characterless placements. Light and camera must be directed per placement (focal length, height, time of day, one human moment), and the grade holds them together as a set.
Direction, per placement
Every scene line carries three things beyond the location:
SHOT focal length, camera height, angle e.g. 85mm compressed from down the road
LIGHT time of day and what it does to surfaces e.g. overcast, wet road holding reflection
MOMENT one human beat e.g. one person stopped, looking up
Without these the model defaults to eye-level, midday, nobody — and every placement looks the same.
Keep the human beat OFF the artwork's plane. The invariant block bans the artwork being "hidden behind a tree", but the MOMENT direction actively asks for a person near the ad, and the two pull against each other. On the live run the transit brief said "walking past mid-frame" and the model put the commuter squarely across the panel, blocking the left of the lower two lines. The plate was unaltered — an occlusion, not a re-letter — but the placement no longer read.
So place the figure deliberately, in the direction itself: in the near foreground and cropped, at the far end of the shelter, stopped on the opposite kerb. Say where the person is relative to the ad, never just that they are in shot. The three placements that did this came back clean; the one that said "mid-frame" did not.
Square on, always
The single biggest driver of placement quality. Three-quarter and corner-wrap views bend the artwork across two planes; the type distorts and stops reading. Tested directly: a scaffold banner wrapping a building corner was the worst result of the whole set, and the same creative shot perpendicular was the best.
Goes in every placement prompt:
THE CAMERA IS SQUARE ON TO THE ARTWORK. The lens is perpendicular to the printed surface, so the ad sits in frame as a TRUE RECTANGLE, flat and undistorted, read straight. Only slight keystone is acceptable.
- NO three-quarter view. NO oblique or angled view of the surface.
- The artwork NEVER wraps a corner and NEVER bends across two planes.
- It is on ONE flat plane facing the camera.
- No fisheye, no wide-angle bowing, no perspective warp through the type.
AND THE SURFACE ITSELF RUNS FLAT ACROSS THE FRAME. The wall, hoarding or panel the artwork sits on does not recede to a vanishing point, is not seen down its length, and its far end is not visible. Both the artwork AND the thing it is printed on face the camera.
Why the second half exists. A hoarding once obeyed "the artwork is on one flat plane" while running the hoarding to a vanishing point — so the artwork bent along with it and the type foreshortened. Banning oblique views of the artwork is not enough; the surface has to be called out separately.
Get variety from SITE and SCALE, not from camera angle. A 6-sheet bus shelter, a pavement-level hoarding and a billboard over traffic are genuinely different media buys. The same wall from three angles is one placement photographed three times.
The four sites, fixed — chosen because they are genuinely different media buys:
gable (flat end wall) · transit (platform panel) · shelter (backlit 6-sheet) ·
hoarding (construction). Swap one only if the brief names a specific environment.
Gotchas
API SHAPE
flora_create_generations starts an array of independent requests. Each successful entry
returns a run_id. Poll those ids with flora_list_generations.
Start existing wired nodes with flora_run_canvas_nodes.
params.image_url the input image for an i2i model, as a SINGLE STRING. This is the
whole image-to-image mechanism.
params.image_urls (plural, array) is accepted without complaint,
silently IGNORED, and still billed — you get a text-to-image
render of the prompt with the creative nowhere in it. Measured.
run status poll flora_list_generations with run_ids for exact runs, or filter
history by project. One call covers the batch. Key on
status == "completed" AND outputs being present — completed_at on
its own does not mean done, and neither does a "running" status
mean it is not.
flora_run_action runs a prebuilt action headlessly on inputs supplied inline.
Credit-free and deterministic — where the resizes and the contact
sheet come from. It does NOT touch the canvas: outputs land under
media.flora.ai/code-sandbox/... and never appear as canvas nodes.
project_id only scopes authorization and generation history.
Entitled per workspace: a workspace without it returns
403 forbidden "Actions are not enabled for this workspace.
Upgrade your plan to use actions." That kills BOTH the resizes and
the contact sheet, so check it before promising either. The PDF
does not depend on actions and still builds.
flora_create_project works in some workspaces and 400s in others on the SAME account,
tracking the workspace rather than the request. The message is the
literal string "Server Error" with a request id and no field — it
is NOT your payload, so reshaping it does nothing. Fall back to an
existing project. The workspace that refuses this is the same one
that refuses actions.
flora_list_canvas_nodes returns media nodes with their asset urls. Use
flora_get_canvas for structure and how nodes connect.
ids project_id is REQUIRED on flora_create_generations, and it must belong to
the workspace you pass, or: 400 input_validation_error "Project
does not belong to the specified workspace."
credits every placement bills. State the total and get a yes before the
first call. Nothing is refundable and retries bill again.
The charged_cost flora_create_generations returns AT FIRE TIME UNDERSTATES
the bill: measured 0.253 quoted against 0.873 actually charged,
3.45x. Quote from a completed run's charged_cost, or say plainly
that the figure is a floor. Four 4k placements are ~$3.50, not ~$1.
The WRITTEN path adds a fifth generation at the same rate — quote ~$4.40,
and quote it BEFORE the master, not before the placements.
media urls fetchable with no credentials. Path contains the date — read the
full url, never reconstruct it.
MODEL PARAMS
GPT Image 2 resolution "2k" / "4k" lowercase. 4k costs no more time than 2k.
Nano Banana Pro resolution "2K" / "4K" uppercase.
Krea creativity is an enum: raw | low | medium | high.
generate_audio agent-gated; including it rejects the WHOLE changeset.
changeset validation ONE bad param fails the ENTIRE changeset, not one node. Probe a
single node when unsure — the error returns valid_values.
RATE LIMITS
Krea rate-limits hard. Pace at ~5s or lose most of the batch to
GENERATION_DOWNSTREAM_SERVICE_ERROR.
GPT / NBP no pacing needed. Fire concurrently.
RESULTS
media urls fetchable with no credentials. Everything this skill produces is
a url, not a local file — there is no filesystem on this surface.
Report urls; never claim to have written or opened a file.
The deck is an annotated PDF
The deliverable is a paginated A4-landscape PDF, built locally from HTML and printed by headless Chrome. Not a server-side render — there is no PDF endpoint — and not a contact sheet standing in for one.
"Annotated" is the whole point. A grid of placements is a contact sheet: it shows what came back. The deck prints the SHOT / LIGHT / MOMENT you actually asked for beside each placement, so a media planner reads the brief next to the result and can act on it. Print what you asked for, not a description of what came back.
cd <project>/Deliverables
HTML=$(python3 build_mockup_deck.py deck.json --check) # fetches, packs, measures
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless \
--disable-gpu --no-sandbox --no-pdf-header-footer \
--print-to-pdf="${HTML%.html}.pdf" \
--virtual-time-budget=25000 "file://$PWD/$HTML"
Take the html path from the builder's stdout, as above. It names the file after the work, so the deck and the PDF match without you retyping a title into a shell command.
The builder is in the appendix at the foot of this file, and its docstring carries the
deck.json shape. Put the placement urls straight into it — the builder downloads and
caches them into src/ on first run, so there is no separate fetch step to get wrong. Write it out beside the deliverables and run it there. Land the HTML
next to the PDF — it re-renders in about two seconds, so a layout tweak never costs a
regeneration.
Keep --no-sandbox. Chrome will not launch as root or inside a container without it,
which is most of the surfaces this skill runs on, and the failure is a bare non-zero exit
that reads like the deck is impossible rather than like a missing flag.
A dropped image is silent — make the builder say so
This is the single most common way the deck comes out wrong. Every section is conditional on its images resolving, so a plate that will not load does not leave a hole: the whole page is dropped, the remaining footers renumber over the gap, and the build exits 0. Measured on a deliberately broken set — a missing local file and a 404 url — the deck came out at 4 pages instead of 6, numbered 1..4, with the cover plate gone and nothing on stdout to say so. It looks like a finished, slightly short deck.
So the builder collects every unresolved image, names it, and exits non-zero:
3 image(s) did not resolve:
creative: ALSO-MISSING.png
TRANSIT: MISSING.png
SHELTER: https://media.flora.ai/does-not-exist.png
fix these or pass --partial to ship the deck without them
Fix them and re-run. --partial is the deliberate escape hatch for a genuinely
incomplete set — and if you use it, say in the final message which placements are missing
from the deck. Never report a page count you did not read back.
Measure the page, don't look at it
--check loads the deck in the same headless Chrome that will print it, walks every
.page, and reports anything outside its content box — which side, how many pixels, which
element. Three seconds, and it exits non-zero.
page 1 SCROLL +1236px
page 1 OVERFLOW top +1236.3px div.cover-txt
page 1 OVERFLOW bottom +1105.7px p.standfirst
That is an overlong standfirst running off the cover, named and quantified, without rendering a PDF or looking at anything. The layout loop is where this skill's wall-clock actually goes — render, squint at a thumbnail, guess which rule is wrong, render again — and almost none of those rounds are taste. They are geometry. A screenshot is a terrible way to read a number.
build with --check free and instant; repeat until PASS
render the PDF once nothing is overflowing
read the pages back pdftoppm -png -r 60 out.pdf pg -- taste only, and only now
A green check is not proof the page is good. It proves nothing is outside its box. It cannot see a distorted aspect ratio, a weak cover, or dead space. That is what the single visual pass is for — do not skip it because the checker passed, and do not spend it on geometry the checker already covers.
If you change the CSS, verify the verifier. Break the layout on purpose and confirm it
goes red before trusting a green check. This is genuinely easy to get wrong: removing
max-height from the placement image still reports PASS, because align-items: center
shrinks the flex item — the oversized image distorts instead of overflowing and the page
box stays clean. Confirm the failing build contains the bad rule AND that the reported
failure is the one you meant to cause.
Say the full path to the PDF in the final message. A deck nobody can find is not a deliverable.
Get the PDF out on every surface
The PDF is the deliverable. Take the best route the surface allows — do not skip straight to "can't". Chrome is how route 1 prints; it is not what makes the deck possible.
1 shell + Chrome build, then print headless -> a .pdf on disk
2 shell, no Chrome build, user opens the html, Cmd-P -> Save as PDF
3 no filesystem run --remote, hand over the html -> user prints it
Route 2 costs nothing: @page { size: A4 landscape } is honoured by the browser's own
print dialog, so File → Print → Save as PDF produces the same document the headless
flag would.
Route 3 is what makes this work on claude.ai and ChatGPT. With --remote the html
references the media.flora.ai urls instead of inlining the bytes, which drops it to
8.5 KB on a four-placement set — small enough to hand over as a file. Measured: those
urls load into a browser with no credentials and print exactly as inlined images do. So
the answer on a hosted surface is "here is the deck, open it and print" — one click from
a PDF, not unavailable.
Only say the deck cannot be built if all three fail. Never synthesise a PDF out of tool output.
The builder downscales before embedding, and that is not optional. Chrome re-embeds
source images badly: measured on a four-placement set of full-resolution 4k PNGs,
113 MB of PDF against 3.6 MB at 2400px JPEG q88 — 31x, with nothing lost, since
the widest slot in this layout resolves ~2185px at 300dpi. A deck nobody can attach to an
email is not a document you can send, so packing is on by default and caches into
packed/ beside the source.
--no-pack exists for the case where a client genuinely wants the print-resolution file,
and it is almost never what you want — the full-resolution plates ship alongside the
deck as separate urls, which is what a media planner actually needs. --remote skips
packing entirely, since it references the urls rather than embedding anything.
The contact sheet, as a fallback
Still worth building where actions are available: one composite image, credit-free and deterministic, that a user can drop straight into a deck of their own.
flora_run_action side-by-side-composite-browser
inputs the four placements, in site order: gable, transit, shelter, hoarding
params { layout: "grid-2x2", normalize: "match-shortest", gap: 24,
background: "#ffffff" }
Use layout: "auto" when the count is not four. normalize: "match-shortest"
downscales to the smallest edge, which keeps every tile the same size without upscaling
anything.
Labels, if the user wants them, come from add-text-to-image-browser run over the
composite — not from burning text into a placement. Never annotate a placement itself;
the plate has to stay clean.
This is a generalist skill. It runs on any poster for any client, so nothing is branded: no logo, no mark, no client name in the chrome. If a client name is wanted, it is a text parameter on the label pass, never a template edit.
Naming and what comes back
the master one url, from flora_create_generations — WRITTEN path only
the placements four urls, one per site, from flora_create_generations — ON the canvas
the resizes three urls, from flora_run_action — NOT on the canvas
the contact one url, from side-by-side-composite-browser — NOT on the canvas
the deck a local .pdf path, built by the deck builder
the project https://app.flora.ai/projects/<project_id>
Everything except the PDF is a url. Report the project link with the ids you resolved at
the top of the run — not one reconstructed at the end, and not the workspace id, which is
the substitution to watch for since both are long _-prefixed strings.
Where the files land
On a surface with a filesystem, always write to <project>/Deliverables/. Create it
if it is missing. Never leave the deck in a scratch or temp directory, never drop it in
~/Downloads, and never leave it loose in the project root — across three live runs the
deck landed in three different places and had to be hunted for.
the source <project>/Deliverables/<Poster-Title>-deck.html
the output <project>/Deliverables/<Poster-Title>-deck.pdf
the plates <project>/Deliverables/<Poster-Title>-deck-assets/ fetched + packed
The b
…(truncated)