Every monorepo tool eventually describes itself with a paragraph of
nouns: caching, task graph, remote execution, affected detection,
generators, a dashboard, a cloud. vx's description is one sentence.
vx runs and caches a task graph, correctly, and stops there.
That sentence is a design, not a slogan, and this post walks through
what it commits us to.
The pipeline
A run is a pipeline with five stages, and each one has a documented
seam a plugin can fill:
-
Discover the projects in the workspace (the package manager's
workspace globs, one
package.jsoneach). -
Evaluate each
vx.config.ts. Configs are TypeScript programs, not JSON; the pipeline sees the object they evaluate to. -
Build one task graph across the whole workspace from
dependsOnand the package dependency graph. - Derive a content-addressed key per task from its declared inputs, its resolved config, and the keys of everything upstream.
- Schedule the graph: look each key up, restore hits, execute misses with bounded parallelism, save results.
In plugin terms the stages are named config → discover → project → graph →
key → fingerprint → schedule → admit, followed by the two
behaviour capabilities executor (where a command runs) and cache
(where artifacts live), the observe-only telemetry capability,
setup and teardown around the run, and commands (CLI verbs).
Core applies no plugin by default and names
none. A workspace with no vx.workspace.ts still runs and caches,
because the local executor and the local cache are the floor under
every list, not plugins you have to remember to add.
What a task is
A task is one shell command with declared inputs and outputs:
build: {
exec: { command: 'tsc -b' },
cache: {
inputs: { files: ['src/**', 'tsconfig.json'] },
outputs: { files: ['dist/**'] },
},
}
Three rules hold across the whole tool:
-
Caching is opt-in. No
cacheblock, no cache. When there is one, bothinputsandoutputsare required. vx never infers what a task reads. -
One command per task. A plugin may change where the command runs,
never what it is. Chain with
&&or split into tasks wired bydependsOnso each step caches on its own. - Project boundaries are hard. A glob never crosses into another project's directory. What one project needs from another arrives through the graph, as an upstream task's outputs.
What is not inside
The list below is not a roadmap gap. It is a boundary, and it is in the
repository's memory file so nobody re-proposes it by accident:
- No cloud, no account, no dashboard. vx does not know your organisation exists.
- No daemon. Every run pays its own discovery and still answers a fully cached 3,270-task graph in about half a second.
- No auto-inferred inputs. A traced read set describes what a task read once, on one machine, after the fact. A key is needed before the task runs. You declare inputs, and the sandbox lets you enforce the declaration.
- No JavaScript-function tasks. The shell is the API. Your tools stay yours.
- No named inputs, no global inputs, no global env. Configs are TypeScript. A shared preset is an import and a spread.
-
Nothing distributed in the core repository. Remote caches, remote
execution, telemetry sinks, agent protocols, GitHub summaries are all
plugins in their own packages.
@vzn/vx-reapi(Bazel Remote Execution API) is the proof the seams are wide enough to build those on.
Why the boundary matters
A tool that owns the cloud has an incentive to make the local path
merely adequate. A tool that owns nothing but the graph has one job:
be correct and be fast on the machine in front of you. Everything that
follows in this series is a consequence of that job. The next post is
about the fast part.
Where to look next: the Quickstart, the
Architecture page, and the
comparison with Turborepo, Nx and vite-task.
Originally published on the vx blog. vx is an MIT task runner and build cache for JS monorepos: github.com/vznjs/vx. Written with AI assistance.
Top comments (2)
The explicit-input approach makes sense, but it puts a lot of weight on getting the dependency contract right. Take an OpenAPI codegen task in a monorepo: if its cache key includes the schema but misses the generator version or configuration, vx can restore a perfectly valid cache hit that produces an outdated client.
That's the trade-off with declared inputs: you get predictable caching, but only if the declaration captures everything that can affect the output. I'd be interested in how vx's sandbox helps developers catch those undeclared reads before they turn into subtle CI-only failures.
Good example, and that's the risk we built around. The generator version is covered already: a task's key includes its package's package.json, and with the lockfile plugin, the exact resolved versions of its dependencies. So bumping the generator is a miss, not a stale hit.
(Lockfiles: vznjs.github.io/vx/guides/configur...)
For config and anything else the task reads, turn on
exec.sandboxfor the task. A read of a file you didn't declare is then denied and the task fails, and a failed task is never cached. Run it sandboxed locally or in CI once, and an undeclared generator config shows up as a red task with the path in the error, instead of a quiet stale client later.(Sandboxing: vznjs.github.io/vx/guides/sandboxing/)
Once a violation is found, you decide: allow the read in the sandbox's
allow.read, or leave it out and it stays blocked. Allowing a read doesn't make it a cache input; if the file should change the key, list it incache.inputs.filestoo. Keeping the two explicit is the point.(Caching: vznjs.github.io/vx/guides/configur...)