@vzn/vx — technical documentation
vx is a task runner and content-addressed build cache for JavaScript monorepos, shipped as one self-contained binary. It runs your task graph in parallel, caches every result by content, and replays work it has already done — and it stops there: everything beyond running and caching is a plugin on a documented seam.
That description fits several tools. What follows is the part that doesn’t: the problems vx treats as the hard ones, and what it actually does about each.
The problems
Section titled “The problems”1. A cache that returns the wrong answer
Section titled “1. A cache that returns the wrong answer”Every other failure degrades. This one lies. A stale hit replays bytes from a build whose inputs no longer exist, under a green checkmark, and nothing downstream can tell. It is the only bug class where “the build passed” is the symptom.
Most of vx’s design is a response to this:
- The config is evaluated, then hashed. Your
vx.config.tsis a TypeScript program. A tool that hashes the config file misses the preset it imported, the constant it computed, the environment it read. vx hashes the resolved object, so imports participate in cache identity. (caching.md) - Declared outputs are wiped before execution AND before restore. The tree ends every run byte-identical to the artifact — no survivor from a previous build masquerading as output.
- Upstream identity is folded in by INPUT, not output. A dependency’s key cascades to its dependents, so a change anywhere upstream re-keys everything downstream, without making a task’s key depend on bytes that were produced non-deterministically.
2. Nobody can tell you why it re-ran
Section titled “2. Nobody can tell you why it re-ran”A cache key is one opaque hash. When it changes, the tool says “miss”
and you guess. vx persists the per-component input fingerprint for
every entry, so vx why <task> names the exact component that moved —
this file, that env var, that upstream — rather than the fact that
something did. (cli.md)
3. Knowing what to run is a different question from knowing what changed
Section titled “3. Knowing what to run is a different question from knowing what changed”--affected maps changed files to projects. That mapping has holes
that are easy to miss and expensive to hit: a shared preset that no
project owns, a config that imports a file from another package. If
input hashing can see a change, selection has to see it too, or CI
runs nothing and reports success. vx routes changed files to projects
through three channels — directory containment, declared
workspaceFiles globs, and a static scan of what each config
imports. (modules/affected.md)
4. Boundaries that hold under pressure
Section titled “4. Boundaries that hold under pressure”A project’s globs never reach into another project’s directory. Inputs
are declared, never inferred — vx deliberately does not trace
filesystem reads, because an explicit input set is a correctness
property. A task that wants its reads confined to the set it declared
asks for that with exec.sandbox.
5. Doing all that without becoming the platform
Section titled “5. Doing all that without becoming the platform”Core is a pipeline with a hook at every stage — config (the
workspace config every verb sees), project (a project’s tasks),
graph (the edges), key (extra key material), fingerprint (a
lockfile claimed and keyed per project), schedule (which ready task
runs first), admit (whether it runs now), executor (where one
task’s command runs), cache (where artifacts live), telemetry
(where run records go), commands (which verbs exist), with setup
and teardown around the run — and applies none of them by
default. Running here and caching here are its floor: the local
executor and the local cache sit at the tail of every list, so a
workspace that declares nothing still runs and caches, and a plugin
that declines a task hands it back to this machine. Nothing here is a
first-party product you have to adopt
to get the good behaviour, and nothing distributed ships in this repo —
the hooks are how it gets built.
(modules/plugin.md, the design in
design/pipeline-2026-09.md, and the
“Extending vx” guides on the docs site)
What that buys, measured
Section titled “What that buys, measured”Numbers come from packages/vx-bench/ and are reproducible; the invariant behind
each is recorded in optimizations.md.
- A fully-cached run on a 100-project workspace completes in 74 ms wall-clock; on 476 packages / 1,428 tasks in 297 ms, where Turborepo 2.10 takes 342 ms and Nx 23 takes 1.38 s on the identical workspace — and restoring every output is 1.5× faster than Turbo (benchmarks.md, 2026-09).
- At 15k input files, deriving every cache key costs zero file reads — hashes come from git’s index for tracked, clean files.
- No daemon. Nothing to keep warm, nothing to restart.
Adopt it in two minutes
Section titled “Adopt it in two minutes”bun add -d @vzn/vx# …or globally, as the prebuilt standalone binary:npm install -g @vzn/vxDrop a vx.config.ts next to any workspace package:
import { defineProject } from '@vzn/vx'
export default defineProject({ tasks: { build: { // Cache-input env vars must ALSO be passed through — the child // env is isolated, and a key that varies on a var the task // can't see is incoherent. exec: { command: 'tsc -p .', env: { passThrough: ['NODE_ENV'] } }, cache: { inputs: { files: ['src/**'], env: ['NODE_ENV'] }, outputs: { files: ['dist/**'] }, }, }, test: { dependsOn: ['build'], exec: { command: 'bun test' }, cache: { inputs: { files: ['src/**', 'tests/**'] }, outputs: { files: [] } }, }, dev: { exec: { command: 'vite', timeout: 30_000, persistent: { readyWhen: 'Local:' } }, }, },})Optionally declare plugins — a remote cache, a remote executor,
telemetry — in vx.workspace.ts (vx init and @vzn/vx-migrate emit it).
Core applies none by default; running here and caching in .vx/cache
are its floor, so the file can be absent:
import { defineWorkspace } from '@vzn/vx'
export default defineWorkspace({ plugins: [] })Run things:
vx run build # current package (+ its dependency graph)vx run build test --all # every package, shared graphvx run build --filter "@app/*" # pnpm-style filtersvx run test --affected # what changed vs the base branch, and what depends on itvx watch dev # re-run on file changevx run build --dry # predicted hits/misses, no executionvx why app#build # what changed the key last timevx last # replay the previous run's summaryvx cache prune --older-than 7d --max-size 5gbRemote caching is plugin-driven: a cache plugin fills core’s
RemoteCacheLayer seam. @vzn/vx-reapi speaks
Bazel’s ActionCache + CAS, so NativeLink / BuildBuddy / Buildbarn /
bazel-remote all work — and the same package can run tasks on those
servers via the executor seam. Any other store implements the same
seam in a plugin.
Where to start
Section titled “Where to start”| You want to… | Read |
|---|---|
| The pitch: what vx does that others don’t | comparison.md § Where vx is ahead |
| Understand the overall shape | architecture.md |
Author a vx.config.ts | schema.md |
| Reason about caching | caching.md |
Trace what vx run actually does | execution.md |
| See each scenario as a diagram | flows.md |
| See every perf decision + invariant | optimizations.md |
| Use the CLI from a shell | cli.md |
| Write a plugin / replace a seam | modules/plugin.md |
| Benchmarks + side-by-side vs other runners | benchmarks.md, comparison.md |
| Coming from Turbo or Nx: the same thing, spelled in vx, with its test | parity.md |
| Check a Turbo or Nx bug report against vx, with the test that holds it | upstream-ledger.md |
| Modify, fork, or replace a module | modules/ (one page per source module) |
| Read forward-looking design notes | design/ |
If you have ten minutes: read comparison.md § Where vx is ahead, then
architecture.md. Together they cover the why and the shape.
Repository layout
Section titled “Repository layout”A Bun-workspaces monorepo: core @vzn/vx is packages/vx, and the
plugins that ship alongside it are its siblings under packages/.
Core src/ is seven modules — each directory’s index.ts is its
contract, and cross-module imports go through it only, enforced by
tests/module-boundaries.test.ts:
| Module | Owns |
|---|---|
cli/ | subcommand parsers, help, plan formatting |
orchestrator/ | run composition: discover → graph → schedule → execute → record |
workspace/ | project discovery, filters, --affected, the lockfile |
graph/ | the task graph and the two-tier scheduler |
cache/ | the local store (the floor), the layering, the RemoteCacheLayer seam |
exec/ | per-task execution primitives (spawn, env, sandbox), the local executor (the floor) |
util/ | small shared helpers |
Every source file is documented under modules/. Tests live
in tests/. The published plugin packages are @vzn/vx-reapi (Bazel
remote cache and remote execution), @vzn/vx-otel (OpenTelemetry
traces, metrics and logs), @vzn/vx-github (job summary and Checks API),
@vzn/vx-lockfile (per-project keys from the package manager’s
lockfile), @vzn/vx-schedule-history (order by the critical path
learned from run history), @vzn/vx-mcp (vx mcp, a server for AI
agents) and @vzn/vx-migrate (a Turbo, Nx, moon, wireit, lage or
scripts-only repo run unchanged, a Turbo or Nx remote cache kept,
vx.config.ts written from any of them), each
importing core only through the public @vzn/vx specifier — a
boundary the test suite enforces.