Adopting Lattice
You already have a repo that builds, tests, and ships. Nothing about adding
Lattice requires touching any of that first. The order that works is: get
lattice run calling the exact commands you already run, one workspace at a
time; only then teach it what to cache; pin toolchains last, and only where
you actually need the guarantee. Skipping ahead — declaring inputs before
the command is right, or pinning an engine before you have a second
workspace to share it with — is what makes an adoption feel like a rewrite.
Start with the workspaces you have, not the tasks you want
A workspace is a directory and a name — nothing else is required to start.
Add one to lattice.json, and one task with no configuration at all:
{
"$schema": ".lattice/schema.json",
"workspaces": [
{ "name": "web", "path": "apps/web" }
],
"tasks": {
"build": {}
}
}
path is a literal directory, never a glob. With auto left at its default
of true, Lattice reads the workspace’s own lockfile or manifest to work out
which tool runs build — for a directory with a package.json and a
package-lock.json, that resolves to npm run build, the same command you’d
already type by hand. You do not need to enumerate every workspace before
this is useful; a lattice.json with one workspace and one task is a
complete, valid file.
Confirm the command before you trust it
Every task command Lattice resolves is inspectable before anything runs:
lattice run build --dry-run
Run against this repo’s own root lattice.json, that prints:
❖ lattice dry run · build
→ web:build npm run build
→ lattice-output:build cargo build
→ lattice-config:build cargo build
→ lattice-cache:build cargo build
→ lattice-workspace:build cargo build
→ dagger:build cargo build
→ lattice-runner:build cargo build
→ lattice:build cargo build
Each line is workspace:task and the exact shell command that workspace
would run — nothing executes. Read it against what you already run in that
directory. If it matches, lattice run build (without --dry-run) does the
same thing you were already doing, just through Lattice. If it doesn’t match,
you have a driver problem to sort out (see below) before you add anything
else.
Bring workspaces in one at a time
You don’t have to convert a repo in one pass. Add a second workspace entry
when you’re ready for it, and use -f/--filter to scope a run to just the
one you’re currently migrating, without disturbing anyone still using the
old command directly in every other directory:
lattice run build --filter web
--filter matches workspaces whose name contains the given pattern; run
against no match, it prints that nothing matched and exits cleanly rather
than erroring. Nothing about adding a workspace to lattice.json requires
removing its existing package.json scripts, Makefile, or CI step — a
workspace’s own scripts keep working exactly as before, called directly or
through Lattice, until you’re ready to point CI at lattice run for that
workspace too. Drop the filter once you trust the whole set.
When detection halts on a workspace
Not every directory has one unambiguous answer. A repo mid-migration between
package managers, with both a stale package-lock.json and a live
pnpm-lock.yaml still checked in, is a real and common case:
Error: workspace 'pkg' has an ambiguous or undeclared task driver.
Candidate tools seen: npm, pnpm
Declare the task driver explicitly by adding to this workspace in lattice.json:
"engines": { "npm": ">=0.0.0" }
This is auto refusing to guess rather than picking one silently — the same
happens if a workspace has only a bare package.json with no lockfile at
all, since a generic ecosystem marker isn’t a strong enough signal on its
own. You have two ways out, and the choice is which one matches reality:
- Declare
enginesnaming the tool. A declaration always wins over any lockfile, so"engines": { "pnpm": ">=8.0.0" }on that workspace settles it for good — use this when the ambiguity is genuinely just noise (a lockfile you meant to delete) and the winning tool should drive every task the usual way. - Set
"auto": falseand writescripts. Use this when there isn’t really one right answer to infer — the workspace’s real build step is a wrapper script, or you’d rather state the command than have Lattice guess at it correctly.
See Driver detection for the full evidence ladder this halt comes from, and what “candidate” and “role” mean there.
When to reach for scripts instead of fighting detection
Auto-detection assumes a plain invocation of the tool it finds — npm run build, cargo test, pnpm run lint. Plenty of real workspaces don’t work
that way, and declaring scripts there isn’t a fallback, it’s the correct
answer:
- The real command is a multi-step or wrapped script, not a bare tool
invocation (a
sh -c '...'chain, a custom flag set you always pass). - The workspace is itself a monorepo with its own task runner underneath — see Nested repos for that shape end to end.
- The workspace’s tool isn’t one of the built-in drivers at all.
scripts entries always take precedence over an inferred command, for any
task, whether or not auto is true — so you can override just the one task
that needs it and leave every other task in that workspace on auto-detection.
A workspace can also set "auto": false and declare scripts for every task
it runs, opting out of inference entirely; that workspace still composes into
the rest of the graph exactly like an auto-detected one.
Add inputs and outputs before you trust the cache
A task is cached by default — nothing you’ve done so far turns that off. But
until a task declares inputs, its cache key does not include your source
files at all: only its command, its resolved environment, the toolchain, and
any lockfile present. That means a task with no inputs is a cache hit on
every rerun with an unchanged command, including a rerun right after you
edited the source it builds from. That isn’t a miss that should have been a
hit; it’s a stale result served with confidence.
This is why inputs is the next thing to add, once --dry-run confirms the
command, not before:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**/*", "public/**/*", "Cargo.toml", "package.json"],
"outputs": ["dist/**"]
}
}
}
inputs globs are resolved per workspace and their contents are hashed, so
an edit anywhere they match invalidates that workspace’s key. outputs is
separate and equally worth declaring deliberately: it’s what actually gets
archived on a miss and restored on a hit. A task like test or lint that
produces no artifact worth reusing can skip outputs entirely and still
cache correctly — only build-shaped tasks need it. If you’re not ready to
reason about either yet, "cache": false on that one task keeps it in the
graph without caching it at all, which is a safer default than an
undeclared, silently-cached task.
See Caching for the rest of what the key is built from, what counts as a hit, and how eviction works.
Pin toolchains once the graph is real
Nothing above required declaring an engine anywhere — the driver Lattice
detected is whatever tool is already on PATH. Reach for engines only once
you need more than that: a version constraint enforced across every machine
and CI runner that touches the workspace, or a tool provisioned into
.lattice/toolchains because it isn’t something you want installed
globally. A constraint can live at the root, where it applies to every
workspace, or on one workspace, where it overrides the root per key — you
don’t need one per workspace just because you have several.
A mid-size example: this repo’s own lattice.json
This project’s own root lattice.json is what following this order actually
produces — eight workspaces (seven Rust crates plus the web app), each
declared with a literal path:
{
"$schema": ".lattice/schema.json",
"latticeVersion": "1.0.0-beta-2",
"workspaces": [
{ "name": "lattice-config", "path": "crates/lattice-config" },
{ "name": "lattice-output", "path": "crates/lattice-output" },
{
"name": "lattice-workspace",
"path": "crates/lattice-workspace",
"dependsOn": ["lattice-config"]
},
{
"name": "lattice-cache",
"path": "crates/lattice-cache",
"dependsOn": ["lattice-config"]
},
{
"name": "dagger",
"path": "crates/dagger",
"dependsOn": ["lattice-config", "lattice-workspace"]
},
{
"name": "lattice-runner",
"path": "crates/lattice-runner",
"dependsOn": [
"dagger",
"lattice-cache",
"lattice-output",
"lattice-config",
"lattice-workspace"
]
},
{
"name": "lattice",
"path": "crates/lattice",
"dependsOn": [
"lattice-runner",
"dagger",
"lattice-cache",
"lattice-output",
"lattice-config",
"lattice-workspace"
]
},
{ "name": "web", "path": "apps/web", "engines": { "node": ">=26" } }
],
"engines": { "cargo": ">=1.86.0" },
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": [
"src/**/*",
"public/**/*",
"Cargo.toml",
"package.json",
"package-lock.json",
"astro.config.mjs",
"tsconfig.json"
],
"outputs": ["dist/**"]
},
"dev": { "persistent": true, "cache": false },
"test": { "dependsOn": ["build"], "inputs": ["src/**/*", "tests/**/*"] },
"lint": { "inputs": ["src/**/*"] },
"check": { "inputs": ["src/**/*", "Cargo.toml"] },
"clean": {}
}
}
Every dependsOn here mirrors a real crate dependency in Cargo.toml —
nothing was invented for the graph. web is the only workspace that needed
its own engines entry, because it’s the one workspace whose toolchain
(node) the root cargo constraint says nothing about; every crate is
covered by that single root line. tasks are the same five or six names
each workspace already had scripts or cargo subcommands for — test and
lint simply don’t apply to web, which has neither, and no node is
created for them there. clean is the one task left with no inputs or
outputs at all: it’s still cached the same as anything else (by command,
env, toolchain, and lockfile alone), so a second lattice run clean with
nothing else changed comes back as a hit and skips re-running it. That’s the
same gap called out above — harmless here only because a skipped delete
leaves nothing stale behind for a task to hand back.
Where to go next
- Workspaces — the full model behind
path,auto,dependsOn, andscripts. - Caching — exactly what’s hashed, what counts as a hit, and how pruning works.
- Driver detection — the evidence ladder, roles, and the composition/conflict rule behind every ambiguity halt.
- Nested repos — wrapping a workspace that already has its own task runner underneath it.
- Troubleshooting — symptom-to-fix for the failure modes this guide only summarizes.