Local runs vs server runs
The execution engine is identical — flow run and a self-hosted worker call the same
runPlan with the same plan. What differs is where the plan came from and what was
checked before it ran.
There are three answers to that, and the difference between them is the difference between running your own code and running someone else's.
Three paths
| Who builds the plan | Executes your source | Checked before running | |
|---|---|---|---|
Local flow run | Your machine | Yes | Validation only — no policy |
| Project run | The run host, from a fresh clone | Yes | Validation → policy → routing policy |
| Uploaded or replayed | Already built; arrives as JSON | No | Validation → policy |
Local
You run flow run, your machine loads your pipeline.ts, and the plan executes. There is
no gate because there is no boundary — it's your code on your machine, and a policy you
could edit protects nothing.
Worth knowing explicitly: a local run is never policy-checked. If you're relying on policy to stop something, it only applies on the server.
Project run
The server has a project pointing at a repository. When it triggers, the run host — the worker, in the usual deployment — clones the repo, runs the project's setup, and builds the plan with the same loader the CLI uses.
That means the run host executes the pipeline author's TypeScript, at plan time, before
any policy has been evaluated. It's the same trust position as any CI system that runs a
config file from a branch: whoever can change pipeline.ts can run code on the worker.
Policy gates what the resulting plan may do, not what the plan-building code may do.
Once the plan exists it goes through validation, then evaluatePolicy, then routing
policy — including the worker-trust gate for production
labels.
Uploaded or replayed
A plan can also arrive already built, as JSON. The server validates it and evaluates policy against it without executing anything at all.
Replay is the same path: the server stored the exact IR it ran, so replaying re-reads that plan and re-checks it against current policy. A replay is not a blind re-execution — if policy has tightened since, the replay is refused.
Project runs have no stored plan of their own, which is why they are retried, not replayed: a retry re-clones and rebuilds, and can therefore pick up a different commit.
Why policy is shaped like the plan
Policy rules are written against the execution plan — stage ids, commands, container images — rather than against your TypeScript. From the source:
Because it operates on the serializable IR — not closures — a server can gate uploaded plans before running them.
That's the whole argument. A policy check against TypeScript would have to execute the pipeline definition to inspect it, which is exactly what you can't do with an untrusted plan. Because the IR is plain JSON, a server can read every command a plan will run, decide, and refuse — having executed nothing.
It also means policy is checkable in places source doesn't exist: an uploaded plan, a replayed one, a plan in a database.
The rules themselves are on Worker — one page, so there's one place to look when a run is refused.
What else differs
Everything below is a matter of what the caller wires up, not of the engine behaving differently.
| Local | Server / worker | |
|---|---|---|
| Credentials | No resolver — declare them for portability, supply them through your environment | Resolved just-in-time by the worker, from the configured providers |
| Artifacts | Not collected | Stored via the filesystem or S3 |
| History | Local SQLite, --no-history to skip | Always recorded, plus a durable event log a client can reconnect to |
| Cache | .flowwright/cache in the working directory | A shared root every worker uses |
| Scheduling | One run, foreground | A shared queue with leases, so a crashed worker's run is requeued |
Stash behaves the same in both — it's run-scoped and ephemeral either way.
Which to use
Run locally while you're writing the pipeline; that's the point of the CLI, and it's the
fastest loop. Put flow run in your existing CI when you want it on every
push without operating anything. Run the server when you want
central triggering, history that outlives a job, a tenancy
boundary around each team,
people who log in — and policy that the person
writing the pipeline can't turn off.