Skip to content

RFL — the Reflow Language

RFL is a small, human-readable language for deterministic browser steps. Agents write it, people review it in diffs, and Reflow executes it without a model call when nothing needs agent assistance.

A canonical rfl fence is always parsed as RFL. In a legacy ts step="…" fence, a non-empty body runs as RFL only when every non-comment line parses; otherwise the whole body runs as Playwright TypeScript. An empty legacy body is agent-driven.

Put intent in the ### heading above a canonical fence, or in the step="…" attribute. Do not put a free-text instruction inside a non-empty RFL body.

RFL sits under each plain-language step heading and stays committed, versioned, and diffable with the flow:

### Add the first product on the page to the cart
```rfl
click role=listitem nth=0 >> role=button name="Add to cart"
| click testid=product-grid >> text="Add to cart"
expect testid=cart-badge text="1"
```

Write one statement per line: verb target [value] [modifiers]. A # outside quotes starts a full-line or trailing comment.

open /checkout
open https://status.example.com
reload
go back
go forward

Paths resolve against the run target. Absolute URLs are allowed when a step intentionally crosses origins.

click role=button name="Pay now"
click role=row name~="Invoice 42" button=right
click testid=canvas at=120,80
dblclick testid=row-42
hover role=menuitem name="Account"
drag testid=card-3 to testid=column-done
scroll role=main to=bottom
tap role=button name="Menu"
fill label="Email" ${MAILBOX}
type role=textbox name="Search" "wireless headphones"
press role=textbox name="Search" Enter
press page Control+K
focus label="Promo code"
blur label="Promo code"

fill clears before entering the value. type appends one key at a time.

select label="Country" "United Kingdom"
check label="Save this card"
uncheck label="Subscribe to updates"
upload label="Attachment" fixtures/receipt.pdf
fill frame title~="Payment" >> label="Card number" "4242 4242 4242 4242"
on dialog accept
click role=button name="Delete account"
click role=link name="Open invoice" opens=tab
switch tab last
switch tab main
click role=button name="Export CSV" opens=download
expect download filename~=".csv"

Dialog handling applies to the next action. switch tab accepts main, last, or a 1-based index.

expect <target> <condition> asserts immediately with Playwright’s normal auto-waiting. wait for is the same assertion form when the wait is the intent:

expect role=status name="cart count" text="1"
expect role=alert hidden
expect testid=price-total value="£42.00"
expect role=listitem count=3
expect label="Email" attr.aria-invalid="false"
expect label="Promo code" focused
expect role=textbox name="Notes" empty
expect page url~="/order/"
expect page title="Order confirmed"
wait for role=progressbar hidden timeout=30s

Element conditions are visible (the default), hidden, attached, enabled, disabled, editable, checked, unchecked, focused, empty, text="…", text~="…", contains="…", value="…", count=N, class~="…", and attr.<name>="…". Page conditions are url=, url~=, title=, and title~=.

Every run has a disposable mailbox. Use ${MAILBOX} as its address, then wait for a message, open a link, or extract an OTP without an agent:

wait for email from~="noreply@" subject~="Verify your email" timeout=60s
expect email subject="Welcome" unread
open email link href~="/confirm" subject~="Verify"
fill label="Verification code" email code pattern~="\d{6}" from~="login@"

Email conditions are from=, from~=, subject=, subject~=, href=, href~=, pattern=, pattern~=, and unread. timeout= is also accepted. No matcher means the next message, or the first link in the selected message. See mailbox testing and test accounts for complete signup and OTP flows.

capture records a value as a run output:

capture account_email = text of testid=account-email
capture verification_code = value of testid=otp
capture confirmation_url = page.url
capture source = "signup"
capture tenant = ${TENANT_ID}

The source may be text of <target>, value of <target>, page.url, a literal, or ${VAR}. A downstream flow named in needs reads a successful upstream output as ${needs.<flow-slug>.outputs.<name>}.

snapshot <name> captures an explicit visual contract. The optional threshold is a percentage and defaults to 1% of pixels:

snapshot checkout
snapshot cart-empty threshold="2%"

Checkpoint names are bare identifiers. The first green run establishes the baseline for that browser; later differences beyond the threshold fail and store a reviewable diff. See visual testing.

A run created with target: { kind: "browser" } may execute trusted shell commands directly inside the browser task image:

exec "node ./scripts/seed.mjs" in browser timeout=30s
bash "curl --fail http://127.0.0.1:3000/health" in browser timeout=10s
exec "test -f /app/package.json" in browser

bash is an alias for exec; both use /bin/sh -c. A browser self-target accepts only in browser, and an ordinary URL-target run has no local shell sink. The command shares the browser task boundary and is for trusted code. Read browser self-targets for the size, timeout, environment, and isolation contract.

A target is a chain of anchors joined by >>, from outer scope to inner:

