Skip to content

Write a flow

A flow is a Markdown document saved in Reflow or in your repository, run with reflow plan and reflow apply. It combines YAML frontmatter, prose intent, and named executable steps. Markdown outside the step fences remains useful context for people and agents, but does not execute by itself.

Choose where new tests are saved during onboarding or in Team settings. The syntax, dependencies, plugin bindings and execution workflow are the same in both locations. Repository examples use .reflow/flows/; shared tests are created through manage_flows or reflow flows create.

Scroll through the explanation to follow each part of checkout.md. This example uses a seed-customer dependency and a private Browser connection named site. Your agent can adapt the journey, setup flow and selectors to your application.

Name the behavior you want to check.

A flow is a Markdown test saved in Reflow or in your repository. Give it a useful name and a starting path. The path is combined with the target you select, so the test can follow your app from localhost to a PR preview.

Lines 1–3
---
name: Checkout with a discount
url: /checkout

Declare what must happen first.

This checkout depends on seed-customer, a separate flow that prepares its test customer. Reflow puts that dependency in the plan and runs it first. If it cannot complete, checkout is blocked. self_heal and validate are off so this flow uses deterministic checks.

Lines 4–6
needs: [seed-customer]
self_heal: false
validate: false

Choose the commands your test can use.

The site alias adds Browser commands to this flow. It uses the private connection named site on the machine running the test. Your agent selects the plugin’s exact version, keeping repeat runs on the same command schema.

Lines 7–12
providers:
  site: Browser
plugins:
  - name: Browser
    sourceCommit: 5852a91f8ba9f4c73585a5984a96117a159c068f
    descriptorDigest: c86d7af94675fed13771b48b5f75f60a4988a2cf4158dd6d211e0f651006e505

Make the values explicit.

promoCode is a string input with a default. Supply a different value through your target’s input file, without rewriting the journey. Private connection settings and credentials stay outside the flow.

Lines 13–15
inputs:
  promoCode: {type: string, default: QA10}
---

Write steps in your application’s language.

The heading explains the intent. Commands inside the rfl fence execute in order. Here, the browser opens checkout and enters the declared promo code. Replace these selectors with the controls in your app.

Lines 16–22

### Apply the customer’s discount

```rfl
site.open url="/checkout"
site.fill selector="[data-testid=promo]" value="${inputs.promoCode}"
site.click selector="[data-testid=apply-promo]"

Assert the outcome. Capture the result.

Check that the customer sees the discount, then capture the screen. The assertion checks a business outcome; the named snapshot gives your agent a visual to compare and bring into PR review.

Lines 23–25
site.expect selector="[data-testid=discount]" text="10%"
site.snapshot name="discount-applied"
```
checkout.md
---name: Checkout with a discounturl: /checkoutneeds: [seed-customer]self_heal: falsevalidate: falseproviders:  site: Browserplugins:  - name: Browser    sourceCommit: 5852a91f8ba9f4c73585a5984a96117a159c068f    descriptorDigest: c86d7af94675fed13771b48b5f75f60a4988a2cf4158dd6d211e0f651006e505inputs:  promoCode: {type: string, default: QA10}--- ### Apply the customer’s discount ```rflsite.open url="/checkout"site.fill selector="[data-testid=promo]" value="${inputs.promoCode}"site.click selector="[data-testid=apply-promo]"site.expect selector="[data-testid=discount]" text="10%"site.snapshot name="discount-applied"```

Use your business data alongside browser checks. This flow captures a repeatable selection of orders; orderLimit is a typed input with a default:

---
name: Inspect orders
providers:
db: PostgreSQL
inputs:
orderLimit: {type: integer, default: 10}
plugins:
- name: PostgreSQL
sourceCommit: 5852a91f8ba9f4c73585a5984a96117a159c068f
descriptorDigest: 52bdb147746c6fea6fe1f11aa839b05c7ee0eb9a9f97619380ae38a143f72afe
---
### Capture recent orders
```rfl
db.query sql="SELECT id, status FROM orders ORDER BY id LIMIT ${inputs.orderLimit}" key="id" checkpoint="orders"
```

db uses the private connection with the same name. key="id" uses a unique, non-null column to give the rows a stable order. The retained results let your agent inspect which order values changed.

To override the default, save {"orderLimit": 25} in a private JSON input file and supply it when selecting your target:

Terminal window
reflow target --group orders --bindings /absolute/path/to/connections.json --inputs /absolute/path/to/inputs.json
reflow plan
reflow apply

Use numeric or boolean inputs for values such as limits and feature switches. Do not concatenate untrusted text into SQL. See variables for typed references and outputs from upstream flows.

Generate values and pass them between flows

Section titled “Generate values and pass them between flows”

Use capture to give a value a name. Later statements can read it; a flow that lists this flow in needs can read its saved outputs. Receipts retain captures as strings; declared types restore numbers when consumed. Use number(...) to convert a text capture before arithmetic.

For example, .reflow/flows/seed-order.md generates test data without opening a browser or connecting to a plugin:

