Workspaces
A workspace is one project directory: a single unit of task running and
caching. A JavaScript app, a Rust crate, a Python package, a Go service — each
one is a workspace. lattice.json declares the full list under workspaces,
and every root task is expanded across all of them into one execution graph
(see Task graph).
{
"workspaces": [
{ "name": "api", "path": "services/api" },
{ "name": "web", "path": "apps/web" }
]
}
name and path are the only required fields. Everything else — auto,
engines, dependsOn, scripts — has a default and is covered below.
Finding the root
Every Lattice command looks for lattice.json by walking up from the current
directory, so you can run lattice from any directory inside the repo, not
just the root. Once the root is found, its lattice.json is parsed and
validated — duplicate workspace names and empty paths are rejected before
anything else runs. Every workspace path is then resolved relative to that
root, never to the directory you happened to run lattice from.
path is literal — never a glob
A workspace’s path is a single directory, checked for existence as-is. It is
not a pattern, and Lattice does not expand it. If you write a wildcard, it is
treated as a literal (and almost certainly nonexistent) directory name:
{ "name": "packages", "path": "packages/*" }
workspace path 'packages/*' does not point to a directory; workspace paths
are literal directories, not globs
That is the exact failure Lattice raises for any path that isn’t a real
directory. Two workspaces also can’t share a name or resolve to the same
directory — both are duplicate errors, not a merge. List one entry per
project directory instead.
auto: inferred vs. explicit
auto defaults to true. An auto workspace has its task driver — the tool
that actually runs its tasks — resolved from evidence in the directory: a
lockfile, a packageManager field, a Cargo.lock, and so on. That resolution
is driver detection; read that page for the full
evidence ladder. Once a driver is resolved, Lattice infers a command for every
root task the driver or its manifest supports (pnpm run build, cargo test,
…) — you don’t write those commands yourself.
Set auto: false to opt a workspace out of all of it:
{
"name": "utils",
"path": "libs/utils",
"auto": false,
"scripts": {
"build": "python3 build.py",
"test": "python3 -m pytest",
"lint": "python3 -m py_compile src/*.py",
"clean": "rm -rf dist"
}
}
With auto: false, nothing is detected and nothing is inferred: you declare
scripts for every root task this workspace should run, and engines for
any toolchain constraint it needs. If a root task is requested and this
workspace has no matching entry in scripts, the run fails rather than
silently skipping it:
workspace 'utils' is "auto": false but declares no command for task 'build';
add it under this workspace's "scripts" map in lattice.json
An auto workspace, by contrast, simply sits out a task it has no driver support for — it isn’t required to cover every task in the pipeline.
engines: per-workspace toolchain constraints
A workspace’s engines map merges with the root engines, and the
workspace’s entries win per key — a workspace can tighten or replace a
root-level constraint without touching the rest of the repo:
{
"engines": { "node": ">=20.0.0" },
"workspaces": [
{ "name": "web", "path": "apps/web", "engines": { "node": ">=26" } }
]
}
Here every other workspace validates against Node ≥20, but web requires
≥26. What an engine constraint’s shape does — trust PATH, validate a
version, or provision a pinned install — is the Engines and
provisioning page.
dependsOn: ordering workspaces
A workspace’s dependsOn names other workspaces (by name) it depends on:
{
"workspaces": [
{ "name": "core", "path": "libs/core" },
{ "name": "api", "path": "services/api", "dependsOn": ["core"] }
],
"tasks": {
"build": { "dependsOn": ["^build"] }
}
}
On its own, dependsOn declares nothing about ordering — it only takes effect
where a task’s own dependsOn uses a ^-prefixed name. Here, api’s build
depends on ^build, which resolves through api’s dependsOn: ["core"] to
core’s build: core builds before api. Without a task naming ^build
(or whichever task), declaring dependsOn on the workspace has no scheduling
effect at all. The full distinction between a bare task name and a
^-prefixed one is the Task graph page.
scripts: your commands, always
scripts maps a task name straight to a shell command, and an entry there
always wins over anything an auto workspace would otherwise infer:
{
"name": "api",
"path": "services/api",
"dependsOn": ["core"],
"scripts": {
"deploy": "./deploy.sh"
}
}
api here is still auto: true (the default): build, test, and lint
are still inferred from its detected driver, but deploy — which no driver
could invent — is exactly the command declared. You only need to name the
tasks you want to override or supply; everything else falls back to
detection. For a workspace with auto: false, scripts is the only source
of commands there is.
The exhaustive field-by-field reference — types, defaults, and every other
lattice.json key — is Configuration.