Engine & workers
A Zuke build is typed code in the language of your project. What runs it is always the same thing: the Zuke engine. Your build file is a worker — it tells the engine what the targets are and runs a target's body when the engine asks. Everything else happens in the engine, once, for every language. TypeScript builds run on it, Python is the first language SDK, and more language SDKs are coming on the same contract.
Two processes, one build
When you run ./zuke test in a Python project, two processes
start. The launcher starts the engine. The engine starts your
build.py and the two talk over a private connection on your
machine for the rest of the run.
./zuke test the launcher @zuke/engine plans the graph, runs every command, keeps the state build.py describes the targets, runs their bodies The split is deliberate. The hard parts of a build system — ordering, retries, timeouts, cancellation, caching, locks, resumable runs, service lifecycles, CI generation, the MCP server, the output — are written once, in the engine. A language SDK only has to do two small things: describe the build and run a body. That is what makes a new language cheap to add, and what guarantees a Python build and a TypeScript build behave identically.
Who does what
| The engine | The worker (your build file) |
|---|---|
| Parses the command line, resolves parameters, and plans the graph in topological order. | Hands the engine a manifest: the targets, their dependencies, retries, timeouts, inputs and outputs, the parameters, the groups, the services, and the CI files it declares. |
| Runs every subprocess: argv only, no shell, output captured and redacted, dry-run, timeout and cancel handled in one place. | Runs a target's body when told to, and asks the engine to run each command on its behalf. |
| Owns the cache, the durable run records, locks, resume, and service start, readiness and stop. | Answers the small questions only the build can: does this target's condition hold, what is its extra cache key, is this service ready. |
| Loads and runs every typed tool wrapper — Docker, npm, Deno, Git, the cloud CLIs — on the worker's behalf. | Calls a wrapper through a generated, typed facade in its own language; the engine runs the real thing. |
| Renders the build log and summary, generates CI, serves the MCP server. | Logs lines; even a stray print arrives in the log rather than on the wire. |
A run, step by step
- Start.
./zuke testreadszuke.json, finds the engine version and the worker command, and starts the engine. - Hello. The engine opens a private connection on your
machine and starts the worker with its address and a one-time token. The
worker connects, says which SDK it is, and sends its environment and
working directory — so a
uv runvirtualenv is what every later command sees. - Describe. The engine asks for the manifest. From here on
the targets, the
--listoutput, the--help, the graph and the generated CI are all the engine's, with the build's data in them. - Configure. The engine resolves parameters from flags,
environment and defaults and tells the worker the values, so
tag.valueis set before any body runs. - Run. For each target in order the engine checks the cache,
takes the lock, asks the condition, and tells the worker to run the body.
While the body runs, each
ctx.sh(…)or tool call is a request back to the engine, which spawns it and answers with the result. Retries, timeouts and cancellation apply to the body exactly as to a TypeScript target. - Finish. The engine records the run, prints the summary, tells the worker the run is over, and waits for it to exit. If the engine itself dies, the worker's connection ends and it exits on its own; the engine never leaves a build file running.
Commands & tool wrappers
A body never spawns a process itself. ctx.sh("pytest", "-q")
sends the argv to the engine, which runs it the way the $ shell
does in TypeScript: as discrete arguments, never a shell string, so there is
nothing to inject into; output is streamed to the log and captured; secrets
are redacted; --dry-run prints instead of running; a timeout
or a cancel kills the process. One implementation, every language.
The typed tool wrappers work the same way. The engine publishes a
machine-readable spec of every @zuke/* wrapper — its tasks,
their settings, every setter — and an SDK generates its facades from it.
DockerTasks.build(ctx, lambda s: s.tag("app:dev")) in Python
sends the engine the wrapper's name and release, the task, and the setters
the lambda called. The engine loads that exact wrapper release and runs it
with the worker's environment and directory. So the whole catalogue of
wrappers is available in every language from day one, at the same version
pinned for everyone.
A call the engine cannot satisfy is refused by name — the wrapper, the task, or the setter it could not find — rather than failing somewhere inside. A task that throws fails the call with its message, and a cancel cuts it short.
TypeScript builds
A zuke.ts build is the same model with one process fewer.
@zuke/core is the engine, as a library: a TypeScript
build imports it, and run(MyBuild) hosts the build in-process
with no socket in between. The @zuke/engine package is that
same core with a worker host in front of it, for a build written in another
language. Every feature on this site — caching, locks, resume, services,
canary releases, the MCP server, AI review — is core, so it reaches both
kinds of build the day it ships.
A Python build
Python is the first SDK. A build is a class whose fields are targets;
dependencies are references the type checker sees; each body gets a
ctx that runs commands through the engine; and every tool
wrapper has a typed facade under zuke.tools.
from zuke import Build, parameter, run, target
from zuke.tools.docker import DockerTasks
class CI(Build):
tag = parameter("image tag").default("dev")
lint = target().description("ruff").executes(lambda ctx: ctx.sh("ruff", "check", "."))
test = target().depends_on(lint).executes(lambda ctx: ctx.sh("pytest", "-q"))
image = (
target()
.depends_on(test)
.executes(
lambda ctx: DockerTasks.build(
ctx, lambda s: s.tag(f"app:{tag.value}").file("Dockerfile")
)
)
)
run(CI)
Beside it, zuke.json names the worker command and the exact
engine release that runs it:
{
"name": "App",
"sdk": { "command": ["uv", "run", "build.py"] },
"engine": "1.2.0"
} Then the familiar surface, served by the engine:
./zuke --list # the targets, as build.py describes them
./zuke test # lint, then test — every command spawned by the engine
./zuke generate-ci # the CI files the build declares
./zuke mcp # the build as typed tools for an agent Get started. The SDK is
zuke on PyPI,
built at zuke-build/zuke-python,
and its worker passes every conformance case of the engine it pins.
zuke setup --sdk python scaffolds a project; the
Python quickstart walks through it, and the
Python API reference documents every class and
facade. Python is the first SDK, not the last: more language SDKs are
coming, each one a thin worker over the same engine.
zuke.json & the launcher
A project with a zuke.ts runs it directly. A project with no
zuke.ts holds a build another SDK hosts, and its
zuke.json says how: sdk.command is the worker's
argv, and engine is the exact @zuke/engine release
to run it with — "1.2.0", never a range, so the engine cannot
float between two runs. The ./zuke launcher (and the global
zuke command, which forwards into the project) then runs:
deno run -A jsr:@zuke/engine@1.2.0 test
The launcher bootstraps Deno itself, so a CI runner needs nothing
pre-installed, and it adds --frozen when a deno.lock
sits beside zuke.json. It refuses, naming the field, when
engine is missing or is not an exact version. Upgrading the
engine is editing one number and committing it.
Writing an SDK
The engine is built to host a build in any language, and the contract an SDK has to meet is published as data, not prose:
- Conformance cases. Under
packages/engine/conformanceeach case is one manifest, one command line, and the exact calls the engine makes in order — retries, conditions, cache keys, locks, services, hooks, suspend and resume, a worker that crashes mid-body, an engine that dies. The engine replays them against a scripted worker in its own test suite, and an SDK replays them against its real worker. Passing every case is what "speaks the protocol" means. - The tool spec.
packages/engine/spec/tools.jsondescribes every wrapper's tasks and settings, so an SDK generates its typed facades instead of writing them by hand — and regenerates them when it vendors a newer spec. - Versioning. A protocol change lands in the engine first. An SDK pins the engine release it was tested against and moves forward deliberately.
Under the hood
For the curious, the whole transport fits in a paragraph. The engine listens on loopback TCP and accepts exactly one connection. It starts the worker with three environment variables — the address, a per-run token, and the protocol version — and the two exchange newline-delimited JSON-RPC 2.0 messages. The worker's own stdout and stderr are piped to the engine as log lines, so the socket is the only channel that carries protocol. The worker ignores Ctrl-C; the engine drives shutdown, so a cancelled run is one coordinated stop rather than two processes racing to die.
A trimmed transcript of the Python build above:
→ {"jsonrpc":"2.0","method":"hello","params":{"sdk":"zuke-python","protocol":1,"env":{…},"cwd":"/app"}}
← {"jsonrpc":"2.0","id":1,"method":"build.describe"}
→ {"jsonrpc":"2.0","id":1,"result":{"name":"CI","targets":[…],"parameters":[…],…}}
← {"jsonrpc":"2.0","id":2,"method":"build.configure","params":{"parameters":{"tag":"dev"}}}
← {"jsonrpc":"2.0","id":3,"method":"target.run","params":{"target":"lint","runId":"…","dryRun":false}}
→ {"jsonrpc":"2.0","id":10,"method":"exec","params":{"target":"lint","argv":["ruff","check","."]}}
← {"jsonrpc":"2.0","id":10,"result":{"code":0,"stdout":"","stderr":""}}
→ {"jsonrpc":"2.0","id":3,"result":{}}
…
← {"jsonrpc":"2.0","method":"run.exit","params":{"code":0}}
Engine-to-worker calls are build.describe,
build.configure, the build.on* hooks the manifest
flagged, target.run, target.condition,
target.cacheKey, target.settings,
target.validate, service.start,
service.ready, service.stop, and finally
run.exit (or run.cancel). Worker-to-engine calls
are exec, tool.call, log,
state.get/set/trySet,
secret.register and signals — the
run context a body already has in
TypeScript, served over the wire. The full shapes, with the guard that
rejects each malformed field by name, are in the
@zuke/engine
API docs.