Skip to content
GitHubRSS

Configure

Tell vx what each package runs, what it reads and what it may see. Every field: the config reference.

Each task in a package’s vx.config.ts is one shell command (chain steps with &&). dependsOn says what finishes first.

packages/app/vx.config.ts
import { defineProject } from '@vzn/vx'
export default defineProject({
tasks: {
codegen: {
exec: { command: 'graphql-codegen' },
cache: { inputs: { files: ['schema.graphql'] }, outputs: { files: ['src/gen/**'] } },
},
build: {
description: 'compile TypeScript to dist/',
dependsOn: ['codegen', '^build'],
exec: { command: 'tsc -b' },
cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } },
},
e2e: {
dependsOn: ['build', 'api#build'],
exec: { command: 'playwright test', timeout: 600_000, retries: 1 },
},
// A group: no command, it only runs its dependencies.
ci: { dependsOn: ['build', 'e2e'] },
},
})
dependsOnRuns first
'build'build in this package
'^build'build in each package this one depends on (from package.json)
'api#build'build in the api package
'build.*'every task in this package whose name matches

A task-name pattern is allowed: dependsOn: ['build.*'] here, '^build.*' in dependencies. Bare wildcards and negation (*, !task) are not. ^build reaches through a package that has no build. A cycle or a missing 'pkg#task' is an error.

When a task failsvx
--continue=deps-ok (default)skips its transitive dependents and runs the rest
--continue=neverstops at the first failure
--continue=alwaysruns the dependents, but saves none of them
FieldDoes
exec.commandthe one shell command, run in the package’s directory
exec.envwhat the command sees (below)
exec.timeoutkill the task after this many ms
exec.retriesre-run a failed task this many times
exec.persistenta server or watcher that does not exit (below)
exec.sandboxonly the declared files and network (Sandboxing)
exec.remotefalse keeps it here; 'only' for a remote pool (Remote execution)

exec.remote is the one exec field stripped from the key: where a task ran says nothing about what it made. The rest is in the key, description included, so editing a description costs one re-run.

A task with a cache block runs once per set of inputs. List every file the command reads, and what it writes ([] for test or lint). Unsure the list is complete? Add exec.sandbox with allow.read listing the same files: a workspace read outside it fails the task. The sandbox checks allow.read, never cache.inputs, so it catches a missing input only while the two lists match.

packages/app/vx.config.ts
import { defineProject } from '@vzn/vx'
export default defineProject({
tasks: {
build: {
dependsOn: ['^build'],
exec: { command: 'vite build', env: { passThrough: ['NODE_ENV'] } },
cache: {
inputs: {
files: ['src/**', 'index.html', '!**/*.test.ts'],
env: ['NODE_ENV'],
workspaceFiles: ['tsconfig.base.json'], // from the workspace root
},
outputs: { files: ['dist/**'] },
},
},
},
})
The keyHolds
Always in itthe package’s package.json, the lockfile, the keys of the tasks it depends on, the task’s config, arguments after --
Always excludednode_modules, .git, .vx, *.tsbuildinfo, vx-lock.json, *.bun-build, files git ignores, the task’s own outputs, a nested project’s files
Out, when you say soa dependency only for order: cache.inputs.tasks: [], as the dev task does

Declared outputs are wiped before every run that writes the cache and every restore, so dist/ ends as the cache stored it; a hit that finds them already as stored skips both. A task that adds files beside an upstream task’s outputs wipes nothing before a run and only the files it recorded before a restore. A failed task is never saved. --force runs and refreshes the cache; --no-cache ignores it.

A fully cached 3,270-task run: vx 510ms, Turborepo 760ms, Nx 3.59s (benchmarks; the key, part by part: Caching in depth).

Terminal window
vx why app#build
Terminal window
app#build — run 019f5a02-…
this run 2026-07-13T05:39:20.590Z · success · executed · key f7ee661520…
previous 2026-07-13T05:37:29.550Z · success · key 8b2e9bb2e8…
verdict cache key changed between the previous run and this one (inputs differ)
what changed (1 component, 41 unchanged):
changed file packages/app/src/index.ts a1b2c3… → d4e5f6…
what to do:
file an edit re-runs by design; a file the task does not read belongs out of cache.inputs.files
The verdict line saysIt means
cache key changed between the previous run and this one (inputs differ)the lines below name what changed
cache key unchanged — this run was served from cache, nothing re-rana hit
cache key unchanged — the previous run on this key failed and saved nothing, so there was nothing to hita failure saves no entry
cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache without read)the run’s policy read no cache
cache key unchanged — no entry for this key was in the cache when it ran (pruned or evicted), so it executed and saved onethe entry was gone
cache key unchanged — re-executed on the same key (--no-cache / --force, or unrelated)none of the above; vx cannot name the cause
cache key unchanged — this run recorded no cache outcome, so whether it re-ran is unknownvx does not guess
this task declares no `cache` block — it runs on every invocation; its key is folded by dependents onlynot cached at all
this task recorded no cache key (skipped, or a persistent task) — nothing to compareno key to compare