---
name: Choose order test data
inputs:
unitPrice: {type: number, default: 25}
---
### Generate the order
```rfl
capture suffix = random.string length=12
capture quantity = random.integer min=1 max=5
capture orderId = "test-${suffix}"
capture total = expression "number(${quantity}) * number(${inputs.unitPrice})"
```

The consumer declares the dependency and the outputs it reads. This .reflow/flows/check-order.md example prints a summary through LocalBash; replace the command with your application’s check when adapting it:

---
name: Check the generated order
needs: [seed-order]
providers:
worker: LocalBash
references:
needs.seed-order.outputs.orderId: {type: string}
needs.seed-order.outputs.quantity: {type: string}
needs.seed-order.outputs.total: {type: string}
plugins:
- name: LocalBash
sourceCommit: 5852a91f8ba9f4c73585a5984a96117a159c068f
descriptorDigest: c00f906e9113dc65cdcc314cfc0cc7464bbaad5780e6a69bcb1872517fb78d98
---
### Show the order values
```rfl
worker.bash command="printf 'Order %s / quantity %s / total %s\\n' '${needs.seed-order.outputs.orderId}' '${needs.seed-order.outputs.quantity}' '${needs.seed-order.outputs.total}'"
```

The generated values above have a fixed safe format. Shell and SQL interpolation is text substitution, not argument escaping or query parameterization; do not insert arbitrary user text this way. Keep credentials in private bindings and avoid capturing sensitive input values: captures become shared run evidence.

Run the consumer; Reflow includes its dependency:

Terminal window
reflow plan .reflow/flows/check-order.md
reflow apply .reflow/flows/check-order.md

The generated suffix and quantity belong to this flow’s named test data in the selected execution group. Re-planning, new applies, retries, browser replay and reflow taint seed-order preserve them. Changing unitPrice recomputes total without choosing another quantity. Renaming a generated capture creates a new identity; changing its generator options requires a reset or a new name.

To choose fresh data for the whole selected group, run reflow reset, then plan and apply again. Reset also replaces the group’s named mailboxes; it does not remove application records. Separate setup flows remain useful for expressing shared dependencies, but are not needed merely to keep random values stable. The producer must succeed and provide the named output before a consumer can run. See the variable reference for exact scope and Mailbox plugin for reusable test accounts.

Aliases belong in frontmatter; executable commands belong in the steps:

providers:
db: PostgreSQL
shell: LocalBash
ui: {use: Browser, binding: site}

The short form uses the alias as its private connection name. Use binding only to select a differently named connection, as ui does above. Declare each plugin’s exact version in plugins; your agent can select the pins with list_plugins.

Keep connection strings, working directories and credentials in the private JSON file passed to reflow target --bindings. The flow stays portable: each teammate supplies their own connections without editing the test.

LocalBash adds shell commands; DockerUbuntu runs commands inside an existing Ubuntu container. PostgreSQL captures database rows and can prepare test data. A custom plugin can add commands and comparisons that express your business rules. See writing a plugin.

FieldPurpose
nameDisplay name of the flow.
providersAliases for plugins and their private connection names.
pluginsPlugin name, source commit and descriptor digest from the installed catalog.
inputsTyped parameters with optional defaults.
referencesTypes of values read from other flows.
urlStarting path for a browser flow, such as /checkout.
needsFlow slugs that must complete before this flow runs.
prerequisitesChecks and optional setup before the steps.
coversRepository globs used to select flows affected by a change.
tagsLabels for organizing flows.
self_healOptional model-assisted runner: false disables in-run selector repair. Your coding agent can still repair and rerun.
validateEnable or disable model-assisted end-of-run validation.
invariantsAssertions checked at the end of a run.
browserBrowser engine; local browser flows use Chromium.
devicePlaywright device preset for browser emulation.
budgets.minutesMaximum duration.
budgets.tokensToken limit for model-assisted execution.

Use a path for url. Reflow combines it with your selected target, so the same flow can run against a local app, a PR preview or staging. An open command can use an absolute URL to visit another origin.

Select the target with reflow target, inspect the work with reflow plan, then execute it with reflow apply or reflow run. Your agent uses the same controller through MCP’s manage_execution tool. Plugins run on the connected device, so localhost reaches that device’s application.

The HTTP batch API accepts either target_url: "https://preview.example.com" or target: {kind: "browser", base_url: "http://127.0.0.1:3000"}. The browser form runs without a separate application container and allows shell commands in the browser image. See browser self-targets.

The HTTP batch runner also accepts named TypeScript steps:

```ts step="the checkout heading is visible"
await page.goto(URL);
await expect(page.getByRole("heading", { name: "Checkout" })).toBeVisible();
```

Within a ts step="…" fence, a body made entirely of valid RFL statements runs as RFL. Otherwise, the complete body runs as TypeScript with page, expect and URL in scope. Keep prose outside the fence: it is not valid TypeScript.

An empty ts step="…" fence asks the runner’s agent to carry out the named step. This requires a configured model provider. Use non-empty rfl fences for local CLI flows.