click role=dialog name="Checkout" >> role=button name="Pay now"
AnchorPlaywright equivalent
role=button name="Pay"getByRole('button', { name: 'Pay' })
label="Email"getByLabel('Email')
placeholder="Search…"getByPlaceholder('Search…')
text="Add to cart"getByText('Add to cart')
alt="Logo"getByAltText('Logo')
title="Close"getByTitle('Close')
testid=cart-badgegetByTestId('cart-badge')
frame title~="Payment"frameLocator(…)
css=.legacy-gridlocator('.legacy-grid') — last resort

Target and statement modifiers include nth=N, substring forms such as name~="…", exact, timeout=10s, optional, button=left|right|middle, at=x,y, opens=tab|download, and scroll … to=top|bottom|into-view.

Continuation lines starting with | are ordered alternatives. Reflow tries each complete statement until one resolves uniquely:

click role=button name="Add to cart"
| click role=menuitem name="Add to cart"
| click testid=product-grid >> text="Add to cart"

Authors and agents can keep reviewed fallbacks when the UI genuinely has multiple forms. Automatic repair does not add fallback actions; it replaces one failed line under the tighter autoheal contract.

Reference a team environment variable as ${VAR}. Names inside a reference may also contain dots and hyphens, which supports cross-flow references such as ${needs.create-account.outputs.email}. ${MAILBOX} is the current run’s disposable inbox address.

Set team variables in dashboard settings. Values are encrypted and injected when the run is claimed; they are not declared in flow frontmatter. Substitution happens only at the last moment before a line or TypeScript body executes. Step names and recorded flow documents keep the ${VAR} placeholder, and substituted values in execution errors are redacted back to the placeholder.

  • Values are a quoted string, a ${VAR} interpolation, or one bare token with no spaces and no =. Use fill label="Promo code" "" to clear a field.
  • Multiple conditions on one expect line are AND-ed.
  • A bare expect <target> asserts visible.
  • timeout= accepts values such as 30s or 500ms.
  • at=x,y is relative to the element’s top-left.
  • A # outside quotes starts a comment.

The reference parser lives in packages/rfl, and the documentation is executable: every bare RFL example in the marketing docs is parsed in CI against that parser.

block = [ "rfl" INT ] line*
line = comment | statement | "|" statement
statement = nav | pointer | keys | forms | meta | assertion
| email | capture | visual | cli
nav = "open" value | "reload" | "go back" | "go forward"
pointer = ("click"|"dblclick"|"hover"|"tap") target
| "drag" target "to" target | "scroll" target
keys = ("fill"|"type") target value
| "press" ("page"|target) value | ("focus"|"blur") target
forms = "select" target value | ("check"|"uncheck") target
| "upload" target value
meta = "on dialog" ("accept"|"dismiss") [ "text~=" STRING ]
| "switch tab" ("main"|"last"|INT)
assertion = ("expect"|"wait for") ("page"|"download"|target) condition*
email = ("expect"|"wait for") "email" email-condition*
| "open email link" email-condition*
| "fill" target "email code" email-condition*
capture = "capture" NAME "=" ("text of" target | "value of" target
| "page.url" | value)
visual = "snapshot" NAME [ "threshold=" PERCENT ]
cli = ("exec"|"bash") value "in" value
target = anchor ( ">>" anchor )*
anchor = KIND ["~"] "=" value param* | "frame" param*
KIND = "role"|"label"|"placeholder"|"text"|"alt"|"title"|"testid"|"css"
param = ("name"|"name~"|"title"|"title~"|"url~") "=" value
| "nth=" INT | "exact"
condition = FLAG | CONDKEY ["~"] "=" value | "attr." NAME "=" value
FLAG = "visible"|"hidden"|"attached"|"enabled"|"disabled"|"editable"
| "checked"|"unchecked"|"focused"|"empty"
CONDKEY = "text"|"contains"|"value"|"count"|"class~" (* element *)
| "url"|"title" (* page *)
| "filename" (* download *)
email-condition = ("from"|"from~"|"subject"|"subject~"|"href"|"href~"
| "pattern"|"pattern~") "=" value | "unread"
value = STRING | "${" VAR_NAME "}" | BAREWORD
modifiers = ("timeout"|"button"|"at"|"opens"|"to") "=" value | "optional"

Statement modifiers may appear after the verb’s required operands. Anchor parameters bind to the anchor they follow. Anything outside the grammar fails a canonical RFL fence or selects Playwright TypeScript for a legacy ts step="…" body.

  1. Strict resolution. An anchor chain must match exactly one element. Zero or many matches without nth= fail instead of picking silently.
  2. Ordered fallbacks. The first uniquely resolving alternative wins and is recorded in the trace.
  3. Playwright actionability. Actions inherit Playwright’s auto-waiting; RFL has no sleep statement.
  4. Pure execution. Parsing and executing RFL makes no model call. Models participate only in authoring, an empty agent step, eligible autoheal, or semantic validation.
  5. Whole-body selection. Every executable line in a legacy body must parse for that body to run as RFL.
  6. Versioned blocks. A block may begin with rfl 1; later grammar versions can preserve older blocks.

A fully scripted green run uses no model unless validate: true, the agent participated, or an invariant requires semantic evaluation. A provider key is still required to start a run, and validate: false suppresses final semantic validation.