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.

you ./zuke test the launcher
engine @zuke/engine plans the graph, runs every command, keeps the state
worker 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 engineThe 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

  1. Start. ./zuke test reads zuke.json, finds the engine version and the worker command, and starts the engine.
  2. 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 run virtualenv is what every later command sees.
  3. Describe. The engine asks for the manifest. From here on the targets, the --list output, the --help, the graph and the generated CI are all the engine's, with the build's data in them.
  4. Configure. The engine resolves parameters from flags, environment and defaults and tells the worker the values, so tag.value is set before any body runs.
  5. 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.
  6. 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/conformance each 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.json describes 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.