Writing flows

A flow says what a journey means, not where it happened. Selectors resolve against the live runtime graph and the accessibility tree, so a moved button re-resolves instead of going red — and the engine writes the repair back into the file so you review it in a diff.

Structure

flows/checkout.yaml
flow: checkout happy path        # the canonical name key (required)appId: com.anonymous.example     # optional; inherited by sub-flowstags: [smoke, checkout]          # optional labelsenv:                             # optional flow-level variables  USER: guest@acme.comprerequisite:                    # optional starting state  route: Home  reset: autohealing: adaptive                # strict | adaptive (default) | lossy-fallbackconfig:                          # optional run requirements  scrollMode: gesturesteps:  - tap: { testID: product-1 }  - type: { into: { testID: email }, value: "${USER}" }  - tap: { text: Checkout }  - waitFor: { route: OrderConfirmation }  - assert: { route: OrderConfirmation }

The header keys

flow (required)
The canonical name. It is what squiggle flow checkout matches, so name a file for what it proves — user_registration_happy_path, not test3.
appId
The bundle identifier to drive. Inherited by every sub-flow, so a composed journey declares it once.
tags
Labels carried on the flow, and the field Maestro’s tags: maps onto. They travel with the file and are readable from the library; the CLI selects a suite by directory rather than by tag.
env
Flow-level variables, interpolated as ${VAR} anywhere a string appears. Parameterise test data here rather than hard-coding it; a caller can then override it through runFlow.
prerequisite
The state the flow assumes. See Starting state and auto-correct below — this is the key that makes a flow repeatable rather than only working the first time.
healing
How hard the engine tries to re-resolve a selector that no longer matches. Three modes, below.
config
Run behaviour this flow requires. Some settings are not preferences — they decide whether the flow is valid. One that tests infinite-scroll paging needs the gesture loop’s real physics; one that asserts on a screen’s own transition timing needs the fixed sleeps. Put that in the file and it cannot be forgotten at the command line. See Run requirements below.

What a good flow looks like

Keep them modular — loginadd_to_cart checkout, composed with runFlow rather than written as one ninety-step file. Use waitFor for dynamic content instead of fixed sleeps. And after any tap whose effect must not be silent, assert the effect:

Selectors

A selector resolves to exactly one node. The fields, in the priority order self-healing uses to rewrite them:

testID
Explicit and stable. Prefer it — it is the only selector that survives a copy change and a redesign at once, and it resolves on the fast in-process accessibility path in about three milliseconds.
source
File.tsx:42 — where the component is declared, stamped at build time by the babel plugin. Survives renames of everything visible.
component · role · label · text · route
Progressively weaker. text is the easiest to write and the first thing a copy edit or a translation breaks.

Disambiguating a match

nth (1-based), index (0-based, for Maestro parity) and within narrow a selector that would otherwise hit several nodes. Prefer within: an ordinal silently points somewhere else the day a row is inserted above it.

YAML
- tap: { testID: sign-in }                       # strongest- tap: { source: LoginForm.tsx:42 }              # where it is declared- tap: { text: Add, within: { testID: cart-row }, nth: 2 }- tap: { point: "50%, 50%" }                     # last resort

Two planes answer, not one

The runtime graph answers for anything React rendered. The host’s accessibility tree answers for what it cannot see — native tab bars, system controls, a label that lives on a child view. Both are consulted, which is why a selector for a native tab works at all, and why an assert against a live counter resolves against the current label rather than the one captured at mount.

Coordinates are the safety net, never the plan

point takes viewport percentages or logical points. The recorder will also write a positional at: fallback beside a selector, but only when the semantic selector it found was weak — a role-only or bare-container hit, like a UITabBar tab. A confident testID records no at.

The selector always stays primary, because a coordinate cannot self-heal and only works over real HID. If a control needs one, that is a signal to instrument it rather than a result to keep.

Instrument for testability

Discovery is only as good as the accessibility tree. A bare Pressable wrapping an icon and a string exposes them as two unrelated elements, so a tap on the icon cannot be addressed by name. One role and one label make the whole control a single named thing:

a control an agent can name
// One labelled control instead of an icon and a string that// happen to sit next to each other.<Pressable  accessibilityRole="button"  accessibilityLabel={title}  testID="section-toggle">  <Chevron />  <Text>{title}</Text></Pressable>

