Flow generator
Read an Adapty flow's builder config, transform it, check the result, and write it back. Transforming a config that exists is the default, and the safer path: everything you emit is then grounded in a document that already works.
Authoring a new flow is also in scope, and three things — and only these three — genuinely cannot be synthesized:
flowProductId, the per-screen declaration in_meta.screens[].products[]— only the builder mints the real value. But you do not need it: the transform service checks a declaration is present and consistent, soflowkit.predeclare(screen_id, product_ids)lets a brand-new draft preview on a device with no publish and no builder visit. Omit it and device preview 422s. When rewriting a flow, never generate one — carry the live_meta.screensforward (products.md).- An image you have no readable FILE for. Given a path you can now upload it —
flows media upload(media.md). An image you can only see — pasted or attached into the conversation — is not one you have: ask for a path. With no file there is nothing to upload, so it stays an empty values map, never a made-up URL (trap 5). SVG uploads fail, so a monochrome glyph is authored inline in_meta.icons; a graphic no element can express, you draw and rasterize (media.md). - Real store prices. They come from the store, not from Adapty;
products createhas no price flag.
Everything else is reachable: product UUIDs from adapty products list (or products create),
theme colours sampled off a reference screenshot, and icon SVG authored and then render-verified.
When you do author, references/flowkit.py owns the mechanical parts —
the hierarchy/map split above all — and patterns.md owns the shapes.
It also covers conditions (when/ref/all_/not_empty), all fourteen action types, the
eight inputs and the tabs composite — each raising on the shape the transform service refuses.
What you print
The user reads your messages, not this file. Keep them short.
Four fixed blocks, and nothing else is fixed: the approval ask before a write and the closing callout after one, both in phase 5; the missing-assets block, printed in phase 2 whenever a build has assets nobody has a file for; and the placement ask in phase 6, before an irreversible developer ID is spent. Fill their slots and do not pad them.
The missing-assets block goes out in phase 2, batched with the product questions, because a path they hand over turns a placeholder into a finished screen. The upload routes are not symmetric — offering both on every row recommends a path that ends in a refusal:
<n>assets missing — the screen ships with placeholders until these land.
what where it goes size 1 <what it is, in their words><where on the screen><w>×<h>Tell me which, per asset or for all of them:
- Send me a path — I'll upload and bind it.
- Upload it yourself at https://app.adapty.io/flows/``/builder — the placeholder is already styled, so it lands finished.
- Design around it — I'll replace that region with something the format can build, and say what I chose. Right when the reference is someone else's screen and that asset was never going to be yours.
Until you answer, they ship as placeholders.
<only if a face is missing:><what>is set in a<description>this account lacks; I'm using<substitute>, which<how it differs>. Fonts are builder-only — I can't upload one: https://adapty.io/docs/using-custom-fonts-in-flow-builder.md — upload it, then tell me the family name and I'll point the theme at it.
Images up to ~2.5 MB you can upload; SVG, fonts, video and anything larger are builder-only (fidelity.md). Phase 5's callout carries the outstanding count as one line, not a repeat of the table.
Everything else is one line or omitted — what changed, what still needs them (products to attach, assets to upload), any decision where two answers were defensible, and what your checks did and did not cover. Say each thing once: if the approval ask already named it, the closing note does not repeat it.
Do not narrate phases, restate the config back, list warnings you did not act on, or explain the CLI to someone who asked for a flow.
References
Each file owns its facts; link rather than restate, or the copies drift.
| File | Read it when |
|---|---|
| flow-schema.md | Before any edit. The envelope, ## Invariants, ## Shape traps, and ## Vocabulary — the map from what a user asks for to what the JSON calls it |
| validate.md | validate says no, or you want to know what a green run does not prove |
| preview.md | A render surprises you: what it cannot show, what it costs, the four disagreeing surfaces, and what to do when it fails |
| fidelity.md | A reference image was given — the per-element inventory, the gap-closing ladder, and what becomes a user ask |
| media.md | The screen has an image: the upload's limits, element-versus-fill shapes, geometry, and when to rasterize |
| products.md | Before touching a product element — products create writes to a live dashboard |
| merge.md | The flow has been edited by a human since it was generated, or you are tempted to re-run a build script over an existing flow |
| transforms.md | You hit a point where two answers are defensible and silence is the only wrong one |
| patterns.md | You need a composite you cannot guess: tabs, progress bars, toggles, countdowns, plan cards |
| placements.md | Phase 6 — every placements refusal and who owns it, the update variant, the dashboard URLs and their params, and why the developer ID is irreversible |
the paywall-teardown skill |
Phase 2 when you choose the design of a screen that sells, phase 4 to grade what you built. It owns whether the screen sells; this skill owns the JSON |
the onboarding-teardown skill |
The same two phases when what you are choosing is a sequence — onboarding, welcome, quiz, activation. It owns the shape of the flow and the onboarding→paywall seam. A flow that is both runs both |
the adapty-integration skill |
Phase 6, once a placement points at the flow. It owns the app side — the fetch, the render and the call sites — and this skill hands it one thing: the placement developer ID |
Executable, all under references/: flowkit.py (authoring), verify-config.py (phase 3),
validate-with-schema.mjs (phase 3), diff-config.py (phase 2 and phase 5), montage.py and
render-measure.py (phase 4), preview-with-playwright.mjs (when a render fails),
mobile-preview.mjs (phase 5, the device-preview link).
The CLI surface
$ADAPTY auth login # browser flow
$ADAPTY auth whoami # verifies the token server-side
$ADAPTY apps list --json # to get <APP_UUID>
$ADAPTY flows list --app <APP_UUID> [--page N] [--page-size N] # page-size max 100
$ADAPTY flows create --app <APP_UUID> --name <name> # row only; always `draft`
$ADAPTY flows get <FLOW_ID> --app <APP_UUID>
$ADAPTY flows config get <FLOW_ID> --app <APP_UUID> --json # 404 until first write
$ADAPTY flows config validate <FLOW_ID> --app <APP_UUID> (--config-file <f|-> | --config <json>) --json
$ADAPTY flows config preview <CONFIG_FILE> [--screen <id>] [--device <id>] [--orientation …]
$ADAPTY flows config update <FLOW_ID> --app <APP_UUID> \
(--config-file <file|-> | --config <json-string>) \
[--expected-updated-at <int>] [--remote-configs <json>]
$ADAPTY flows media upload <IMAGE_FILE> --app <APP_UUID> # PNG/JPEG/WEBP/GIF, < ~2.5 MB; no SVG
$ADAPTY flows update --app <APP_UUID> <FLOW_ID> --name <name> # --name required; 405 in prod, rename in the builder
$ADAPTY flows publish --app <APP_UUID> <FLOW_ID> [--yes] # async; 404 in prod, see below
Resolve $ADAPTY once, here, and use it for every command. A global adapty is frequently old
— measured at 0.3.0 on a real machine, which has no flows topic — and three agents read that as
"validate and preview do not exist" and skipped phases 3 and 4:
adapty --version # >= 0.8.0 ? ADAPTY="adapty", done
npm i -g adapty@latest >/dev/null 2>&1 \
&& ADAPTY="adapty" \
|| ADAPTY="npx --yes adapty@latest" # fallback: prefix not writable
Install once; do not wrap every call in npx. The wrapper costs ~1 s per call against
0.07 s installed, and a run makes dozens. Where the global prefix is not writable the npx form
still works, and there --yes is not optional: without it npx stops to ask permission to install.
Declare a command unavailable only after npx --yes adapty@latest and npx --yes adapty@beta
both lack it — never from a version number you read somewhere.
In zsh — the macOS default — a multi-word $ADAPTY is not split into words, so every command
below fails with command not found: npx --yes adapty@latest. Run setopt shwordsplit once in the
same shell (verified), or call npx --yes adapty@latest in full. That error is a shell problem,
never evidence the command or the CLI is missing.
flows media upload works in production. It takes a local image file and prints a live CDN URL
to bind into the config, so an image the user handed you a file for is yours to place, not a user
ask. Two limits shape when you reach for it: SVG
returns http_500, and the ceiling is ~2.5 MB of file bytes (a bare http_400 means too
large). Call shape, the two config shapes it binds into, and the geometry:
media.md.
flows publish --app <APP_UUID> <FLOW_ID> is not in every build. Per the rule above, decide
that by running flows publish --help, not from a version number — the command has left stable
once already, so a numeric floor is not a reliable test. Its own flags are --app plus --yes/-y, and the CLI's global --json on top of
them. Five measured facts shape how you call it: publication is asynchronous, so the response reads
status: publishing and never published — report it that way rather than claiming the flow is
live; the confirmation prompt goes to stderr, so --json stdout stays parseable; --json or a
non-TTY without --yes refuses with exit 2 (Re-run with --yes) instead of hanging, so a
headless run passes --yes only once the user has said yes in the conversation; a declined prompt
exits 1 (Cancelled, nothing was sent.); and a flow with no config exits 1
with Flow has no current version.
A successful publish tells you what to run next, and you run it. In human mode it prints three
lines: that publication is asynchronous and the flow is not published yet, the flows get poll to
run until the status reads published or publication_failed, and the flows config get that
shows why if it fails. They are suppressed under --json, which leaves you holding
status: publishing and nothing else — poll anyway. On publication_failed, flows config get
is the answer to why: its envelope carries publication_status, transform_error and
publication_error alongside the config, and transform_error is the transform service's own
objection. It is a raw string — a JSON issues payload or a summary — with no CLI helper to parse it,
so read it and quote it rather than re-deriving a cause. Where the API does not send those fields
they are simply absent; that is not an error, and it does not mean the publish succeeded.
Two things gate this, and neither of them is the account. One is the CLI version, above. The
other is the API deployment: flows publish has been observed answering http_404 and
flows update --name answering Method "PUT" not allowed. Neither error means you wrote the
command wrong and neither is fixable by switching accounts — they are per deployment, every account
on it alike — so say that and hand the user the editor's publish button or the builder's rename
field. Run the call before you believe it, though. Both routes are unverified rather than known
absent, so treat a 404 as something you observed, never as something you expected.
There is still no flows delete. Deleting is a dashboard action, so never claim to have
deleted a flow. Never write a command name the CLI does not have, and never invent a flag —
config validate takes only --app, --config/--config-file and --json.
Four facts about the config commands that are not guessable:
config getreturns an envelope, not the config:{config, remote_configs, status, updated_at}, pluspublication_status,transform_errorandpublication_errorwhen the last publish failed. The document you transform is theconfigfield, and bothupdateandvalidatetake that field alone. Handingvalidatethe envelope returnsInvalid flow input— which reads exactly like a broken config and is not one. (previewis the odd one out: it accepts either.)statusis not yours to write. It belongs to the envelope and is discarded if you put it insideconfig. Do not emit it in a config you send toupdate, and do not treat its absence as a defect. (A browser export does carrystatusandidat the top level — that is a different document shape, and phase 5 covers what to do when the user wants a file.)updated_atis an epoch integer (e.g.1787210847609) and it is the optimistic lock.flows createprints an ISO timestamp instead, which--expected-updated-atrejects — so always take the integer fromconfig get.config updatehas no dry run.validateandprevieware the pre-flight checks, and both run before a write — see phase 5 on why that ordering matters.
The six phases
1. Resolve the invocation, then authenticate
First set $ADAPTY as the CLI surface describes — probe
adapty --version, and if it is old install once globally rather than paying the npx wrapper's
~1 s on every later call (npx only as the fallback). Do it before the first command, not after one fails,
and print the version you resolved in the same command you run next so the two cannot disagree.
Then $ADAPTY auth whoami. It hits the server and prints the name and companies, so it proves the
token works. Prefer it to auth status, which only reports what is stored locally and does not
verify it — it happily prints Email: undefined next to a working token.
If it fails, $ADAPTY auth login opens a browser. That is the user's to complete; wait for them
rather than retrying in a loop. Then $ADAPTY apps list --json for the <APP_UUID> every later
command needs.
Then check where the request actually starts, because not every run is an edit. "I published my flow", "how do I get this into my app", "nothing shows up in the app" is a phase 6 request: the flow exists and is published, and what is missing is the placement pointing at it and the app's own call site. Go straight to phase 6 — phases 2-5 have no work to do — and say that is what you are doing, so a user who did want an edit can redirect you.
2. New flow, or existing flow
Decide this explicitly and say which you chose, because the two paths differ in what they can destroy.
Existing flow — the user names it, or flows list and confirm the match back to them
before touching it. Then flows config get, and keep the updated_at for the write.
Take a backup before the first edit. config update replaces the whole config and there is no
undo, so the copy you fetched is the only way back:
$ADAPTY flows config get --app $APP $FLOW --json > flow.working.json
cp flow.working.json flow.backup.json
Patch what you just fetched. A build script or a draft.json from an earlier run predates
whatever was done in the builder since, and config update replaces everything
(merge.md). If such a copy is lying around, diff it against live, report
the ADDS/CHANGES as the human's edits, and move it out of the way:
python3 references/diff-config.py <the-old-local-copy>.json flow.working.json
New flow — flows create, then seed its config from one the user already has
(flows list → config get) so theme, fonts, locales and products are real. Its first
config update omits --expected-updated-at. A new flow is the safe default for anything the
user calls new, because config update replaces everything and generating over a flow with
content discards that content.
Then, before editing: report what the source config contains — screens and captions, locales, products, the navigation graph. Before proposing anything; it grounds the user and catches a wrong flow immediately.
Confirm the transform. In scope: add a locale, rewrite copy, add/remove/reorder screens,
branching and conditions, renaming screen ids, and reusing a piece of another flow — its
dependency resolution has a measured hard-422 class (flow-schema.md invariant 8), so it runs
through references/snippet.py, never by hand (snippets.md). A request
outside those is named as out of scope, not improvised.
If the request is "save this for reuse" or "add the thing I saved", run
references/snippet.py plan before any graft — read snippets.md first.
If it is "give the screens readable ids" — usually so a customer's own analytics stops reading
scr_oAPBHPa7 — run references/rename-screens.py, never a hand edit: a screen id lives in three
places and the one that gets forgotten, _meta.screens, makes the flow unpublishable. Renaming
breaks analytics continuity, so it goes in the phase-5 ask (transforms.md
decision 9). Element el_XXXX ids are not part of this and must not be renamed to match — a
bad one compiles into the runtime script as a black screen, and it buys nothing anyway: the id a
customer's analytics sees is props.customId
(flow-schema.md trap 7b).
Were you given a design to follow? Answer it out loud: it decides who is choosing. A reference
image, a screen to copy, or a layout they spelled out means they chose it — follow it, and
compare against the file rather than your memory of it (phase 4). Follow the reference for style,
colour, typography, icon style and hierarchy, but keep Adapty's fluid layout discipline
(width: fill, height: hug, position: relative): never hardcode fixed dimensions or offsets to
match a screenshot's pixels, because fixed geometry breaks across devices (ADP-7117). No
reference means you are choosing it — "build me a paywall", "build me an onboarding", "make one
that converts" — and the request map only turns nouns into element types; it says nothing about
what sells.
When you are the one choosing, a teardown skill is the reference, and which one is decided by what you are building — not by which you reached for last time.
| What you are building | The reference | What it returns |
|---|---|---|
| One screen that sells — a paywall | paywall-teardown |
An archetype (the screen's composition) plus the patterns this vertical needs |
| A sequence — onboarding, welcome, quiz, activation | onboarding-teardown |
A skeleton (the sequence's shape) plus the patterns, placed per screen |
| Both — an onboarding that ends on a paywall | Both. onboarding-teardown owns the sequence and the seam; paywall-teardown owns the paywall screen itself |
Run the sequence one first: it decides what the paywall must reflect |
Invoke it before you write anything. Both name the values they refuse to invent — a rating, a review count, an outcome stat, a discount, a hero asset, and for a sequence the thing the app must really deliver behind a personalized promise. Put those asks to the user before you write the config, and leave the element out rather than filling it with a plausible number: a missing element is recoverable, a fabricated rating is a lie in front of real buyers. Build the shape it names and do not substitute one you built last time — that is how two unrelated verticals got the same screen. It also grades the result in phase 4, where a correction is still free. And when the user wants to know how good a flow is rather than to change it, that answer is a teardown, not a transform.
In a build there is nobody to interview. onboarding-teardown's six questions are its
front door when a user brings a flow to it; invoked from here they are answered from the brief,
the config and the catalog, and whatever is left becomes one batched ask alongside the others
above. Do not start a six-turn questionnaire in the middle of a build.
Products are the user's to pick — catalog first, store ids second, create last. For any screen that sells, resolve the products before the design, in this order, and never skip a step silently:
$ADAPTY products listand show what exists — title, period, store bindings — and ask which of these belong on the screen. Most accounts already have the right products.- Only if nothing fits: ask for their store product ids (App Store product id; Google
product id plus base plan id for subscriptions) — those are the bindings
products createcannot run without, so asking later just stalls the create. - Only then
products create, behind its own confirmation gate (products.md → Creating a product).
Before the design, because the catalog gates it: a trial timeline needs a verified offer, a period switcher needs plans differing only by period, a price variable needs a matching period. And picking for them is not a shortcut — it decides what they sell, and it is the one choice on the screen a screenshot cannot show.
Assets are resolved here too — upload the file, then build with its URL. The upload reads a path, so an image you can only see is not an image you have: one the user pasted or attached arrives as pixels in your context with no file behind it, and you cannot write the bytes you were shown.
For every asset the screen needs, one of three states, decided before you write the element:
- You have a path that reads — one they gave you, or a project file you found and named.
Upload it now and bind the URL it returns:
Bind it on anURL="$($ADAPTY flows media upload --app "$APP" ./hero.png | sed -n 's/^URL: //p')"imageelement or flat inside afill— two different shapes, and theidis a string even though the command prints a number (media.md). - You can see the image but have no path (pasted, attached), or they named one they have not sent — ask for a path, once, batched with your other asks. Never guess one: a guess that misses fails loudly, and a guess that hits ships the wrong picture in a screen that renders perfectly. A URL they pointed at is fetchable, but say what you are downloading first.
- Nobody has a file — a styled empty
imagewhere the graphic occupies a box:borderRadius,objectFit, and a fixed size taken from the reference, on the element itself, so the upload lands styled and the layout is checked at the size the asset will fill. Where it has no box of its own — a texture, a glow — leave it out rather than approximating it. Either way it goes on the missing-assets list, never into a paragraph. Never a made-up URL (trap 5). If the reference itself contains the graphic on a flat backdrop, you may be able to cut it out instead —references/crop.py, which refuses rather than guessing when the backdrop is textured or the box is wrong.
Upload before the preview loop, not after it, and upload each asset once. A placeholder does not occupy the space the real asset will, so a screen previewed with placeholders is a screen whose layout was never checked — and the upload does not deduplicate, so re-running it per iteration litters the user's media library permanently (media.md).
Before you author a construct you have not seen in a real document, count it. The schema says
what is permitted; a real export says what is produced; only the second predicts the device. One
jq over the config you fetched and over references/component-catalog.json settles it in seconds,
and a count of zero is a finding to say out loud
(flow-schema.md
— why, and the defects that shipped from skipping it).
Resolve the request into schema terms. The user's noun is rarely the element type — there
is no button and no toggle element, and tabs are a five-element composite. Use the request
map in flow-schema.md → Vocabulary, and source any shape the config
does not already contain via
patterns.md → Where to source a pattern, in order.
Editing one screen of many? Patch in place with a script — never slice the screen out. An
isolated mid-flow screen fails the publish gate the moment it navigates to a screen that is no
longer there, and isolation buys no speed on either gate — measured, with nothing to stitch back
(transforms.md). Reach the screen with jq or a short Python patch
instead of reading the whole file into context.
Apply, preserving every key you did not deliberately change — including unrecognized ones.
Nested unknown keys survive a round trip; unknown keys at the top level of config are
discarded, so never park anything there.
Write the result to a local file. Phases 3 and 4 both work on that file, with nothing saved yet — and they apply whether the deliverable is a flow write or the file itself. "No CLI write happened" exempts you from the approval gate, never from the phases.
If the file IS the deliverable, its contract applies the moment you write it, here. A source
export carries top-level status and id; never emit "status": "published" — it imports as
live-looking content — and the id names the flow the export came from. Drop them or downgrade
status, say which you chose, and say the import must be pointed at the flow the user means.
references/verify-config.py warns on both fields, and that warning is this rule firing —
act on it, never paste it through.
3. Check the shape, then clear the publish gate
Walk Verify first — it is local and free and it finds every defect at once, which the commands below do not. Then all three gates in one call:
BASELINE=flow.backup.json references/gates.sh flow.working.json <APP_UUID> <FLOW_ID>
It runs the structural walk, the schema shape check and the publish gate over the same bytes, prints one verdict, and exits non-zero only when something blocking was found. One call, not three — the gates cost well under a second each while an extra round trip costs tens of seconds, so the turns were the expensive part. Drop the app and flow ids and it says so rather than pretending a local pass is a publish gate. The three underlying commands, if you need to run one alone, are in validate.md.
Always pass BASELINE= — the pristine copy from step 2. The schema tracks the newest
schemaVersion while most live flows are older, so an unbaselined run on a v9 flow reports
hundreds of pre-existing mismatches, none of them yours. Details in
flow-schema.md → the two different validators.
validate runs the same transform service that gates publishing, so it is the only pre-write
check here that speaks for the publish gate. It saves nothing, needs no confirmation and needs no
baseline — a v9 config validates clean. It takes the bare config, not the envelope, and the
flow must already exist, so on new work it runs after flows create.
Read the verdict, not the exit code. Exit 1 means "not publishable" or "the call failed", and
only --json separates them: a valid field versus an error object. An agent gating on the exit
code reports a good config as broken and a dead call as a defect.
Done here is a run that printed
valid: trueover the exact bytes you are about to write. It reports one fatal per run, so fix, re-run, repeat — a shorter list is not progress.
Neither check is a proof, and they do not overlap. validate catches the stranded references
the schema cannot see — an undeclared product, a groupId or a navigate pointing at something
that is gone. It also passes fill: "banana", schemaVersion: 999, an element with no states,
and every property the service will silently drop on the device. The schema check answers the
opposite question and knows nothing about publishability. Coverage both ways, and how to read each
message family: validate.md.
4. Preview, and iterate until it looks right
Render the screens you changed, in ONE call, and get back one strip to look at:
references/shoot.sh draft.json scr_a scr_b scr_c # preview + screenshot + montage
It previews locally (no --app, no auth, no save — file-only tasks included), screenshots each
screen with a watchdog, joins them left-to-right and prints the one path to open. Open that
image and look at it, against what the user asked for and — if they gave one — against the
reference image file, re-opened, not remembered.
Render only what you changed. A screenshot is ~18 s of Chrome cold start, so the number of renders is the cost of this phase, and re-shooting seven screens to check an edit to one is six wasted launches. One strip is also one look instead of N, and a before/after or default-vs-selected pair only reads as a difference when the halves are adjacent.
Do not try to speed the screenshot itself up — shrinking --virtual-time-budget does nothing
on a fast host, and parallel Chrome is slower than serial. But raise it when a shot comes back
empty: on a slow render host 8 s yields no file where 60 s renders correctly, so no file is
usually a slow host, not a broken config — shoot.sh retries at 60 s for you, and after that,
load the URL in a real browser before suspecting your work
(preview.md). A dead render
is never a reason to report the work finished.
Measure rather than eyeball with references/render-measure.py. Always try the preview: never decide
from the config's size.
Every image gets its properties checked here — reference build or not — because no other gate
looks at an image at all. Read the drawn box off the screenshot and choose: height: hug takes
its height from the asset's aspect, so the layout moves if the file changes and any value on
the size is dead; height: fixed holds the box and the asset absorbs the mismatch — cover crops,
fit letterboxes and leaves a dead band. objectFit is fit or cover, no CSS set. Re-render
after each change (media.md → Geometry).
A reference image raises the bar from "matches the request" to "matches the reference" — run the fidelity pass before anything is written, every time one was given. "Nothing jumped out" is not a result. Produce a written per-element difference list — colour, typeface, icon style, imagery, proportions — marking each one match, gap, or unreachable, then close every gap the format can reach and turn the rest into named asks. The list is the mechanism, not the looking: measured, agents who only looked shipped emoji for designed icons and colours from memory while disclosing them, and agents who wrote the list fixed everything reachable.
Done means every remaining difference is on the ask list — a user declining previews waives the deliverable, not this pass. What to inventory and what to do with each gap: fidelity.md.
Read preview.md → What a render cannot show you
before you report what a screenshot proves — every blindness on it measured, and two of them run
the wrong way: the render draws things a device will not. Two you act on here: it draws no notch
and no home indicator, so author safeArea: true and hand short-device clipping over as a device
check.
Never downgrade a correct element to a preview-visible lookalike to make the screenshot look
complete. When an element is preview-blind — a spinner that draws nothing on this screen, a
video, a toggle's selected state, a progress bar's advance — the answer is to keep the real
element, say the preview cannot show it, and hand it to the device check; not to swap in something
the render can draw. Standing a static icon in for a spinner (or any impostor for the element
it mimics) ships a thing that passes the screenshot and does nothing on the device — the
fake-footer mistake in a new
place, and no local gate catches it. A blank in the render is a reason to reach for a device (the
Adapty app), never a reason to author a fake. The loading-screen shape and the spinner's two
non-guessable facts are in patterns.md.
And the rule is not only about preview-blind elements — it also forbids faking a fully
previewable element because building it properly looks hard. A carousel renders in the
preview, so this is where the trap is easiest to rationalize: a static review card plus three
decorative dot stacks screenshots exactly like a testimonials slider and is one — one frozen
slide, no swipe, dead dots. The carousel is a real element with built-in dots, so the real
thing is usually less work than the fake, and the seed flow you already fetched often contains one
to copy. Resolve the request through the map in
flow-schema.md
before you reach for a lookalike — reviews, sliders, swipeable cards and dots all route to
carousel, never to hand-built dots. component-catalog.json ships a filled reviews-carousel
template and flowkit.carousel() builds one from scratch; verify-config.py errors on a
hand-built indicator row and warns on the dotless form. The same trap catches the progress-bar:
a static filled stack or a row of step stacks looks like progress and never advances — build the
real components entry and wire it per screen via props.progressBar, never a bar that cannot move.
If you built a screen that advances itself, ship the diagnostic with the first ask. The page
never navigates, so a working auto-advance and a broken one look identical here and only the user's
device can tell them apart — at a real cycle per attempt. Give the timer a child text carrying
the timer_minutes/timer_seconds tokens: digits never appear is the element not mounting,
digits reach zero and nothing happens is the trigger not firing. Without it a failed test returns
one bit and you guess again. Say it is temporary and remove it; the device-verified timer shape is
in patterns.md.
Confirm you screenshotted the flow at all. A bad --device, a broken fragment and a wrong host
all render as pages that pass a "did anything draw" check. If the render is blank, slow or wrong,
switch to preview-with-playwright.mjs, which uses the
page's file input instead of the URL — do not shrink the config. If you cannot render at all, say
so and ask the user to look; never report the work finished on a clean validate.
A missing element may not be your bug, and a clean preview is not the builder opening the flow. Both, with the four surfaces and what each one proves: preview.md.
A render that matches the request can still be a weak screen — and a set of renders that each
match can still be a weak flow. Every check above asks whether you built what was asked; none
asks whether it sells. If you chose the design — no reference, no source screen — running the
teardown over what you built is part of the work, not a courtesy. Same routing as phase 2: a
paywall goes to paywall-teardown, a sequence to onboarding-teardown, a flow that is both
to both. Hand it the render and the config (one shows what is visible, the other what is there),
then apply what it ranks Fix first or High and re-render. Findings on something you designed
are defects, not suggestions — handing it over with a list of the patterns you skipped is
unfinished — and this is the cheapest moment, a screenshot instead of a config update.
Skip it only on a literal edit: a typo fix, a locale add.
A sequence is graded on the sequence, so give it every screen. Render each one and pass the set
— references/montage.py joins them into a strip, which is what makes "screen 4 promises what
screen 5 doesn't deliver" visible at all. One screen out of six cannot show a seam.
Iterate here. Anything off, go back and fix it, then re-run phases 3 and 4. Nothing has been saved yet, so an iteration costs a screenshot rather than a write.
5. Get approval, then deliver — a write or a file
Whether you need a yes before writing is decided by one observable fact: does the target flow already have a config?
It does not — a flow you just created with flows create, whose config get 404s. Write it.
There is nothing to lose and nothing to overwrite, and stopping to ask would be friction over an
empty document. Report what you wrote afterwards.
It does — anything you fetched in phase 2. Stop and get an explicit yes before the write.
config update replaces the entire config: no partial write, no undo, no version history here, so
the document you are replacing exists in exactly one other place — the phase-2 backup. Put all of
this in front of the user in one message and wait:
First, compute what the write destroys — never describe it from memory. Run this on the bytes you are about to write, after the last edit:
python3 references/diff-config.py flow.backup.json draft.json # REMOVES = what you destroy
The backup is the baseline, not your working file: it is the one copy nothing in the run has touched, so it is the only honest answer to "what was there before me". (An edit that landed after you fetched is the lock's job, not this one's.)
Every REMOVES line goes in the ask below, traced to the request that asked for it, and one you
cannot trace is someone else's work: name it and ask, never write past it. Exit 1 says the list
is non-empty, not that anything is wrong — deleting a screen is a supported transform, doing it
silently is not (merge.md).
**Then show them the change — an
…(truncated)