Workspaces
A workspace is a directory with its own manifest. A JavaScript app with a
package.json, a Rust crate with a Cargo.toml, a Python package with a
pyproject.toml. Lattice runs tasks per workspace and caches results per
workspace, so the workspace is the unit that everything else in the tool is
counted in.
You list them under workspaces in the repo’s lattice.json:
{
"workspaces": [
{ "name": "api", "path": "services/api" },
{ "name": "web", "path": "apps/web" }
]
}
That is the whole declaration for most workspaces. Everything else about them is optional, and much of it Lattice can work out on its own.
The list is explicit because a glob is a guess
path names one directory and is checked for existence exactly as written.
packages/* is read as a directory whose name is literally packages/*, and it
fails. There is no globbing and no filesystem crawl looking for manifests.
That is a real cost. A repo with forty packages under packages/ gets forty
entries, and a new package means a new entry. The alternative costs more.
A crawl has to decide what counts as a package, and every repo has directories
that look like one and are not: a fixtures tree with its own package.json, a
vendored dependency, an example app nobody builds in CI. A tool that discovers
those runs tasks in them, caches results for them, and reports failures from
them. A tool that reads a list runs what the list says.
The list is also the thing you can read. lattice.json tells you what the repo
consists of without you running anything, and a diff to it is a reviewable
change rather than a side effect of adding a file.
The workspace directory is a boundary, not a hint
Almost everything Lattice does to a task is scoped to that task’s workspace
directory. The input walk stops there. outputs globs are resolved from there.
A cache hit clears what those globs match inside that directory before it
unpacks the stored artifact.
So a path has to stay inside the repo. An absolute path, or one that climbs
out with .., is rejected during validation rather than at the moment a task
would have written somewhere unexpected. So is a path that resolves out of the
repo. When Lattice discovers a workspace whose directory is a symlink to
somewhere else on the disk, it refuses that workspace, because the directory a
path resolves to is the one that bounds the task. A symlink that stays inside the
repo is still a workspace.
Error: workspace path 'app' resolves to /elsewhere/app, which is outside the repo root. A workspace directory has to be inside the repo
Two workspaces also cannot share a name or resolve to the same directory, because a name is a cache identity and a directory is a blast radius, and sharing either would mean two tasks quietly overwriting each other’s results.
The one thing that does not respect the boundary is a file above it. A base
tsconfig.json at the repo root is read by tasks in several workspaces and can
be named by no workspace-relative pattern. globalDependencies covers that case
at the repo level. See Caching.
An auto workspace still has to show evidence
auto defaults to true, which means Lattice works out which tool runs the
workspace’s tasks by looking at what is in the directory. Find pnpm-lock.yaml
and pnpm run build is the build command. Find Cargo.lock and it is
cargo build. You write no commands for either.
What auto does not mean is that Lattice will produce an answer no matter what
it finds. An empty directory stops the run before any task starts:
Error: workspace 'empty' has an ambiguous or undeclared driver.
Lattice detected no driver. The directory holds no lockfile, no wrapper, and no native declaration.
Declare the driver in lattice.json, under this workspace:
"auto": false, "scripts": { "build": "<command>" }
A directory holding nothing but a bare package.json stops it too, and says
what it saw:
Error: workspace 'bare' has an ambiguous or undeclared driver.
Candidate drivers: pnpm, npm, yarn, bun
Declare the driver in lattice.json, under this workspace:
"engines": { "pnpm": ">=0.0.0" }
This is the decision worth understanding about Lattice, because it is the one
that will interrupt you. A package.json proves the workspace is JavaScript. It
does not say whether build means pnpm run build, yarn build, or
npm run build, and those three are not interchangeable: they resolve different
dependency trees and read different lockfiles. Lattice could pick one. Picking
one means every repo that never chose a package manager silently gets whichever
Lattice’s authors preferred, and the day it guesses wrong the failure looks like
a bug in your build rather than a decision in ours.
So a bare ecosystem marker is deliberately not enough. Lattice needs a file that
only one tool produces, or a file a developer wrote on purpose to name one:
pnpm-lock.yaml, bun.lockb, Cargo.lock, a packageManager field, a
.tool-versions, a checked-in gradlew. Any of those settles it. Nothing does
not. Driver detection has the full ladder and what
happens when two tools both have a claim.
An auto workspace that resolves a driver is allowed to have no command for a
given task. Lattice skips it rather than failing, so a test task can exist in
the repo and cover only the workspaces that have tests.
A persistent: true task is the one case where a driver that would otherwise
infer a command declines to. cargo, go, gradle and the rest take the task
name as a subcommand, and there is no cargo dev, so the task is skipped there
unless the workspace declares a scripts entry for it. Task runners are
exempt, since they run the tasks the repo declared to them. See Persistent
tasks.
auto: false is the way out
Set auto: false and nothing is detected and nothing is inferred. You declare
scripts for every task the workspace should run:
{
"name": "utils",
"path": "libs/utils",
"auto": false,
"scripts": {
"build": "python3 build.py",
"test": "python3 -m pytest"
}
}
The trade you are making is precision for maintenance. An auto: false
workspace cannot be wrong about its driver, because it has none, and it cannot
be surprised by a lockfile someone adds later. It also cannot pick up a new task
for free: ask for a task it has no scripts entry for and the run fails rather
than skipping it, which is the correct behavior for a workspace that told you it
would declare everything.
Use it for a workspace whose build is a script rather than a tool, for a workspace whose tool Lattice has never heard of, and for the case where detection is ambiguous and you would rather write two lines than reason about evidence rungs.
scripts also works without auto: false. An entry there always beats what
detection would infer, so an otherwise-auto workspace can name the one task no
driver could ever produce:
{
"name": "api",
"path": "services/api",
"scripts": { "deploy": "./deploy.sh" }
}
build, test, and lint still come from the detected driver. Only deploy is
declared.
Per-workspace engines exist so one workspace can be different
A workspace’s engines map merges over the root one, key by key. That is there
for the repo where one workspace genuinely needs a different tool version than
the rest, and where the honest fix is to say so in one place rather than raise
the floor for everything:
{
"engines": { "node": ">=20.0.0" },
"workspaces": [
{ "name": "web", "path": "apps/web", "engines": { "node": ">=26" } }
]
}
Every workspace but web is checked against Node 20 or newer. web needs 26.
Whether a constraint checks the host tool, installs one, or does neither depends
on the constraint’s shape rather than its name, which is
Engines and provisioning.
Workspace dependsOn does nothing by itself
This is the field people expect to order their build, and on its own it orders nothing.
{
"workspaces": [
{ "name": "core", "path": "libs/core" },
{ "name": "api", "path": "services/api", "dependsOn": ["core"] }
],
"tasks": {
"build": { "dependsOn": ["^build"] }
}
}
A workspace’s dependsOn declares a fact about the repo: api uses core. It
does not declare a schedule. The schedule comes from the task, and only when the
task asks for it. Here build depends on ^build, and the ^ means “in each
workspace this one depends on”, so it reads api’s dependsOn and orders
core:build before api:build. Delete the ^build and the two build in
whatever order the scheduler picks.
The separation is deliberate. Not every task should follow the dependency graph.
lint usually should not, because linting core has nothing to do with linting
api, and making every task inherit the workspace graph would serialize work
that has no reason to be serial. Declaring the relationship once and opting each
task into it is what lets build be ordered and lint be parallel in the same
repo. Task graph covers both tokens.
A dependsOn name that matches no declared workspace is an error rather than a
no-op, because an unresolvable name builds no edge, and a missing edge is
invisible: the build still runs, in the wrong order, until it fails somewhere
unrelated.
Where to look next
Types, defaults, and validation rules for every field on the workspace object are in Configuration. The full list of what counts as driver evidence is in Driver detection. Every error message this page mentions is reproduced with its cause in Errors.