Skip to content

RFL — the Reflow Language

RFL is the command language inside Reflow flows. A flow can query a database, run a shell command, read a test inbox or drive a browser. Plugins define the commands and the evidence they return; RFL connects those commands to named bindings and values.

Agents write the steps, people review them in Git, and Reflow executes non-empty RFL steps without a model call when agent assistance and semantic validation are disabled.

A provider declaration gives a plugin a local alias. The alias selects commands; binding names the private connection configured for your execution target. For example, db can use PostgreSQL through a connection named orders-test:

providers:
db: {use: PostgreSQL, binding: orders-test}
worker: {use: LocalBash, binding: checkout}
container: {use: DockerUbuntu, binding: ubuntu-test}
inbox: Mailbox
ui: {use: Browser, binding: site}

The short form inbox: Mailbox uses inbox as both the alias and connection name. Aliases belong to the flow. Keep connection strings, tokens and local paths in private bindings supplied to reflow target --bindings /absolute/path/to/connections.json. The flow’s plugins list records each plugin’s exact source commit and descriptor digest; Write a flow has complete pinned examples.

PluginPrivate binding selectsCommands in a flow
PostgreSQLA test database; writes require allowWritesdb.query, db.execute
LocalBashAn absolute working directoryworker.bash
DockerUbuntuAn existing Ubuntu containercontainer.bash
MailboxYour Reflow team and tokeninbox.address, inbox.cursor, inbox.wait_code, inbox.wait_link
BrowserA browser session and targetui.open, ui.fill, ui.click, ui.snapshot

You can also declare a binding directly in RFL. Use either frontmatter or the statement form for an alias, because duplicate declarations fail validation:

provider db use="PostgreSQL" binding="orders-test"
db.query sql="SELECT id, status FROM orders ORDER BY id" key="id" checkpoint="orders"

Qualified calls use alias.command argument=value. The plugin descriptor defines required arguments, their types, and whether the command observes or mutates state. Reflow rejects unknown aliases, commands, arguments and incompatible types before execution. A mutating command’s declaration describes its effect; it does not grant operating-system permissions.

worker.bash command="./bin/check-orders"
container.bash command="./bin/check-worker"
db.query sql="SELECT id, status FROM orders ORDER BY id" key="id" checkpoint="orders"
capture email = inbox.address name="signup"
ui.open url="/login"
ui.fill selector="[name=email]" value=${email}
ui.snapshot name="login"

These fragments assume the bindings above and application-specific scripts and data. A command returns a snapshot with its status and any evidence. Only commands that declare a scalar return type can be assigned with capture. A SQL query, for example, retains rows as evidence; it does not return a scalar for capture.

checkpoint="orders" names retained plugin evidence for review. Browser screenshots, Bash output and database rows have different comparison rules. See snapshots and review for examples of each.

Typed inputs supply parameters. capture gives a returned or generated value a name, and later commands read it with ${name}. A dependent flow declares needs and reads ${needs.flow-slug.outputs.name} with a matching reference type. See variables and the complete flow examples.

capture suffix = random.string length=12
capture orderId = "test-${suffix}"
capture total = expression "number(${inputs.quantity}) * number(${inputs.unitPrice})"
capture email = inbox.address name="signup"

Named generated values and mailbox addresses survive replanning and partial reruns until reflow reset starts a new test-data generation. Reset does not delete test accounts or other records in your application. Shell and SQL interpolation is text substitution, so keep untrusted values out of command strings and SQL fragments.

An rfl fence contains executable commands. Every non-comment line must be valid RFL; invalid commands fail before execution.

Put intent in the ### heading above the fence, or in its step="…" attribute. Put explanations outside the fence.

Browser steps also support the compact statements below. In local plugin flows, declare a Browser binding before using them. The interactive browser runner uses the same selector syntax. Keep each executable fence under its step heading:

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

Saved browser flows capture the next download from an action marked opens=download before checking its filename. The listener starts before the click; exact output bytes and filename metadata become review artifacts. A later marked action replaces the current download, and consecutive filename assertions check that same retained file. Filename substring matching is literal.

