flow-generator
Use when a user wants to change an Adapty flow by editing its builder config JSON — add a locale, translate a paywall or onboarding, rewrite copy, add/remove/reorder screens, add tabs or plan pickers, wire quiz branching, or build an onboarding sequence — and also once a flow is published, to point a placement at it so an app can fetch it. Triggers on "edit my flow config", "add a language to my paywall", "translate my onboarding", "remove a screen from the flow", "add tabs to my paywall", "build me a paywall like this", "build me an onboarding", a supplied Adapty flow config, and on "publish my flow", "I published my flow", "attach my flow to a placement", "create a placement for this flow", "my flow does not show up in the app".
How do I install this agent skill?
npx skills add https://github.com/adaptyteam/adapty-skills --skill flow-generatorIs this agent skill safe to install?
- Gen Agent Trust Hubpass
This skill provides a robust toolkit for generating and managing Adapty subscription flows. It uses official command-line tools and local validation scripts to ensure that data remains consistent and secure during the generation and editing process. All external interactions are limited to trusted vendor services and official package registries.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
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 two things — and only these two — genuinely cannot be synthesized:
- 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 its catalog entry stays empty, 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). An icon is not in this list:python3 references/icons.py --search <word>resolves any phosphor glyph, and an invented name draws blank however good your markup is (trap 23). - 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 markup resolved from the bundle
references/icons.py ships.
flowProductId — the per-screen declaration in _meta.screens[].products[] — is derived, not
waited for. flowkit.predeclare(screen_id, products) writes the block; pass exact Product + Offer
pairs, a bare product id or a (product_id, offer_id) tuple, because the offer is part of the id.
Omit the declaration and device preview 422s. When rewriting a flow, carry the live
_meta.screens forward instead of regenerating it — and screens[].products with it, the
screen-owned registry that _meta is derived from. Never write "products": []: absent and empty
mean opposite things to the builder. When authoring, flowkit.screen() writes the registry and
the structured product refs for you, from the screen's own bindings — bind an offer with
product(..., offer_id=...) and a dotted price variable picks it up
(products.md).
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.
How you sound
Every line the user reads, the fixed blocks included:
- "I" for what you do, "you" for what they do. Lead with what changed; end on one next step. No opener, no recap of their request, no narration of phases.
- A patient colleague, not a log. Assume they may be new to flows; say what a step is for in their terms, once.
- Bold at most one thing per block. A fixed block's heading is that one thing; step labels, list lead-ins and the things to check stay plain.
- Raw values only when the user will type them or see them elsewhere — a flow name, a screen
name or button label as the builder shows it, a price, a link. Never element or screen ids,
internal action names (
closeFlow,navigate), config keys (_meta.screens), language codes, file names, element counts, checker output (valid: true) or tool names. Say what it means: "the Continue button now opens the paywall", "Spanish", "the publish check passed". - Full, clickable links, bare on their own line, never in backticks.
- Reply in the user's language, the fixed blocks too. Builder labels stay exactly as the builder shows them.
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. Images only.
- Upload it yourself at https://app.adapty.io/flows/`<FLOW_ID>`/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 you drew stand-ins:><n>are drawings of mine, already uploaded so the screens look finished —<which>. They are stand-ins, not your art: replace each one the same way, by path or in the builder.
<only if a clip is missing:><what>is a video, and there is no upload path for a clip — not one I can take either, so this one is yours whichever route you pick. The element is on the screen already, sized<w>×<h>to match the design: open https://app.adapty.io/flows/`<FLOW_ID>`/builder, click it and upload the file. Nothing else about the screen changes.
<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.
Name what the builder cannot do yet, beside it: for each behaviour or effect in the design the
flow does not perform (creating an account, a permission prompt, a blur), say what the screen really
does ("sends create_account to your app; the flow creates no account") and the routes that fit — a
custom action your app answers with its own screen, a second flow on its own placement if the
onboarding continues after it, or an image or a video (uploaded in the builder) of an effect — and
call it "not in the builder yet", never impossible.
When any route is a custom action, list the action IDs and offer to write their handlers in the
app: "I can add the code that answers these in your app. Want me to?" — on a yes, hand the IDs to
the adapty-integration skill, which owns app code; without it, find the platform's "Respond to
actions" page through the adapty-docs skill and link 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 |
the adapty-docs skill | You need an Adapty docs page nothing here links — a dashboard behaviour, a CLI or API detail. It routes to the right docs index instead of guessing a URL |
| 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), icons.py (glyph lookup,
any phase), localization.py (phase 2, the catalog), 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 # errors 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> --json # 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). Run it with --json redirected to a file, and keep the file: the human output omits
preview_base64, the config binds it as previewValue, and nothing else returns it — so that
file is the asset's record, and a second upload is a second asset rather than a way back to the
value. Call shape, the three values to capture, the two config shapes they bind into, how to get
a preview back when you no longer have one, 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.) -
The lock token comes from
flows config getand from nothing else. Two fields share the nameupdated_atand mean different things. Theconfig getenvelope carries it as epoch milliseconds (1787210847609), and it marks the last change to the content. That value is the optimistic lock.flows get,flows listandflows createcarry an ISO string (2026-09-24T12:14:40Z), and it marks the last change to the flow row (name, status). You already hold that one after a publish poll, and it is the wrong one:UA="$($ADAPTY flows config get <FLOW_ID> --app <APP_UUID> --json | jq -r .updated_at)" # right $ADAPTY flows config update <FLOW_ID> --app <APP_UUID> --config-file <f> --expected-updated-at "$UA"A raw ISO string fails before anything is sent (
Expected an integer, exit 2). Never convert it to milliseconds to make it fit: the flag accepts the number, and the write comes back409with "This flow configuration was already updated by <name>" — naming a person who did not touch it. So before believing a 409, check where your token came from: if it was notconfig get, the conflict is yours, and re-runningconfig getand retrying with its value clears it. A 409 on a token that did come fromconfig getis a real edit — re-fetch and apply your change to the new config. Never drop the flag to get past either. -
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 its updated_at for the write, not
the one flows get or flows list printed.
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
jq .config flow.working.json > draft.json
Two files, two shapes. flow.working.json and flow.backup.json are the envelope
config get and config update return; draft.json is the bare config you edit, check,
preview and send. A new flow has no envelope until its first write: author straight into
draft.json.
The text is in the catalog. Every localizable field holds a {_lid} ref, and its per-locale
values live in localization.content — so a copy edit or a translation changes
content[id].values[<localeId>] and keeps every id, and an entry several fields share changes all
of them (transforms.md decision 1). Anything you write inline — a
flowkit fragment, a catalog template, a hand-written value — gets catalogued before phase 3:
python3 references/localization.py catalog draft.json
A fetched config below schemaVersion 13 is converted first, the same way; below 12 the user opens
and saves it in the Flow Builder and you fetch again. The rules:
flow-schema.md → Localization: the catalog.
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 — in Rebuild mode
(below) the reference's style then replaces the seed's theme. 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, and whose is it? Answer it out loud: it decides where each part of the screen comes from.
| What you were given | Mode | Structure from | Style and copy from |
|---|---|---|---|
| Their own design: their Figma, a screenshot of their own app, or an explicit "rebuild / match / copy this exactly" | Rebuild | the reference | the reference |
| Someone else's design with the mode said: "in my style", "inspired by", "adapt this for my app" | Adapt | the reference | the app |
| No design | You choose | a teardown skill (below) | the app |
When the request names neither mode — "build the same onboarding", "something like this" — ask before you build, one question, and wait:
Do you want an exact copy of this, or the same structure in your app's own style and words?
A wrong guess costs a whole build and a flow that cannot be deleted; the question costs one turn. Only when nobody can answer (an unattended run, or the user said not to wait) take Adapt and say so in one line.
In Adapt, the reference gives the shape and the app gives everything else. Take the sequence, the sections, the component patterns and the hierarchy from the reference. Take the palette, type scale and weights, corner radii, button and card treatment, icon style and spacing rhythm from the app, and write the copy for the app: its name, its products, its voice. Never ship another brand's name, its claims, its offers or its social proof.
Take the app's style from its code when the session is in its repo (theme, colour assets, button and card components, strings), else from its own flows — the ones its placements show first. With neither, ask once; with no answer, use the reference's style and say so.
In every mode, 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 (team-diagnosed). In Rebuild, compare against the
reference file rather than your memory of it (phase 4). No design 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 take three values off that one call,
--jsoninto a file that outlives the command so the preview blob stays out of your context:
Bind all three on anmkdir -p media $ADAPTY flows media upload --app "$APP" ./hero.png --json > media/hero.json MEDIA_URL=$(jq -r .url media/hero.json); MEDIA_ID=$(jq -r .id media/hero.json) MEDIA_PREVIEW=$(jq -r '.preview_base64 // empty' media/hero.json)imageelement or flat inside afill— two different shapes, and theidis a string even though the command prints a number.previewValueis not optional in practice: without it the renderer draws a transparent 1×1 until the asset downloads, so the screen ships with a hole in it. An asset you uploaded earlier is rebound from its record file, or lifted out of a config that already binds it — never re-uploaded, and never bound as a bare URL you remembered. Anything that hands you a URL and no preview is silent about the value, not evidence the field is optional (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.
A clip is in none of those three states: the upload refuses a video, so emit a sourceless
video element — flowkit.video(fixed_h=…) or the catalog's video-hero / video-card — and
say in words that they upload it in the builder
(media.md).
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 draft.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.
Pass BASELINE= whenever you fetched a config — the pristine copy from step 2. A brand-new
flow has no config to fetch (config get answers with an error), so it runs without one and has nothing older to
report. The schema describes the newest
schemaVersion while most live flows are older, so an unbaselined run on a v9 or v10 flow reports
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.
And while the products are unsettled it reports nothing but the products — binding is an early stage, so an unbound card or an undeclared product hides every later defect behind it. That is what makes the local walk above the fast path rather than the thorough one: it names those defects anyway, at no round trip. validate.md.
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. The digits never leave the timer: a timer fires only when a text
inside it shows its digits — a logo or plain copy as its only child does not fire. To hide them
once the user has checked, colour them like the background; never delete them. The device-verified
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 approval on a description is not an approval on the screen. Split it in two, because the two halves need different things from the reader.
Screens that changed — one row each, so "there are four of these" is visible at a glance.
Render every touched screen from the draft, and its before from the backup (preview takes the
backup envelope as-is, no jq). A new screen has no before; say so rather than dropping the row.
Screen What changed Look at Paywall (new) outcome rows, two plans, trial badge Paywall, after — open in your browser Daily goal nothing visual Daily goal, after
Name screens as the builder shows them and link each render with the screen name as its text — the file name is yours, not theirs.
Changes with nothing to see — list them separately and say why. A reader who has just looked at four screenshots will otherwise assume the pictures were the whole change:
- Daily goal: the Continue button now opens the paywall instead of closing the flow.
- The two new plan cards are registered with their products, so the flow can publish.
- 38 texts translated into German. The 4 images stay the same in every language. The preview only draws English, so check German on a device.
Open exactly one of them live, and mark which row it is. You are on their machine, so open it rather than handing over a command to paste:
URL="$($ADAPTY flows config preview draft.json --screen <id>)"
open "$URL" # macOS; xdg-open on Linux, start on Windows
The explicit opener is needed because your shell captures stdout, so the CLI prints the URL instead
of launching a browser as it would on a terminal. preview is fully local — no auth, no app id, no
write. Print the command instead only when there is no display (remote or headless) and say
that is why.
One tab, whatever the size of the change: the page renders a single screen and does not walk the flow, so N tabs is N times the noise and still not the flow. Open the screen whose state matters most — a picker, a toggle, a selected plan — because state is the one thing a static PNG cannot show. Identical screenshots do not mean an identical config (preview.md); that is what the second list is for.
About to save over <flow name>, currently <status, as the dashboard shows it: draft, published, has unpublished changes>.
<the two lists above>Removes: <each
REMOVESline in plain words, traced to the request that asked for it — or "nothing">. I kept a copy of the current version, so I can put it back if you need it.Save it?
Do not paraphrase this into "shall I save?" — the flow name, the status, what it removes and the way back are the content, and an approval given without them is not informed. Wait for a yes; a screenshot the user liked is not one.
If the flow is published, disclose what the save changes — then edit in place on their yes.
A published flow you save becomes dirty: the status is visible to their whole team and cannot
be reverted from the CLI, while end users keep seeing the published version until the next
publish. Put that sentence in the approval ask and proceed — editing a live flow is the normal
case, and the preview pair, the backup and the lock exist precisely so it is safe. Suggest a
fresh flows create only when the work is exploratory — a redesign the user wants next to the
original — never as the default answer to an edit.
Restoring from the backup — verified end to end. Re-read updated_at first, because your own
write has just invalidated the one you were holding:
UA="$($ADAPTY flows config get --app $APP $FLOW --json | jq -r .updated_at)"
jq '.config' flow.backup.json > restore.json
$ADAPTY flows config update $FLOW --app $APP --config-file restore.json --expected-updated-at "$UA"
Verified byte-identical on a real flow — so the recovery is real, but only if phase 2 actually took the backup, which is why that step is not optional.
Only then:
# one call: re-read the lock and write under it, because your own last write invalidated it
UA="$($ADAPTY flows config get $FLOW --app $APP --json | jq -r .updated_at)" \
&& $ADAPTY flows config update $FLOW --app $APP --config-file draft.json \
--expected-updated-at "$UA" --json > flow.working.json
Validate and preview both run on a local file, which is why the write comes last: one write when
the thing is right, instead of one per iteration. If you have already written and then found a
problem, that is fine — re-read updated_at from config get before the next write, because
yours is now stale.
That --json redirect is the read-back — no extra call. The write returns the same
envelope as get, so your working file is already in sync for the next round, and you can diff
it against what you sent. A faithful round trip is the norm, so any difference is a real finding —
a top-level key that vanished, a status you should not have emitted.
A write that changes nothing does not bump updated_at (measured once), so re-running the same
content is not a fresh lock token. On a 409 someone edited the flow since you fetched it and
nothing was written: re-get, re-apply your change to their config, write again. Never force
past it and never re-send your local copy — that is the content that would erase their work. If
their version differs in ways you did not author, ask rather than restore.
If you rebuilt the config from a script rather than patching the fetched one, you have the wrong
document — a rebuild replaces the live flow, taking _meta.screens and the screens[].products
registry it derives from (products.md) and every manual edit with it. Patch the fetched config;
merge.md names the only two cases where a rebuild is right.
The file deliverable
When the user asked for a file rather than a write, there is no approval gate — the contract lives
in phase 2, at the moment the file is written, and your closing report repeats which
status/id shape you chose. A file handed over without that sentence is undelivered.
Tell them to back up before importing it. A shape-invalid config can open empty in the builder, and the next save writes that emptiness over the real flow — no error to act on, no unlucky timing needed. Their untouched config is the only copy that survives it (merge.md).
Never end with the work in a local file. config update is the only save this surface has, and
saving is where you stop by default. Publishing is a separate, explicitly confirmed step the user
asks for — never something you fold into a write.
Then end with this callout, every time. A save is not a release, and it takes their word to
finish it. Fill the slots and keep all three steps plus the closing line — that line is the
point. If the user asked you to publish and you did, the heading becomes "Published — live
on <placement>" (or "Published — not on a placement yet"), step 3 and the closing line go, and
the rest stays.
Never publish with Terms or Privacy as inert text. That is the right draft default
(transforms.md decision 10), but on a published paywall flow-audit
calls it a blocker and both stores reject it. Before a publish you were asked to make, get the
two URLs; without them, save, don't publish, and say why.
Saved as a draft — your users can't see this yet.
Review it in the builder — refresh the page if it is already open:
https://app.adapty.io/flows/<FLOW_ID>/builder
Preview it on a real device and check <the specific things this build could not verify>. This link opens it in the Adapty mobile app, which draws it the way your users will see it:
<the preview link, bare>
On mobile, tap the link to preview. <the QR image line if they asked for one; otherwise the offer, or nothing>
Publish: say the word and I'll do it, or use Publish at the top right of the editor. After that I can attach it to a placement, which is what makes your app show it.
<only if a product card has no product chosen:>Publishing is blocked until you choose the products for<the cards, by label>— tell me which, or pick them in the builder.
<one line, only if the phase-2 missing-assets list still has open items:><n>assets are still placeholders — see the list above.
<the closing line, by the flow's status:>dirty— Until you publish, everyone continues to see the previous version.draft— Nothing is live yet: nobody sees this flow until it is published and attached to a placement.
Build the link for slot 2 yourself — do not send the user hunting for it. It is pure string
construction from the app id, the flow id and the config's localization.locales, so
mobile-preview.mjs produces it with no network call and no auth:
# The link alone — the default. No image, no window, no `qrcode` dependency.
(mkdir -p ~/.cache/adapty-flow-qr && cd ~/.cache/adapty-flow-qr && node <abs-path>/references/mobile-preview.mjs \
--app <APP_UUID> --flow <FLOW_ID> --config <abs-path>/flow.working.json)
# Add a QR as well, when it will actually be scanned:
# … --qr --md-base <your working directory>
Run it after the write, never before. The app fetches the saved draft, so a link built over an unsaved file previews the previous version and reads as "your edit did nothing". One link survives later writes, so hand it over once rather than per change.
The link is required in the callout. The QR is off unless the user asked for it — --qr writes
a throwaway PNG into the working tree and opens a window, so it is not a free addition. Never commit
the image.
Decide from what they actually said — nothing else is observable. You cannot tell whether someone is at a laptop or holding a phone, so do not build the decision on it:
| What you have | What you do |
|---|---|
| they asked for a QR, to scan, or to test on a device | --qr, and keep doing it for the session |
| they asked for the link only, or declined a QR | link only, and do not offer again |
| anything else, including no signal at all | link only, plus this one line |
On mobile, tap the link to preview. If you want a QR code to scan for device preview, just say so and I'll generate one.
The bare link goes in every callout, on its own line, and never in backticks — a code span is
not a link, and most terminals linkify a bare URL. When you do pass --qr, paste the
 line it prints into your answer too, whatever surface you think you are on.
Why the link is unconditional and the QR is not, why surface detection cannot work, the --md-base
rule, and why there is no character-art QR:
preview.md.
Fill that slot with the actual list, never with "check it works". You know which of your choices the render could not reach — a branch that fires on tap, a toggle, a non-default locale, a progress bar that advances, a screen that advances itself, glyph metrics that differ on iOS. A generic instruction gets skipped; three named things get tapped, and every defect this skill has shipped to a user came through a gap this slot exists to hand over (preview.md).
6. Attach it to a placement, then hand the ID over
A published flow reaches nobody until a placement points at it. The placement's developer
ID is the string the app fetches with, so publishing is the middle of the chain and not the end:
config → publish → placement → the app's call site. Offer this whenever a flow reaches
published. Never run it unasked — a placement cannot be undone.
Three ways in. You published it and polled to published; the user says they published it, in
the builder most likely; or this is where the conversation starts — then phase 1 has already sent
you here and phases 2-5 have no work to do.
Verify the status yourself, whoever published it. flows get <FLOW_ID> --app <APP_UUID> must
read published; publishing means poll, and anything else means it is not attachable yet.
Then read the placements before proposing one — one call answers both questions, whether this location already has a placement and whether the ID you would propose is free:
$ADAPTY placements list --app "$APP" --page-size 100 --json # the default page size is 20
What list shows | What you do |
|---|---|
| nothing for this location | placements create with a new developer ID |
| a flow placement that should now point at this flow | placements update — it rewrites every audience, so pass back the ones you are keeping |
| a paywall placement, even an unused one | Nothing. The type is fixed at creation and the write is refused. Propose a different ID and say why |
The ID needs an explicit yes, and this is the fourth fixed block. It is irreversible in a way the phase-5 write is not: unique across every placement in the app whatever its type, no rename, no delete from the CLI.
Ready to point a placement at
<flow name>(<flow-id>,published).
- Developer ID:
<id>— the string your app will fetch with.- Placement:
<new, titled "…">/<existing "…", now pointing at <what>>- Permanent. IDs are unique across every placement in the app whatever its type, cannot be renamed, and there is no delete from the CLI — a wrong one is spent.
Create it?
On their yes, one call:
$ADAPTY placements create --app "$APP" --title "<Title>" --developer-id "<id>" \
--audiences '[{"content_type":"flow","flow_id":"'"$FLOW"'","segment_ids":[],"priority":0}]'
The flow form of --audiences is the normal path — accepted against production. Every refusal,
which of them is yours to fix, the update variant and the dashboard fallback:
placements.md.
Then hand the ID over and stop. This skill does not touch app code:
Live at
<developer-id>. https://app.adapty.io/placements/flows/`<PLACEMENT_UUID>`Nothing in your app changes yet — it still has to fetch this placement and render what comes back. The
adapty-integrationskill owns that end: give it this developer ID and it wires the fetch and the rendering into your call sites.
That link takes the placement's UUID, not its developer ID — the two are different fields and
only the UUID routes. placements create prints it as id. The middle segment is the placement's
type, so a flow placement is /placements/flows/… and a paywall one would be
/placements/paywalls/… (placements.md).
Safety
Pass --expected-updated-at on every write except the first. A stale value fails instead of
clobbering, so this is a real guarantee rather than a warning. Read it from config get
immediately before you write — never from flows get, whose updated_at is a different field. Omitting it is last-write-wins and will silently overwrite an edit
someone else made in between.
The lock is not a merge. It guards the timing of a write and says nothing about its content: a document that was never based on the live config — regenerated, or patched from a stale local file — passes the lock and overwrites the flow anyway. So patch what you fetched, and diff before you write (merge.md).
Never write to a flow the user did not name. flows list is for finding the right one and
confirming it back to them, not for picking one yourself.
config update replaces the whole config. There is no partial write, no undo and no version
history. Prefer a fresh flows create for new work.
Overwriting an existing config needs an explicit yes. Phase 5 owns the form. The gate is keyed to whether the flow already has a config, not to how confident you feel about the edit — a clean validate, a good screenshot and a user who liked the design are all upstream of the question and none of them is an approval.
Deleting a flow is a dashboard action. There is no flows delete, so never claim to have
removed one — including a throwaway you created yourself. Name the flow and tell the user where
to delete it.
validate and preview change nothing and cost nothing. Use them freely.
Verify
Walked in phase 3 before the two commands, because this finds every row at once locally where
validate reports one per round trip.
references/verify-config.py ships and phase 3 runs it, and it mechanises all but two of the
referential rows. So do not re-derive them by eye: run it, and read its output as the checklist.
The full statements — what each invariant is, what breaks it, what a violation does — are
flow-schema.md → Invariants, which owns them.
What the tool cannot answer, and you must:
| Check | Why no tool catches it |
|---|---|
fill keeps the form the input used — object or array, never converted, and one layer | the form is only wrong relative to the input you fetched |
an image URL is one flows media upload printed in this session | a plausible public-media.adapty.io path is indistinguishable from a real one |
_meta.icons[].raw is real SVG — resolved through icons.py, not written by you | presence is checkable, authenticity is not |
| every price variable's field agrees with its product's period | needs the catalog, not the config |
That last one is a hard stop, not a disclosure: if the catalog has no product with the period the design needs, stop before the write.
Publish blockers (Common issues): a screen with
zero elements, a product element with no product, an incomplete interaction. validate reports one
per run. Never treat a publish blocker as protection for a defect you left in — the user clears
blockers, and clearing one often activates whatever it was masking
(products.md).
Warnings — report, never "fix": an unreferenced component; a declared but unreferenced entry in
variables[] or theme; an inert conditional whose branches all resolve to nothing. Real
configs contain all of these.
One warning splits by authorship: a catalog value under a locale id localization.locales does
not declare. If you wrote it this run it is your defect — declare the locale and translate
what adding a locale implies. If it came with the config you fetched, it is report-never-fix like the others:
it is usually half a locale run someone started, and finishing or deleting their work is not your
call. Name the two exits and ask.
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/adaptyteam/adapty-skills/flow-generator">View flow-generator on skillZs</a>