The recorder marks low-confidence captures as (weak fallback) in its status line, and squiggle legibility --fix finds the candidates across the app and inserts the identifiers, with the source lines to review.

Steps

Every verb the engine understands:

tap · doubleTap · longPress
Tap gestures, by selector or by point/at. Delivered in-process by default, with a per-tap real-HID fallback. A target that resolves but sits outside the viewport is refused rather than tapped — see Off-screen targets.
type: { into, value, submit? }
Focus and type, optionally followed by a submit key. In-process runs use a single focusType round trip that polls for first-responder server-side instead of sleeping for the keyboard. Note that it appends — see prerequisite.reset.
swipe · scroll: { direction, amount? }
Directional gestures over real HID — one drag, two conventions. swipe names where the finger travels; scroll names where the content goes, so scroll: { direction: down } reveals what is below and the finger moves up. Both hold still before lifting, so amount is how far the content moves rather than a lower bound.
scrollUntilVisible: { element, direction, maxScrolls?, mode? }
Scroll until a target is reachable. mode: gesture (default) drives deterministic dwell-drags; mode: direct drives the enclosing scroll view server-side. See Speed & delivery.
navigate: { route }
Imperative route navigation — the portable alternative to a tap whose only job is to get somewhere.
waitFor: { route?, commitQuiet?, animationIdle?, networkIdle?, timeout? }
Graph-driven waits. animationIdle is backed by the injected dylib’s frame-hash probe; networkIdle waits for zero in-flight HTTP(S). Both report pending rather than passing when the app is not injected.
network.stub: { urlMatch, status?, body? } | clear
Answer matching requests locally, for deterministic offline replay. Needs a --capture-net launch.
launchApp: { app?, relaunch?, clearState? } · stopApp · back · pressKey
Lifecycle. relaunch: ifNeeded (the default) skips the cold start when the app is already running and injected; relaunch: always terminates and launches for a genuinely fresh process, and the host reattaches its graph socket afterwards.
openLink
Open a deep link through the dylib’s in-process handler, with a simctl openurl fallback. ${VAR} interpolates.
runFlow · repeat · retry · branch · escalate
Composition and control flow — see below.
assert
Behavioural assertions — see below.

Telling a step where it lands — expectRoute

A navigating tap can name the route it arrives at. Once that route is reached the engine skips the post-navigation transition tail instead of sleeping it out — dead time under deterministic replay. It is a pure speed optimisation: naming the wrong route costs you the tail, it does not change what runs.

YAML
- tap: { text: Checkout, expectRoute: OrderConfirmation }- navigate: { route: Home }   # expectRoute defaults to the navigate target

The recorder fills this in for you, from the route it observed the tap landing on.

Off-screen targets

A tap or type whose target measures outside the viewport fails, naming the rect, the viewport and the point that was clamped. It is not a no-op that got reported as a pass: the driver clamps an out-of-bounds point into the viewport — it has to, an out-of-bounds HID event can take the simulator down — and would otherwise press whichever control happens to sit at that edge. Five different off-screen tabs once “passed” onto the same visible one.

Scroll it in first with scrollUntilVisible, which is the explicit form and the one to prefer in a committed flow. Or run with --auto-scroll (Flow Studio: Auto-scroll; MCP: autoScroll: true on tap, input_text and run_flow), which scrolls the target into view and delivers once more, reporting scrolled into view on the step. It is off by default because it moves the screen on a step that never asked to, which is wrong for any flow whose subject is the scroll position.

Control flow

when is a modifier on any step; repeat, retry and branch take blocks. optional: true lets a step fail without failing the run, and label: renames a step in the report.

YAML
# when / optional / label are modifiers INSIDE a step body:# a step mapping has exactly one verb key.- tap: { text: Accept, when: { visible: { text: Cookies } } }- tap: { testID: dismiss, optional: true, label: close the promo }- repeat:    times: 3    commands:      - tap: { testID: load-more }- retry:    maxRetries: 2    commands:      - tap: { testID: flaky-submit }      - assert: { route: Done }- branch:    if: { visible: { text: "Signed in" } }    then:      - tap: { testID: continue }    else:      - runFlow: { file: login.yaml }

branch is the same two-armed conditional the intent runtime compiles into — its if is any assert body, evaluated read-only. That is deliberate: a program a model writes and a flow a human writes are the same IR, so a program can be saved into your flow library and a flow can be run by an agent.

