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.
The execution rule
Section titled “The execution rule”A canonical
rflfence is always parsed as RFL. In a legacyts 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.
Where it lives
Section titled “Where it lives”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
```rflclick role=listitem nth=0 >> role=button name="Add to cart" | click testid=product-grid >> text="Add to cart"expect testid=cart-badge text="1"```Statements
Section titled “Statements”Write one statement per line: verb target [value] [modifiers]. A #
outside quotes starts a full-line or trailing comment.
Navigate
Section titled “Navigate”open /checkoutopen https://status.example.comreloadgo backgo forwardPaths resolve against the run target. Absolute URLs are allowed when a step intentionally crosses origins.
Pointer
Section titled “Pointer”click role=button name="Pay now"click role=row name~="Invoice 42" button=rightclick testid=canvas at=120,80dblclick testid=row-42hover role=menuitem name="Account"drag testid=card-3 to testid=column-donescroll role=main to=bottomtap role=button name="Menu"Keyboard and focus
Section titled “Keyboard and focus”fill label="Email" ${MAILBOX}type role=textbox name="Search" "wireless headphones"press role=textbox name="Search" Enterpress page Control+Kfocus label="Promo code"blur label="Promo code"fill clears before entering the value. type appends one key at a time.
Forms and files
Section titled “Forms and files”select label="Country" "United Kingdom"check label="Save this card"uncheck label="Subscribe to updates"upload label="Attachment" fixtures/receipt.pdfFrames, tabs, dialogs, and downloads
Section titled “Frames, tabs, dialogs, and downloads”fill frame title~="Payment" >> label="Card number" "4242 4242 4242 4242"
on dialog acceptclick role=button name="Delete account"
click role=link name="Open invoice" opens=tabswitch tab lastswitch tab main
click role=button name="Export CSV" opens=downloadexpect download filename~=".csv"Dialog handling applies to the next action. switch tab accepts main,
last, or a 1-based index.
Assertions and waits
Section titled “Assertions and waits”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 hiddenexpect testid=price-total value="£42.00"expect role=listitem count=3expect label="Email" attr.aria-invalid="false"expect label="Promo code" focusedexpect role=textbox name="Notes" emptyexpect page url~="/order/"expect page title="Order confirmed"wait for role=progressbar hidden timeout=30sElement 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=60sexpect email subject="Welcome" unreadopen 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.
Captures and cross-flow outputs
Section titled “Captures and cross-flow outputs”capture records a value as a run output:
capture account_email = text of testid=account-emailcapture verification_code = value of testid=otpcapture confirmation_url = page.urlcapture 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>}.
Visual checkpoints
Section titled “Visual checkpoints”snapshot <name> captures an explicit visual contract. The optional threshold
is a percentage and defaults to 1% of pixels:
snapshot checkoutsnapshot 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.
Browser-image CLI
Section titled “Browser-image CLI”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=30sbash "curl --fail http://127.0.0.1:3000/health" in browser timeout=10sexec "test -f /app/package.json" in browserbash 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.
Targets
Section titled “Targets”A target is a chain of anchors joined by >>, from outer scope to inner:
click role=dialog name="Checkout" >> role=button name="Pay now"| Anchor | Playwright 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-badge | getByTestId('cart-badge') |
frame title~="Payment" | frameLocator(…) |
css=.legacy-grid | locator('.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.
Fallback chains
Section titled “Fallback chains”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.
Variables
Section titled “Variables”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.
Small print
Section titled “Small print”- Values are a quoted string, a
${VAR}interpolation, or one bare token with no spaces and no=. Usefill label="Promo code" ""to clear a field. - Multiple conditions on one
expectline are AND-ed. - A bare
expect <target>assertsvisible. timeout=accepts values such as30sor500ms.at=x,yis relative to the element’s top-left.- A
#outside quotes starts a comment.
Grammar
Section titled “Grammar”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 | clinav = "open" value | "reload" | "go back" | "go forward"pointer = ("click"|"dblclick"|"hover"|"tap") target | "drag" target "to" target | "scroll" targetkeys = ("fill"|"type") target value | "press" ("page"|target) value | ("focus"|"blur") targetforms = "select" target value | ("check"|"uncheck") target | "upload" target valuemeta = "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 "=" valueFLAG = "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 "}" | BAREWORDmodifiers = ("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.
Determinism contract
Section titled “Determinism contract”- Strict resolution. An anchor chain must match exactly one element. Zero
or many matches without
nth=fail instead of picking silently. - Ordered fallbacks. The first uniquely resolving alternative wins and is recorded in the trace.
- Playwright actionability. Actions inherit Playwright’s auto-waiting; RFL has no sleep statement.
- Pure execution. Parsing and executing RFL makes no model call. Models participate only in authoring, an empty agent step, eligible autoheal, or semantic validation.
- Whole-body selection. Every executable line in a legacy body must parse for that body to run as RFL.
- 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.