Missing, interrupted, oversized or unretained downloads do not pass. Download evidence cannot be optional or hidden by fallback chains. A filename invariant checks the current retained file without triggering another download. Retained downloads are limited to eight files, 16 MiB per file and 64 MiB per run. These are evidence limits, not an OS quota for temporary browser files. Interactive sessions without a capture sink refuse download actions before clicking. The stepwise executor does not support tab switching.

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~=.

A mailbox-enabled run has a disposable mailbox. Local device jobs must request mailbox: "required" and have a configured delivery backend; see test accounts. 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, ${VAR}, or the computed and generated sources. 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 of visual difference and defaults to 1%:

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

Checkpoint names are bare identifiers. Captures need explicit approval before becoming a baseline; later differences beyond the threshold fail and store a reviewable diff. Finite animations may finish naturally for up to two seconds before capture. Use settle="500ms" or settle="5s" to change that bound (at most 10s), or settle="0ms" to capture immediately. A settling timeout fails and retains the actual image. Clocks and looping animations remain live. 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=.product-gridlocator('.product-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.

Declare typed inputs with optional defaults:

inputs:
orderLimit: {type: integer, default: 10}
unitPrice: {type: number, default: 25}
promoCode: {type: string, default: QA10}
giftWrap: {type: boolean, default: false}

Use ${inputs.orderLimit} in a command. ${orderLimit} is also accepted for a declared input. A whole plugin argument reference keeps the declared primitive type; inside a quoted string it becomes text. Inputs accept string, number, integer and boolean.

Supply overrides with reflow target --inputs /absolute/path/to/inputs.json. JSON keys are input names, for example {"orderLimit": 25, "giftWrap": false}. An explicit false, 0 or empty string overrides its default. Reflow validates values before execution; changing an effective input changes the plan. See the complete database flow.

Captures make values available to following statements in the same flow:

capture quantity = random.integer min=1 max=5
capture suffix = random.string length=12
capture requestId = random.uuid
capture email = "test-${suffix}@example.test"
capture total = expression "number(${quantity}) * number(${inputs.unitPrice})"
capture label = expression "string(${total})"
SourceResult
random.string length=12Lowercase letters and digits; length 1–64.
random.integer min=1 max=5Inclusive ordered safe-integer bounds; the range width must also be safe.
random.uuidA UUID-shaped test identifier with version 4 and variant bits.
A quoted templateA string with named references substituted as values.
expression "…"A scalar computed from literals, references and operators.

Expressions support +, -, *, /, %, unary minus, parentheses, number() and string(). References are values, not executable expression text. Expressions cannot call JavaScript, read files or make network requests. Unknown references, invalid conversions and non-finite results fail. The limits are 512 characters, 128 tokens and an expression-tree depth of 16. Long arithmetic chains count toward the depth limit, even without nested parentheses.

These helpers execute on the host in admitted CLI/MCP plan / apply RFL flows; no browser is needed for generated values. Browser sources such as text of, value of and page.url still need a browser. Use a matching CLI/MCP release that includes the helpers; older releases and other execution modes do not gain these commands by installing the skill alone.

Planning does not sample random values. Admission allocates each named generator once in the selected group’s test-data generation. New applies, retries, partial reruns, taint and browser-session reconstruction keep that value. Moving a capture between steps does not change it; its flow slug and capture name identify it.

ValueLifecycle
Named random.* capturePreserved until reflow reset; a new name gets a new allocation.
Named Mailbox addressShared across flows in the group until reset.
Expression or quoted templateRecomputed from current inputs when its step executes.
Browser or plugin observationRead again when its step executes; an unchanged plan can reuse its evidence.
Explicit inputUses the supplied value or declared default; edits invalidate affected evidence.

Changing a named generator’s kind, length or range refuses with a reset-or-rename explanation. Duplicate generated names in a flow are invalid. A flow can declare up to 256 generated names. The same name in another flow is a separate value. Shared groups use the same generation across devices; device groups are isolated. Selecting another target configuration for the same group preserves test data, although the new execution epoch invalidates previous execution evidence.

Terminal window
reflow taint seed-order # rerun with the same test data
reflow plan
reflow apply
reflow reset # replace test data for the selected group
reflow plan
reflow apply

Reset reruns random-generating and plugin-invoking steps, including fallback invocations, plus their dependents. Every plugin receives the generation in its runtime context, so this also reruns plugin observations that do not use named data. Unrelated steps without generators or plugin invocations can remain reusable.

Reset does not clear application accounts or unresolved execution uncertainty. It refuses active ownership and requires uncertain outcomes to be reconciled. These generators produce retained test data, not private keys or authentication tokens. Use a matching CLI/MCP/API build that implements this lifecycle.

A command declaring a scalar return type can feed an ordinary variable:

capture email = inbox.address name="signup"
capture before = inbox.cursor name="signup"

The inbox alias binds the Mailbox provider. The first command returns a string and the second an integer. A command without a declared return type cannot be captured; failed, missing or incorrectly typed values do not become outputs. false, 0 and an empty string are valid declared values. Provider snapshots remain reviewable evidence; Reflow does not scrape their JSON or rendered text to invent variables.

The producer captures named values. A consumer lists its producer under needs and declares the types of outputs it uses:

needs: [seed-order]
references:
needs.seed-order.outputs.orderId: {type: string}
needs.seed-order.outputs.quantity: {type: integer}

Read an output with ${needs.seed-order.outputs.orderId}. A reference binds to the exact successful upstream execution chosen by the plan, including reused results. A missing output or failed producer cannot supply a value. Captures are stored as strings in evidence; typed consumers validate and decode the declared primitive. Ordinary browser text captures are strings. Use explicit number() conversion when computing from text.

The two-flow walkthrough shows a complete producer and consumer. Inputs and captures follow the RFL scope; raw TypeScript fences have their own environment and do not automatically inherit all of these values. Use a declared plugin return or another supported capture source for values you need to pass along.

Keep credentials in private connection settings. Do not capture secrets or sensitive transformed inputs: captured values become retained, shared evidence.

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}. In the separate browser-run mailbox path, ${MAILBOX} is the current run’s disposable inbox address. For named inboxes that survive plan / apply reruns, use the Mailbox plugin.

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.

