Skip to content

RFL — the Reflow Language

Between your plain-language steps and the browser sits RFL (the Reflow Language): a small, human-readable, deterministic step language. Agents write it; humans review it in diffs. It exists so a flow can run without a model in the loop when nothing changed, and so any step can be regenerated against a live page when something did.

If a line parses as RFL, it executes deterministically — fast, no model. If it doesn’t parse, it is evaluated as free text by the agent.

RFL is a fast path, not a gate. An agent (or a human in a hurry) can always write dismiss whatever cookie popup appears as a step line; the run still works — the agent interprets it against the live page, at model speed and model cost. Over time agents converge free-text lines into RFL so runs get faster and cheaper. The run trace marks which lines ran on which path.

Natural language is the right interface for intent, but it isn’t deterministic. Raw Playwright is deterministic but brittle and hostile to review. RFL is the contract between the two:

  • Deterministic — one RFL program resolves to one Playwright execution. Same page, same result.
  • Covers Playwright’s interaction surface — every kind of interaction Playwright supports has an RFL construct (not every argument: the 20% of surface that covers ~all real flows).
  • Regenerable — when an anchor stops matching, the agent re-reads the plain-language step and emits new RFL for just that step.
  • Readable & diff-friendly — verbs read like English, one action per line, stable formatting. PR deltas stay reviewable.

In fenced blocks under each plain-language step, maintained by the agent — committed, versioned, diffed:

## Steps
1. 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"
```

One statement per line: verb target [value] [modifiers]. A # outside quotes starts a comment (full-line or trailing). Anything that doesn’t parse is a free-text step (see the rule above).

open /checkout # path, relative to the run's target url
open https://status.example.com # absolute urls allowed for cross-origin steps
reload
go back
go forward
click role=button name="Pay now"
click role=row name~="Invoice 42" button=right # context menu
click testid=canvas at=120,80 # position within element
dblclick testid=row-42
hover role=menuitem name="Account"
drag testid=card-3 to testid=column-done
scroll role=main to=bottom # top|bottom|into-view
tap role=button name="Menu" # touch devices
fill label="Email" $inbox # clears, then enters value
type role=textbox name="Search" "wireless headphones" # appends, key by key
press role=textbox name="Search" Enter
press page Control+K # page-level chord
focus label="Promo code"
blur label="Promo code"
select label="Country" "United Kingdom"
check label="Save this card"
uncheck label="Subscribe to updates"
upload label="Attachment" fixtures/receipt.pdf
# iframes join the target chain like any other scope
fill frame title~="Payment" >> label="Card number" "4242 4242 4242 4242"
# native dialogs: handling applies to the next action only
on dialog accept
click role=button name="Delete account"
# a click that opens a new tab
click role=link name="Open invoice" opens=tab
switch tab last # subsequent lines target that tab
switch tab main
# downloads
click role=button name="Export CSV" opens=download
expect download filename~=".csv"

expect <target> <condition> — or wait for as a synonym when the condition is expected to become true:

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: visible (default) · hidden · attached · enabled · disabled · editable · checked · unchecked · focused · empty · text="…" (exact) · contains="…" · value="…" · count=N · class~="…" · attr.<name>="…". Page conditions: url= · url~= · title= · title~=.

A target is a chain of anchors joined by >> (outer scope → 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

Modifiers: nth=N · name~="…" / text~="…" (substring) · exact · timeout=10s · optional (no-op if the target never appears — cookie banners) · button=left|right|middle · at=x,y · opens=tab|download.

Continuation lines starting | are ordered alternatives. The compiler tries each whole statement until one resolves; if none do, the step deviates and the agent regenerates it from the plain-language step:

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

Heals are cheap because the agent usually prepends the new reality and keeps old anchors as history.

$NAME resolves from flow variables: (set per-team in the dashboard, encrypted, never logged). Substitution applies anywhere a value appears — fill values, anchor values (label=$FIELD_NAME), paths. Builtins: $inbox (this run’s email address), $run.id, $target (the run’s base URL).

  • Values are a quoted string ("…", \" escapes), a $variable, or a single bare token with no spaces and no = (paths, key names). To clear a field: fill label="Promo code" "".
  • Multiple conditions on one expect line are AND-ed.
  • A bare expect <target> asserts visible.
  • timeout= takes 30s or 500ms. at=x,y is relative to the element’s top-left. switch tab takes main, last, or a 1-based index.
  • A # outside quotes starts a comment, full-line or trailing.

The reference parser lives at packages/rfl in the Reflow repo, and the documentation is executable: every RFL example on this page is parsed in CI against that parser. If the docs and the language disagree, the build fails.

block = [ "rfl" INT ] line*
line = comment | statement | "|" statement (* "|" = fallback *)
statement = nav | pointer | keys | forms | meta | assertion
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*
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 *)
value = STRING | "$" NAME | BAREWORD
modifiers = ("timeout"|"button"|"at"|"opens"|"to") "=" value | "optional"

Statement modifiers may appear anywhere after the verb; anchor params bind to the anchor they follow; anything else ends the deterministic parse and the line becomes free text.

  1. Strict resolution. An anchor chain must match exactly one element. Zero or many (without nth=) is a deviation — never a silent pick.
  2. Ordered fallbacks. First uniquely-resolving alternative wins; the chosen branch is recorded in the trace.
  3. Playwright actionability. Actions inherit Playwright’s auto-waiting. There are no sleeps in RFL.
  4. Pure compilation. RFL → Playwright involves no model and no network. Models are only involved in writing RFL (from your plain language) and in executing free-text lines.
  5. Parse or free text. A line either parses and runs deterministically, or it is a free-text step executed semantically. Nothing in between, and the trace says which happened.
  6. Versioned. A block may open with rfl 1. Grammar changes bump the version; old blocks keep compiling.
plain-language step (intent — yours or your agent's)
│ model, once
RFL block (agent-maintained, committed, diffable)
│ pure function, every run
Playwright execution (real browser, no model in the loop)
free-text line ──────────────► agent executes semantically (slower path),
then proposes RFL for next time

A fully cached green run costs one model call — the final intent check.