A multi-language monorepo
examples/polyglot in the Lattice repo is a small, real monorepo: a
JavaScript app, a Rust binary, a Python library, and a Go service, run and
cached from one root lattice.json. This page walks it end to end — what
each workspace is, what Lattice detects for it and why, how lattice run build orders the four of them, and what a second run looks like once the
cache is warm.
The repo
examples/polyglot/
lattice.json
apps/
web/ package.json, package-lock.json — an Express app
api/ Cargo.toml, Cargo.lock — a Rust binary
libs/
utils/ pyproject.toml, uv.lock — a Python library
services/
worker/ go.mod — a Go binary
Four workspaces, four languages, none of them named in the config as anything other than a directory:
{
"$schema": ".lattice/schema.json",
"latticeVersion": "1.0.0-beta-2",
"workspaces": [
{ "name": "web", "path": "apps/web" },
{ "name": "api", "path": "apps/api" },
{
"name": "utils",
"path": "libs/utils",
"auto": false,
"scripts": {
"build": "python3 -c \"import os; os.makedirs('dist', exist_ok=True); import shutil; shutil.copy('src/utils.py', 'dist/utils.py'); print('utils build complete')\"",
"test": "python3 src/utils.py",
"lint": "python3 -m py_compile src/utils.py && echo 'Syntax OK'",
"clean": "python3 -c \"import shutil; shutil.rmtree('dist', ignore_errors=True); print('utils clean complete')\""
}
},
{
"name": "worker",
"path": "services/worker",
"auto": false,
"scripts": {
"build": "sh -c 'if command -v go >/dev/null; then go build ./...; else echo \"[worker] go not installed, skipping native build\"; fi'",
"test": "sh -c 'if command -v go >/dev/null; then go test ./...; else echo \"[worker] go not installed, skipping tests\"; fi'",
"lint": "sh -c 'if command -v go >/dev/null; then go vet ./...; else echo \"[worker] go not installed, skipping lint\"; fi'",
"clean": "sh -c 'rm -rf bin && echo worker clean complete'"
}
}
],
"engines": {
"node": ">=20.0.0",
"rust": ">=1.75.0"
},
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**/*"],
"ignore": ["**/*.test.*", "**/README.md"],
"outputs": ["dist/**", "target/debug/**", "bin/**"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**/*", "tests/**/*"]
},
"lint": {
"inputs": ["src/**/*"]
},
"clean": {}
},
"settings": {
"maxCacheSize": "1GB"
}
}
This is the file as shipped, unedited. Every command below runs against a copy of this exact directory.
What Lattice detects, workspace by workspace
web and api use the default auto: true and declare no scripts at
all — Lattice has to find their driver on its own. utils and worker set
auto: false and declare every command by hand. That split is not
arbitrary; each half exists for a different reason, and the evidence ladder
(see Driver detection) explains why.
web: a lockfile picks the driver
apps/web has a package.json and a package-lock.json. A bare
package.json only says “JavaScript” — Lattice’s ladder needs rung 3, a
tool-unique lockfile, to go further. package-lock.json is npm’s, and
nothing else’s, so npm is the driver, with the package-manager role. Every
task command comes from the scripts block in apps/web/package.json:
npm run build, npm run test, npm run lint.
api: same ladder, a different lockfile
apps/api has a Cargo.toml and a Cargo.lock. Cargo.lock is cargo’s
fingerprint (rung 3 again), so the driver is cargo, with
the build-tool role. Its invoke form composes the task name directly onto the
tool: cargo build, cargo test. (cargo lint is not a real cargo
subcommand without an alias configured for it — the invoke form is generic,
not verified against what the tool actually supports, so lint here would
fail if it ran for real. build and test are the ones this walkthrough
actually runs.)
utils: auto: false even though a lockfile exists
libs/utils has a uv.lock — if this workspace were auto: true, the
ladder would resolve uv as the driver (also the package-manager role) via that
lockfile, same as web and api. It isn’t: utils sets auto: false and
supplies scripts for all four root tasks itself, calling python3
directly instead of uv run ….
Because every task has an explicit script, auto: false changes nothing
observable today — the explicit command always wins over an inferred one,
whether or not the workspace is auto. What it buys is a safeguard for
tomorrow: if a task got added to tasks in lattice.json without a
matching entry in utils’s scripts, an auto: true workspace would
silently try to invent a uv run <task> command; auto: false makes that
same gap a hard failure instead — the workspace declares no command for
that task, and the run halts. Also worth knowing: pyproject.toml’s own
requires-python = ">=3.11" is metadata for Python tooling, not something
Lattice reads — the only Python version Lattice would ever check is one
declared under engines, and this config declares none.
worker: auto: false because auto would halt
services/worker has a go.mod with no toolchain line and no go.sum
(there are no external dependencies to lock). go.sum is the only
fingerprint the driver registry recognizes for Go — without it, auto: true
finds no candidate at all. Flipping worker to auto: true in a scratch
copy of this example and running lattice run build --dry-run confirms it:
Error: workspace 'worker' has an ambiguous or undeclared task driver.
No task driver could be detected (no lockfile, wrapper, or native declaration).
Declare the task driver explicitly by adding to this workspace in lattice.json:
"engines": { "node": ">=0.0.0" }
So here auto: false is load-bearing, not defensive: without it (or an
explicit engines declaration), this workspace can’t run at all. Its
scripts shell out to go directly and check command -v go first, so the
example still runs — printing a skip notice instead of failing — on a
machine without Go installed.
The root engines, and who they apply to
node and rust are declared once, at the root, with no per-workspace
override. Every workspace’s resolved engine map is the same merged map (root
engines, since none of the four workspaces adds its own), so Lattice
resolves it exactly once for the whole run and reuses the result for all
four — not once per workspace (see Engines and
provisioning). Both constraints are version-only, no
installCmd, so this is validate-only mode: Lattice runs node --version
and rustc --version on the host and fails before any task starts if either
doesn’t satisfy its constraint. Nothing is installed. This validation runs
for the whole graph regardless of which workspace actually uses Node or
Rust — utils and worker are checked against the same node/rust
constraints as web and api, even though neither runs a line of
JavaScript or Rust.
Running build
lattice run build --dry-run shows the graph without running anything:
❖ lattice dry run · build
→ worker:build sh -c 'if command -v go >/dev/null; then go build ./...; else echo "[worker] go not installed, skipping native build"; fi'
→ utils:build python3 -c "import os; os.makedirs('dist', exist_ok=True); import shutil; shutil.copy('src/utils.py', 'dist/utils.py'); print('utils build complete')"
→ api:build cargo build
→ web:build npm run build
Four nodes, no edges between them. build’s task-level dependsOn: ["^build"]
means “build every workspace this one depends on, first” — but none of the
four workspaces here declares a dependsOn of its own, so ^build has
nothing to expand into for any of them. All four are graph roots. Running it
for real (-l for plain line-by-line output instead of the interactive TUI)
shows exactly that: every build starts at once and finishes independently,
bounded only by the concurrency limit (default: logical CPUs — see
Selecting what runs):
lattice: running `build` across 4 workspace(s)
lattice: worker:build: hash ec52d859e992ff10
lattice: worker:build: cache miss
worker:build: running
lattice: web:build: hash 839dbe990f662989
lattice: utils:build: hash 2328e123c2be5737
lattice: utils:build: cache miss
utils:build: running
lattice: web:build: cache miss
web:build: running
lattice: api:build: hash ca9cf431598d41a5
lattice: api:build: cache miss
api:build: running
worker:build: [worker] go not installed, skipping native build
worker:build: done (0.01s)
utils:build: utils build complete
utils:build: done (0.08s)
web:build: Building web app...
web:build: done (0.26s)
api:build: Compiling api v0.1.0 (…/apps/api)
api:build: Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.67s
api:build: done (0.71s)
lattice: 4 tasks, 0 cached, 0 failed, 1.05s
That output is real — captured from cargo build -p lattice’s binary run
against a copy of this example, on a machine with Node and Rust installed
and Go absent, which is exactly what the worker script’s own
command -v go check is there to handle. Every task shows a cache miss
first: nothing has ever built here before, so there’s nothing to restore
(see Caching for what feeds that hash).
Running it again: the cache
Run lattice run build -l a second time with nothing changed:
lattice: running `build` across 4 workspace(s)
lattice: worker:build: hash ec52d859e992ff10
lattice: web:build: hash 839dbe990f662989
lattice: api:build: hash ca9cf431598d41a5
lattice: utils:build: hash 2328e123c2be5737
worker:build: cache hit [ec52d859]
web:build: cache hit [839dbe99]
utils:build: cache hit [2328e123]
api:build: cache hit [ca9cf431]
lattice: 4 tasks, 4 cached, 0 failed, 0.07s
Same four hashes as the first run — build’s inputs is src/**/*, and no
source file changed, so every workspace’s key is identical and every lookup
is a hit. 1.05s of real compiling and copying became 0.07s of restoring from
.lattice/cache. Edit one workspace’s source and only that workspace’s key
changes: appending a line to apps/web/src/index.js and re-running build
reruns web:build alone —
lattice: web:build: cache miss
web:build: running
worker:build: cache hit [ec52d859]
utils:build: cache hit [2328e123]
api:build: cache hit [ca9cf431]
web:build: Building web app...
web:build: done (0.22s)
lattice: 4 tasks, 3 cached, 0 failed, 0.28s
— while worker, utils, and api come back as hits, because build’s
inputs (src/**/*) never matched anything under apps/web for them in
the first place.
The cross-language dependency edge
As shipped, examples/polyglot’s four workspaces build independently — the
previous section’s dry run has no edges to show. But the mechanism for a
real cross-language dependency is exactly the same file, one field: a
workspace’s dependsOn. Say worker (Go) actually needed utils (Python)
built first — for instance, its build step shells out to something utils
produces. You’d declare that on the worker workspace, and only there:
{
"name": "worker",
"path": "services/worker",
"auto": false,
"dependsOn": ["utils"],
"scripts": {
"build": "sh -c 'if command -v go >/dev/null; then go build ./...; else echo \"[worker] go not installed, skipping native build\"; fi'",
"test": "sh -c 'if command -v go >/dev/null; then go test ./...; else echo \"[worker] go not installed, skipping tests\"; fi'",
"lint": "sh -c 'if command -v go >/dev/null; then go vet ./...; else echo \"[worker] go not installed, skipping lint\"; fi'",
"clean": "sh -c 'rm -rf bin && echo worker clean complete'"
}
}
Nothing else in the file changes: build’s task-level dependsOn: ["^build"]
already means “build every workspace I depend on first,” for every
workspace that has that dependency. Adding dependsOn: ["utils"] to
worker is what gives ^build something to resolve to for worker
specifically. lattice run build --dry-run against that one-line change
shows the new edge immediately:
❖ lattice dry run · build
→ utils:build python3 -c "import os; os.makedirs('dist', exist_ok=True); import shutil; shutil.copy('src/utils.py', 'dist/utils.py'); print('utils build complete')"
→ worker:build sh -c 'if command -v go >/dev/null; then go build ./...; else echo "[worker] go not installed, skipping native build"; fi'
→ api:build cargo build
→ web:build npm run build
utils:build now precedes worker:build; api and web are still
independent roots. Running it for real shows the same ordering play out:
api:build and web:build start immediately, utils:build starts
immediately too, and worker:build’s hash and start only appear once
utils:build finishes —
lattice: utils:build: hash 2328e123c2be5737
lattice: utils:build: cache miss
utils:build: running
lattice: api:build: hash ca9cf431598d41a5
lattice: web:build: hash ea603568d0b9ef0e
lattice: web:build: cache miss
web:build: running
lattice: api:build: cache miss
api:build: running
api:build: Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
utils:build: utils build complete
utils:build: done (0.03s)
lattice: worker:build: hash ec52d859e992ff10
lattice: worker:build: cache miss
worker:build: running
worker:build: [worker] go not installed, skipping native build
worker:build: done (0.01s)
— confirmed by adding that one field to a scratch copy of this example and
running both commands against it. Lattice does not care that the dependency
crosses a language boundary: the edge between worker:build and
utils:build is built the same way it would be if both workspaces were
Go, or both were Python — from dependsOn naming a workspace, not a
language or a driver. The full mechanics of dependsOn, the task vs.
^task distinction, and how several requested tasks merge into one graph
are covered in Task graph.