A pipeline with seams
The word “plugin” usually means one of two things. Either a plugin is a whole subsystem with its own configuration language (Nx executors), or it is a callback bolted to one event the tool happened to expose. vx uses the word the way Vite does: the core is a pipeline, every stage has a named hook, and a plugin is an object that implements the hooks it needs.
The stages
Section titled “The stages”config → project → graph → key → fingerprint → schedule → admit → executor / cache → telemetrysetup and teardown wrap the run; commands adds a verb| Stage | What a plugin can do there |
|---|---|
config | See and adjust the workspace config before anything uses it. |
project | Add, remove or edit one loaded project’s tasks. |
graph | Add or drop edges, mark tasks requested. |
key | Contribute extra cache-key material per task. |
fingerprint | Claim a lockfile out of the workspace fingerprint and key it per project. |
schedule | Return a priority per ready task. |
admit | Vet each local dispatch against what is running right now; false holds the task. |
executor | Decide where one task’s command runs. |
cache | Provide a layer where artifacts live. |
telemetry | Receive immutable run records. Cannot change behaviour, by construction. |
setup | Once per run, after the planning stages and before the first task. |
commands | Add a CLI verb. Core’s verbs match first; nothing can shadow vx run. |
teardown | Flush and close at the end of the run. |
A plugin is definePlugin(import.meta, hooks). Its name is its package
name, read from import.meta, never a field you set. Declaration order
in vx.workspace.ts is the order everywhere: executors are consulted in
order, cache layers are chained in order, telemetry sinks receive in
order.
What fits in one hook
Section titled “What fits in one hook”The proof that the seams are the right width is what has been built on them without a special case in core:
turbo()from@vzn/vx-migratefills theprojectstage from aturbo.jsonand each package’s scripts. A Turborepo workspace runs under vx with a two-line workspace file and no config rewritten.@vzn/vx-lockfileusesfingerprintto claimpnpm-lock.yaml(orbun.lock,package-lock.json,yarn.lock) and key each task on its own project’s dependency closure.--affectedfollows the same claim.@vzn/vx-schedule-historyfillsschedulewith the critical path learned from run history, andadmitwith a memory reservation packed from what each task used before.@vzn/vx-reapiprovides bothexecutorandcacheagainst any Bazel Remote Execution API server: remote cache and remote execution from one plugin.turboCache()andnxCache(), from the same package, arecachelayers speaking Turbo’s/v8/artifactsand Nx’s/v1/cachewire formats, so an existing self-hosted cache server keeps working.@vzn/vx-oteland@vzn/vx-githubaretelemetrysinks: an OTLP exporter with no OpenTelemetry SDK dependency, and a GitHub Actions job summary plus a check run on the built commit.@vzn/vx-mcpiscommands: a Model Context Protocol server for coding agents, a verb core does not know.
Every one of these lives in its own package and imports core only
through @vzn/vx’s public façade. A test pins the façade so it cannot
widen by accident.
The rule that keeps the seams honest
Section titled “The rule that keeps the seams honest”Seam over special case. When core grows a branch for one consumer,
the seam is too narrow, and the fix is to widen the seam, not to keep
the branch. Twice in this repository’s history a capability shipped
inside core and was moved out once the hook it needed existed: vx migrate became @vzn/vx-migrate, and the run-history scheduler became
@vzn/vx-schedule-history on schedule. Core got smaller both times.
The second rule is the one that keeps the floor under your feet: core
applies no plugin by default and names none. A capability a plugin
must supply, a remote, a wire format, is declared in vx.workspace.ts
or it does not exist. The one thing that is implicit is the
local floor: running here and caching here.
Writing one is a short guide: Writing a vx plugin.