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.
Bind plugins to your environment
Section titled “Bind plugins to your environment”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.
| Plugin | Private binding selects | Commands in a flow |
|---|---|---|
| PostgreSQL | A test database; writes require allowWrites | db.query, db.execute |
| LocalBash | An absolute working directory | worker.bash |
| DockerUbuntu | An existing Ubuntu container | container.bash |
| Mailbox | Your Reflow team and token | inbox.address, inbox.cursor, inbox.wait_code, inbox.wait_link |
| Browser | A browser session and target | ui.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"Call a plugin command
Section titled “Call a plugin command”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.
Pass values between commands and flows
Section titled “Pass values between commands and flows”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=12capture 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.
The execution rule
Section titled “The execution rule”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
Section titled “Browser steps”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
```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"```Browser statements
Section titled “Browser 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.
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.
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~=.
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=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, ${VAR}, or the computed and generated sources. 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 of visual difference and defaults to 1%:
snapshot checkoutsnapshot 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.
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=.product-grid | locator('.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.
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”Inputs and overrides
Section titled “Inputs and overrides”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.
Capture and compute
Section titled “Capture and compute”Captures make values available to following statements in the same flow:
capture quantity = random.integer min=1 max=5capture suffix = random.string length=12capture requestId = random.uuidcapture email = "test-${suffix}@example.test"capture total = expression "number(${quantity}) * number(${inputs.unitPrice})"capture label = expression "string(${total})"| Source | Result |
|---|---|
random.string length=12 | Lowercase letters and digits; length 1–64. |
random.integer min=1 max=5 | Inclusive ordered safe-integer bounds; the range width must also be safe. |
random.uuid | A UUID-shaped test identifier with version 4 and variant bits. |
| A quoted template | A 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.
Reuse and random values
Section titled “Reuse and random values”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.
| Value | Lifecycle |
|---|---|
Named random.* capture | Preserved until reflow reset; a new name gets a new allocation. |
| Named Mailbox address | Shared across flows in the group until reset. |
| Expression or quoted template | Recomputed from current inputs when its step executes. |
| Browser or plugin observation | Read again when its step executes; an unchanged plan can reuse its evidence. |
| Explicit input | Uses 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.
reflow taint seed-order # rerun with the same test datareflow planreflow applyreflow reset # replace test data for the selected groupreflow planreflow applyReset 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.
Capture plugin results
Section titled “Capture plugin results”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.
Pass data between flows
Section titled “Pass data between flows”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.
Browser-run environment variables
Section titled “Browser-run environment 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}. 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.
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”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 | invocationprovider = "provider" NAME "use=" value "binding=" valuenav = "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 | 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" valueinvocation = 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 "=" 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 is an error in an rfl fence. For TypeScript
steps in the HTTP batch runner, see step formats.
Execution and failure rules
Section titled “Execution and failure rules”- For browser selectors, an anchor chain must match exactly one element. Zero
or many matches without
nth=fail instead of picking silently. - For browser fallbacks, the first uniquely resolving alternative wins and is recorded in the trace.
- Browser actions inherit Playwright’s auto-waiting; RFL has no sleep statement.
- Parsing and executing RFL makes no model call. Models participate only in authoring, an empty agent step, eligible autoheal, or semantic validation.
- A block may begin with
rfl 1to 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.