A hit after you changed something means that something is not declared.

A task sees only the variables you pass it:

ListThe command sees itThe key sees itUse it for
exec.env.passThroughyesnosecrets and CI flags (CI, GH_TOKEN); stays on this machine
cache.inputs.envno, except on a @vzn/vx-reapi workeryeswith passThrough: a variable that changes the output
exec.env.defineyesyesa literal value; a remote task gets it too

The child always gets a small essential allowlist so normal CLI tools work: PATH, HOME, SHELL, USER, LOGNAME, TMPDIR, TEMP, TMP, LANG, LC_ALL, LC_CTYPE, TERM, COLORTERM, FORCE_COLOR, NO_COLOR, CI, NODE_OPTIONS, plus the Windows essentials. vx sets VX_RUN_WORKSPACE (the workspace root) and VX_RUN_TASK (the project#task running) on every task. The package’s node_modules/.bin is first on PATH. What vx itself reads: the CLI reference.

A persistent task is a server or watcher. Its dependents start once it prints a line matching readyWhen; if none comes, exec.timeout fails it.

packages/web/vx.config.ts
import { defineProject } from '@vzn/vx'
export default defineProject({
tasks: {
dev: {
exec: { command: 'vite', persistent: { readyWhen: 'Local:' }, timeout: 30_000 },
},
e2e: {
dependsOn: ['dev'],
exec: { command: 'playwright test' },
cache: {
inputs: { files: ['e2e/**'], tasks: [] }, // dev is for order, not the key
outputs: { files: ['playwright-report/**'] },
},
},
},
})

When the run ends, vx sends the server SIGTERM. One that ignores it gets a 2-second grace and is then SIGKILLed. A persistent task has no cache block. A sandboxed server on Linux lists its port in sandbox.allow.localBinding.

vx.workspace.ts, beside the root package.json, is optional. Without it, vx runs and caches on this machine.

vx.workspace.ts
import { defineWorkspace } from '@vzn/vx'
import { reapi } from '@vzn/vx-reapi'
export default defineWorkspace({
plugins: [reapi({ endpoint: 'cache.internal:443' })],
concurrency: 8, // default: the cores this process may use
cacheDir: '.vx/cache', // default: .vx/cache (relative to root)
timeout: 600_000,
cacheRetention: { olderThan: '30d', maxSize: '10G' },
})
FieldSets
pluginsasked in order; this machine is always last (Plugins)
concurrencytasks at once; --concurrency <n> overrides it for one run
cacheDirwhere the local cache lives; add it to .gitignore
timeouta default task timeout in ms; default none
cacheRetentionevict at the end of every run: olderThan unused, then least recently used past maxSize; default none

For a timeout, the first one set wins: a task’s exec.timeout, then --timeout <ms>, then VX_TASK_TIMEOUT, then this. It is never in a cache key. There is no globalInputs: import a shared array instead.

Without a plugin, the lockfile is in every task’s key, so one install re-runs everything. With @vzn/vx-lockfile (bun add -d @vzn/vx-lockfile), each package is keyed on its own dependencies, and --affected follows.

vx.workspace.ts
import { defineWorkspace } from '@vzn/vx'
import { pnpm } from '@vzn/vx-lockfile' // or bun, npm, yarn
export default defineWorkspace({
plugins: [pnpm()], // pnpm({ scope: 'workspace' }) keys the whole file instead
})
PluginA package’s key foldsInstall-wide, for every package
pnpm()every package it reaches, by name, version and resolved peers, with integrity and any patchevery top-level field but importers, packages, snapshots, patchedDependencies and catalogs (settings, overrides, onlyBuiltDependencies, …)
bun()the same, through Bun’s hoisted layoutevery top-level field but workspaces and packages (overrides, patchedDependencies, catalogs, trustedDependencies, …)
npm()the same, from package-lock.json versions 2 and 3the root package’s overrides
yarn()the same, from a berry lockfile, each entry with every field but its dependency lists; a yarn 1 lockfile records no workspaces, so every package folds the whole file__metadata, and the root workspace’s entry (its dependenciesMeta) through the root’s closure

Every package’s key also folds the root package’s own dependencies: their bins run from the root node_modules/.bin, which is on every task’s PATH. So bumping a root devDependency re-runs every package.

vx why names the part plugin @vzn/vx-lockfile/pnpm. An unreadable lockfile stops the run and names the install to re-run. An import you never declared is in no package’s key.