Assertions

The point of an assertion is to re-observe the app. Squiggle can assert on much more than what is on screen, because the graph is already recording it:

YAML
- assert: { route: checklist }- assert: { visible: { text: "${USER}" } }- assert: { rerenders: { CartRow: { lt: 3 } } }- assert: { droppedFrames: { lt: 2 } }- assert: { log: { field: event, equals: order.submitted } }- assert: { action: { checkout.submit: { eq: 1 } } }

What you can assert on

route
The current screen route.
visible · exists · notVisible
Node presence — graph first, then the host AX tree, so dynamic text the graph only captured at mount (a live counter) still resolves against the current label.
rerenders
Render-count delta over the step window. { CartRow: { lt: 3 } } is a performance assertion you can put in CI.
droppedFrames
Frame samples over the step window.
store · query
Zustand store presence and mutation counts; TanStack Query state (status, fetchCount).
log
Captured app-log lines since the step began — contains, regex, or a structured field/equals pair.
action
defineAction-instrumented verbs invoked since the step began. The strongest proof available that an action had an effect rather than merely being dispatched.

Structured app events

contains and regex work against whatever your app already prints. Matching a field needs structured lines, and the SDK you already installed emits them — nothing to add, nothing to configure:

TypeScript
import { logEvent } from '@squiggle.sh/react-native';// Wherever the thing you want to assert on actually happens:logEvent('order.submitted', { orderId, total, currency: 'EUR' });

That is one console.log(JSON.stringify(event)). On a simulator it lands on stderr, where Squiggle's injected agent tees it — so a flow can match it field by field instead of scraping prose:

YAML
- assert: { log: { contains: "order submitted" } }        # no wiring needed- assert: { log: { field: event, equals: order.submitted } }- assert: { log: { field: currency, equals: EUR } }

The difference matters more than it looks. A message is copy and will be reworded; a field is a contract. Asserting { field: event, equals: order.submitted } keeps passing after somebody rewrites the string it prints, and keeps failing when the order stops being submitted.

When you want base fields on every event, a different sink, or one logger per unit of work rather than per app, build your own — same import:

lib/appEvents.ts
import { consoleJsonSink, createRunLogger } from '@squiggle.sh/react-native';const logger = createRunLogger({  fields: { source: 'shop-app' },   // stamped on every event  sink: consoleJsonSink,            // or your own: (event) => …});logger.event('order.submitted', { orderId, total, currency: 'EUR' });

Starting state and auto-correct

A replay assumes the app is at the flow’s starting state, and the previous run almost never left it there. prerequisite declares that state; the engine drives the app to it before step 1 and reports it separately, as a [pre] prerequisite: … line, so a correction can never flip the flow’s verdict.

YAML
prerequisite:  route: Home       # where the flow assumes it starts  reset: auto       # auto | scroll | back | relaunch  app: com.acme.app # optional; defaults to the flow's own appId

The reset strategies

auto (default)
Scroll to top, then back out to the route, then relaunch — escalating only while the target route has not been reached.
scroll · back
Force one cheap strategy and go no further.
relaunch
Runs unconditionally, even when the app is already on the target route — and that is the difference that makes a form flow repeatable. type appends, so a second run against a field still holding the first run’s text submits guest@acme.comguest@acme.com; only a genuinely fresh process clears it. The tier terminates before launching (launching a running app merely foregrounds it) and the host re-establishes the graph plane afterwards, so the run sees the new instance.

In Flow Studio the same thing is the Auto-Correct toggle in the toolbar. Recording with it on captures the current route into prerequisite for you.

Network stubbing

With --capture-net, a pass-through URL protocol sits in front of the app’s HTTP stack. That makes waitFor: { networkIdle } real, and lets a flow answer requests locally so a journey is deterministic offline:

YAML
- network.stub:    urlMatch: "**/api/orders"    status: 200    body: '{"orders":[]}'- tap: { testID: refresh }- waitFor: { networkIdle: true }- assert: { visible: { text: "No orders yet" } }- network.stub: clear

Dragging to a position

swipe with direction and amount moves a finger a distance. For a control whose value is the finger’s position — a slider, a segmented track, a colour picker — that is the wrong unit: the track is 333pt wide on one device and something else on the next, so a point distance is a different value on every screen. to: ends the drag at a fraction of the target’s own rect instead:

Terminal
- swipe: { testID: brightness-slider, to: 25% }    # a quarter across- swipe: { testID: brightness-slider, to: 100% }   # hard right- swipe: { testID: canvas, to: '10%, 75%' }        # both axes

One value is the x fraction and leaves y centred. The drag starts at the target’s centre; only the endpoint is specified, because that is what such a control reads. Percent only — a bare number would have to mean either points from the target’s origin or absolute screen points, and a step that reads correctly under two meanings is worse than one that refuses. 0–100% is enforced at parse time, and giving both direction and to is an error rather than a precedence rule.

Assert the result, not the swipe: a swipe reporting pass proves the gesture was delivered, and a control that ignored it looks identical in the step list.

Naming a route

A route selector — waitFor, assert, goal, branch — takes either the leaf screen name or the full navigator chain, and both are matched exactly:

Terminal
- assert: { route: wide }        # the leaf- assert: { route: oklch/wide }  # the chain, as the file tree reads

There is no suffix matching. lab/wide does not match oklch/wide, and cart/detail does not match shop/cart/detail — a selector that matched more than it named would be the same defect as a tap reporting success after landing on a different element.

Prefer the path when a name could be ambiguous. There may be one wide screen today; the leaf will silently start matching a second one the day it is added. The path comes from the app, so one built before the SDK carried it reports none — and the failure says so rather than leaving you to infer it from a mismatch that looks like a missing screen. Expo Router’s synthetic __root is stripped, since it prefixes every route and names nothing; route groups like (tabs) are kept, because they are navigators you named.

Run requirements

Six settings can be declared in the flow itself, because the right value for them is a property of the flow rather than of the machine you are running on or of what you are doing with this particular run:

Terminal
flow: product list pagingconfig:  scrollMode: gesture        # gesture | direct  fastSettle: false          # pin the fixed transition sleeps  autoScroll: true           # scroll an off-screen tap target into view  strictAssert: true         # a step the driver could not perform fails the run  stepDelayFloorMs: 150      # post-action floor, ms (max 2000)  transitionSettleMs: 900    # pre-action transition ceiling, ms (max 10000)steps: 

scrollMode: gesture is the common one: it says “scrolling is the subject of this test, not plumbing to reach a row”, so the drag physics that paging and pull-to-refresh depend on is not swapped out for a server-side offset write.

autoScroll and strictAssert are the two whose right value most often differs between two flows in the same suite. A journey that merely crosses a scroll container to reach a row wants an off-screen target recovered; a flow testing the off-screen refusal itself must not have it. A flow that is a contract wants a step the driver could not perform to fail the run; one with a conditional step — dismiss a banner if it appeared — needs that step skipped. No flag can tell either pair apart, which is the whole argument for putting them in the file.

Who wins, and why it depends on scope

one flow, no flag → the flow wins
Nobody said otherwise, and the flow knows what it needs.
one flow, explicit flag → your flag wins
A deliberate one-off is exactly what a flag is for, and it beats editing the file to try something.
several flows (squiggle flows, Studio multi-run) → the flow wins
One flag cannot be right for thirty flows, and not having to know which ones are special is the whole reason this exists.

A step’s own mode: still beats all of it. “Explicit” is per key, so --direct-scroll overrides a flow’s scrollMode while leaving its stepDelayFloorMs alone — and inheriting a default does not count, or every flow’s config would be cancelled by the very defaults it exists to change.

Both directions are announced before the run, because a flow silently losing its declared requirement is exactly as bad as a flag silently losing:

Terminal
! flow config: scrollMode=gesture (was direct)  declared by the flow; pass the flag explicitly to override! flow config: scrollMode=direct (was gesture)  your flag overrode the flow's declared requirement! flow config: scrollMode=gesture (was direct)  declared by the flow (a batch run never overrides it)

A misspelled or out-of-range key is a parse error naming the valid ones, rather than a shrug — a quietly-ignored sccrollMode is the exact failure this exists to prevent. --ignore-flow-config turns the block off for debugging.

What a flow cannot declare

The block is deliberately not every flag in YAML. Naming one of these is a parse error that gives the reason, so the argument is in the tool rather than only in the docs:

noAnimations, captureNet, captureLogs
Baked into the app’s process environment at launch, so honouring them per flow would mean relaunching mid-suite.
inProcess
Delivery belongs to the session, which Studio and the MCP server share across a batch. Genuinely a flow’s business — and still refused, because a key the CLI honours while the other two silently drop it is worse than no key at all.
headless, udid, platform, relay, profile
They name where you are running, never what.
fresh, continueOnFail, timeout, record, junit
They are about what you are doing with this run, and change between two invocations of the same unchanged flow.

A genuine typo still gets the plain unknown-key error with the full list, so the two cases stay distinguishable.

Healing

When a selector no longer resolves, the engine ranks the nodes that could plausibly be the same control, rewrites the selector, and reports what it did — healed {field}: from → to (score X). Three modes:

strict
No rewriting. A moved control is a failure. Use in CI when you want the test to catch the move.
adaptive (default)
Heal when the match is confident, and rewrite the flow file in place so the repair lands in your next diff.
lossy-fallback
Heal aggressively, including down to weaker selector classes. Useful while a UI is churning; noisy afterwards.

A run reports what it survived as well as its verdict. A green run with four heals is not the same as a green run with none, and the report says which you got.

Reuse

A flow can call another with runFlow, passing env to override its defaults. Give each sub-flow defaults that let it run standalone — then squiggle flow login works on its own, and the parent still controls the credentials.

YAML
- runFlow:    file: login.yaml    env: { USER: admin@acme.com, PASS: hunter2 }

Start from a recording

Writing YAML from scratch is the slowest way to get a flow. Drive the journey by hand and convert it:

Terminal
squiggle record --out checkout.sqrecsquiggle flow --from-recording checkout.sqrec --out flows/checkout.yaml

The generated file is a draft, and it says so — including a comment listing the taps nothing durable identified, with their coordinates. That list is the honest output of the exercise: those controls have no testID and no accessibility label, and no amount of cleverness will make a selector for them.

Or let the crawler find the journeys

squiggle crawl drives the app on its own to surface crashes and coverage, and to seed flows. Per screen it computes a structural signature from the graph — route plus interactive handles — to deduplicate, fills text inputs with format-plausible values, taps a seeded candidate, descends when a tap reaches a new screen and backs out when one is exhausted.

Terminal
squiggle crawl --seed 7 --max-nodes 50 --max-depth 5 --duration 30

It is bounded by --max-depth, --max-nodes and --duration, so it always halts; it exits non-zero if it observed a crash; and its deny-list refuses destructive verbs (“logout”, “delete”, “pay”, “checkout”…) by default. The seed is a random number, so a crawl is reproducible. Flow Studio runs the same thing from its Crawl button and loads the discovered flow.

Coming from Maestro

The grammar is deliberately close, and index, point, runFlow, repeat, retry, optional, label, env, tags and appId all mean what they mean there.

What is not supported is reported, never silently passed

Relational selectors (below, above, leftOf), hierarchy matchers (containsChild), state matchers (enabled, checked), eraseText, hideKeyboard, clearState, setLocation and the rest of the device-farm surface are parsed and then reported as unsupported. There is no JavaScript engine, so runScript, evalScript and assertTrue have no equivalent — the graph assertions above are the answer instead.

The distinction that matters: none of those quietly count as green. A migrated suite tells you exactly which parts of it are not actually being tested.

What Squiggle adds

Self-healing selectors, and behavioural assertions read from the runtime graph — rerenders, droppedFrames, store, query, action — rather than only what is on the screen. A UI-only runner cannot see any of it.

Running one

Terminal
squiggle flow checkout                       # by name, from .squiggle/flows/squiggle flow ./flows/checkout.yaml          # by pathsquiggle flow - < generated.yaml             # from stdinsquiggle flows ./flows/smoke                 # a directory, sharded across simssquiggle flow checkout --junit results.xml   # native CI test resultssquiggle watch checkout                      # re-run on every file save

squiggle watch is the payoff of a warm session: it re-runs against the same connection rather than paying an app handshake per iteration. A save during a run marks the loop dirty and re-runs once at the end instead of interrupting, because a flow killed mid-tap leaves the app on an arbitrary screen and the next run then fails for a reason unrelated to your edit.

Flows are portable across surfaces by construction: the same file runs from the CLI, from Flow Studio in the desktop app, and from an agent via run_flow — because all three drive the same engine rather than three implementations of it.