Architecture
One idea explains the shape of the whole system: the execution plan is the boundary.
Your pipeline.ts is TypeScript with closures. What runs is JSON. Everything upstream of
that JSON exists to produce it; everything downstream consumes it and has never heard of
TypeScript. That's what lets the same pipeline run on your laptop, in someone else's CI,
and on a server that has never seen your source.
The two-phase model explains what that means when you're writing a pipeline. This page is about how the packages are arranged around it.
The layers
Dependencies point downward only. That's the whole rule, and three specific consequences of it are worth stating.
@flowwright/core has no dependencies
Not "few" — none. dependencies is empty. It's the authoring SDK plus the IR types, and
it is the one package your pipeline.ts imports directly, so anything it dragged in would
become something you'd have to install.
It does use Node built-ins (hashFiles reads the disk), so it isn't browser-safe. The
guarantee is about your dependency tree, not about where it can run.
@flowwright/api-contract is IO-free
No node: imports at all, one dependency (zod). It's the schemas the server, the CLI,
the workers and the web UI all agree on.
The proof is @flowwright/web: a browser app that depends on api-contract and nothing
else. If a node:fs import ever crept in, the UI build would break.
@flowwright/runtime knows nothing about servers
The local engine — runPlan, the executors, the event stream — has no idea the control
plane exists. control-plane depends on runtime, never the reverse.
This is why flow run on your laptop and a run on a self-hosted worker execute the
same function. There is no "local mode" branch to drift.
The layering is checked, not just intended
pnpm graph:check compares the real dependency graph against an exhaustive list. An edge
that isn't on the list fails the build, so core → runtime can't be added quietly. It
used to only check that expected edges were present, which meant it couldn't catch a
violation at all.
Loading a pipeline
flow run doesn't import your pipeline.ts. It spawns a child process that does.
flow run
└─ spawn: node --import tsx load-child pipeline.ts
├─ imports your pipeline.ts
├─ runs every stage body once (the recording pass)
└─ prints <marker>{"version":2,…} to stdout
└─ parse the JSON after the marker
└─ validate it
└─ runPlan(plan)
Three things about that are deliberate.
Arbitrary code runs in a process that isn't the CLI. Your pipeline is TypeScript that can do anything at import time. Keeping it out of the long-lived process keeps whatever it does from affecting the run.
Only the serializable IR crosses back. Not closures, not handles — JSON on stdout,
after a marker so a stray console.log in your pipeline can't corrupt the parse.
Module resolution is redirected, in both directions. @flowwright/core and any
@flowwright/plugin-* your pipeline imports resolve to the runtime's own copies, not
whatever is in the checked-out repo. Two reasons:
- The recording singleton must be the same instance your pipeline imports. Two copies of core means the stage bodies record into an object the builder never reads, and you get an empty plan.
- Your repo might not have them. A Java or Go repo with a
pipeline.tsand nonode_modulesstill works.
Both module systems are patched — the CommonJS resolver and the ESM hook — so it works whether your pipeline loads as ESM or CJS. A package the runtime doesn't carry falls through to normal resolution, and the redirect applies only while loading the definition: your stages run against your repo's own dependencies.
How the scheduler picks
Once the plan is JSON, runPlan walks it in topological order.
A stage is ready when every dependency has a decided status — succeeded, failed or skipped. Not "started": decided. Concurrency is a cap on stages in flight; when one finishes, the scheduler re-scans the whole order for whatever is now ready, so ties break by plan order rather than by who was waiting longest.
What happens when something fails — which stages get skipped, what continueOnError
changes, and why in-flight stages are allowed to finish — is on
Dependencies and ordering.
Where things live
| Package | Does |
|---|---|
core | The DSL you import, and the IR types |
runtime | runPlan, executors, the event stream, the loader |
plugin-* | Seven shared libraries of stage helpers |
api-contract | Schemas shared by every process |
control-plane | Server logic, framework-independent |
store-postgres · store-s3 | Swappable state and artifact backends |
provider-github · provider-gitlab | Source-control adapters |
outbound-network | Proxy-aware outbound transport |
cli | The flow binary |
server-host · worker-host · web | The deployable applications |
The applications are compositions — they wire libraries together and own no logic of their
own. That's why control-plane can be embedded in something else, and why the
server and worker are the same image with different entrypoints.
Everything except the three applications is published to npm. The field-by-field IR contract is the reference.