These are browser-run substitutions. Host-evaluated captures in plan/apply use the declared input and reference types described above.

  • 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.

RFL and the plugin framework are currently private source. Reflow checks these RFL examples against its parser during development. Public documentation and the CLI are available now; the language implementation is not yet open source.

block = [ "rfl" INT ] line*
line = comment | statement | "|" statement
statement = nav | pointer | keys | forms | meta | assertion
| email | capture | visual | cli | provider | invocation
provider = "provider" NAME "use=" value "binding=" value
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 | invocation | "random.uuid"
| "random.string" "length=" INT
| "random.integer" "min=" INT "max=" INT
| "expression" STRING)
visual = "snapshot" NAME { "threshold=" PERCENT | "settle=" DURATION }
cli = ("exec"|"bash") value "in" value
invocation = NAME "." NAME (NAME "=" 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 is an error in an rfl fence. For TypeScript steps in the HTTP batch runner, see step formats.

  1. For browser selectors, an anchor chain must match exactly one element. Zero or many matches without nth= fail instead of picking silently.
  2. For browser fallbacks, the first uniquely resolving alternative wins and is recorded in the trace.
  3. Browser actions inherit Playwright’s auto-waiting; RFL has no sleep statement.
  4. Parsing and executing RFL makes no model call. Models participate only in authoring, an empty agent step, eligible autoheal, or semantic validation.
  5. A block may begin with rfl 1 to name its grammar version.

The local deterministic author path needs no provider key: use non-empty RFL steps, self_heal: false and validate: false, with deterministic assertions. The separate remote batch API can require a configured provider key at run admission even for scripted flows. Semantic validation and eligible autoheal depend on the execution path’s provider authority; validate: false suppresses final semantic validation.