# 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".

- Skill: `adaptyteam/flow-generator` (Agent Skill, multi-file: 26 files)
- Install (CLI): `npx skillmds@latest add adaptyteam/flow-generator`
- Raw SKILL.md: https://api.skillmd.com/api/skills/adaptyteam/flow-generator/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: adaptyteam (https://skillmd.com/u/adaptyteam)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/adaptyteam/flow-generator

---


# 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**, so `flowkit.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.screens` forward
  ([products.md](references/products.md)).
- **An image you have no readable FILE for.** Given a path you can now upload it —
  `flows media upload` ([media.md](references/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](references/media.md)).
- **Real store prices.** They come from the store, not from Adapty; `products create` has 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`](references/flowkit.py) owns the mechanical parts —
the `hierarchy`/`map` split above all — and [patterns.md](references/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/`<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 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](references/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](references/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](references/validate.md) | `validate` says no, or you want to know what a green run does *not* prove |
| [preview.md](references/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](references/fidelity.md) | A reference image was given — the per-element inventory, the gap-closing ladder, and what becomes a user ask |
| [media.md](references/media.md) | The screen has an image: the upload's limits, element-versus-`fill` shapes, geometry, and when to rasterize |
| [products.md](references/products.md) | Before touching a `product` element — `products create` writes to a live dashboard |
| [merge.md](references/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](references/transforms.md) | You hit a point where two answers are defensible and silence is the only wrong one |
| [patterns.md](references/patterns.md) | You need a composite you cannot guess: tabs, progress bars, toggles, countdowns, plan cards |
| [placements.md](references/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:

```bash
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](references/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 get` returns an envelope, not the config**: `{config, remote_configs, status,
  updated_at}`, plus `publication_status`, `transform_error` and `publication_error` when the last
  publish failed. The document you transform is the `config` field, and both `update` and
  `validate` take that field alone. Handing `validate` the envelope returns
  `Invalid flow input` — which reads exactly like a broken config and is not one. (`preview`
  is the odd one out: it accepts either.)
- **`status` is not yours to write.** It belongs to the envelope and is **discarded** if you put
  it inside `config`. Do not emit it in a config you send to `update`, and do not treat its
  absence as a defect. (A *browser export* does carry `status` and `id` at the top level — that
  is a different document shape, and phase 5 covers what to do when the user wants a file.)
- **`updated_at` is an epoch integer** (e.g. `1787210847609`) and it is the optimistic lock.
  `flows create` prints an **ISO** timestamp instead, which `--expected-updated-at` rejects — so
  always take the integer from `config get`.
- **`config update` has no dry run.** `validate` and `preview` are 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](#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:

```bash
$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](references/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:

```bash
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](references/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](references/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](references/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](references/flow-schema.md#7b-the-id-analytics-sees-is-customid-and-leaving-it-blank-is-silent)).

**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:

1. `$ADAPTY products list` and show what exists — title, period, store bindings — and ask which
   of these belong on the screen. Most accounts already have the right products.
2. 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 create`
   cannot run without, so asking later just stalls the create.
3. Only then `products create`, behind its own confirmation gate
   ([products.md → Creating a product](references/products.md)).

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:

1. **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:
   ```bash
   URL="$($ADAPTY flows media upload --app "$APP" ./hero.png | sed -n 's/^URL: //p')"
   ```
   Bind it on an `image` element or flat inside a `fill` — two different shapes, and the `id` is a
   **string** even though the command prints a number ([media.md](references/media.md)).
2. **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.
3. **Nobody has a file** — a **styled empty `image`** where 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`](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](references/media.md#geometry-what-changes-when-the-asset-lands)).

**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](references/flow-schema.md#before-authoring-a-shape-you-have-not-seen-produced-grep-for-it)
— 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](references/flow-schema.md), and source any shape the config
does not already contain via
[patterns.md → Where to source a pattern, in order](references/patterns.md).

**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](references/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](#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**:

```bash
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](references/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](references/flow-schema.md).

`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: true` over 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](references/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:**

```bash
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](references/preview.md#what-a-render-costs-and-which-knobs-do-nothing)). 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](references/media.md#geometry-what-changes-when-the-asset-lands)).

**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](references/fidelity.md).

**Read [preview.md → What a render cannot show you](references/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](references/patterns.md#a-bar-that-stays-at-the-bottom-use-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](references/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 `stack`s 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](references/flow-schema.md#from-what-the-user-asks-for-to-what-the-json-calls-it)
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 `stack`s 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](references/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`](references/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](references/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:

```bash
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](references/merge.md)).

**Then show them the change — an 

…(truncated)
