Skip to content
GitHubRSS

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.

config → project → graph → key → fingerprint → schedule → admit
→ executor / cache → telemetry
setup and teardown wrap the run; commands adds a verb
StageWhat a plugin can do there
configSee and adjust the workspace config before anything uses it.
projectAdd, remove or edit one loaded project’s tasks.
graphAdd or drop edges, mark tasks requested.
keyContribute extra cache-key material per task.
fingerprintClaim a lockfile out of the workspace fingerprint and key it per project.
scheduleReturn a priority per ready task.
admitVet each local dispatch against what is running right now; false holds the task.
executorDecide where one task’s command runs.
cacheProvide a layer where artifacts live.
telemetryReceive immutable run records. Cannot change behaviour, by construction.
setupOnce per run, after the planning stages and before the first task.
commandsAdd a CLI verb. Core’s verbs match first; nothing can shadow vx run.
teardownFlush 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.

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-migrate fills the project stage from a turbo.json and each package’s scripts. A Turborepo workspace runs under vx with a two-line workspace file and no config rewritten.
  • @vzn/vx-lockfile uses fingerprint to claim pnpm-lock.yaml (or bun.lock, package-lock.json, yarn.lock) and key each task on its own project’s dependency closure. --affected follows the same claim.
  • @vzn/vx-schedule-history fills schedule with the critical path learned from run history, and admit with a memory reservation packed from what each task used before.
  • @vzn/vx-reapi provides both executor and cache against any Bazel Remote Execution API server: remote cache and remote execution from one plugin.
  • turboCache() and nxCache(), from the same package, are cache layers speaking Turbo’s /v8/artifacts and Nx’s /v1/cache wire formats, so an existing self-hosted cache server keeps working.
  • @vzn/vx-otel and @vzn/vx-github are telemetry sinks: an OTLP exporter with no OpenTelemetry SDK dependency, and a GitHub Actions job summary plus a check run on the built commit.
  • @vzn/vx-mcp is commands: 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.

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.