Log inGet started

Development & versioning

Updated October 20, 2026

Durable now, published later

Writes to /zero/source are durable the instant you make them — there's no save step, and they sync to every collaborator immediately. But durable isn't the same as published. Two deliberate steps separate your live editing from what players see:

  • commit — a navigable checkpoint in the world's history. Stays inside the editor.
  • push — publishes a new playable version for players and other importers. Only pushed versions are what players run; a push bundles your unpushed commits into one release.

Direct file writes vs. live scene edits

"No save step" applies to direct writes to /zero/source — write_file, vfs.write, asset.create. Those bytes are in the source the instant you make them.

Editing the scene through the ECS is different. Spawning or changing entities with entity.*, the entityOps tools, or the scene tools mutates the running scene, not scene.json directly. Those edits are live immediately, but they are held as pending scene changes — visible via tools.use("sceneAuthoring", "changes") — and reach scene.json only when you save the scene: layers.active:save(), or review-and-accept them with tools.use("sceneAuthoring", "acceptChanges"). A spawned entity that is never saved is live for the session but absent from the source, so it does not survive a reload. topics/building-a-game walks the play → changes → acceptChanges authoring loop in full.

A rebuild of a scene written as code saves it. For a scene with a build.luau, writing that file or reloading the scene (the scene reload tool, layers.reload) runs the build and saves the scene, and its pending scene changes are saved with it. A library scene (under /libs/) is the exception: its reload rebuilds the live scene and leaves its files as they are.

Staging records a scene as saved. zm add, world.add, world.add_all and world.commit stage and freeze scene.json exactly as its file holds it; pending scene changes stay pending in the session. So to publish entities you spawned or changed, write them into the scene first, then stage:

# edit mode: accept the pending changes (or save the active scene), then publish
zero sceneAuthoring changes            # review what is pending
zero sceneAuthoring acceptChanges      # write it into scene.json
zm add . && zm commit -m "describe the change" && zm push

layers.active:save() does the same for the active scene in edit mode. While play runs, staging records the scene as it stood before play, and the play session's edits are kept through the leave-play review (changes / acceptChanges). Each staging call that leaves pending edits out posts a notice naming the scene, its pending edits, the calls that keep them, and discardSceneEdits on the scene saver (require("@builtin::assetTypes.scene.shared.saver").discardSceneEdits(<scene>)) to drop them.

The friendliest surface is the zm command in the engine bash — the verbs you already know from git, on the shell you already drive git with:

zm status                        # what's changed
zm add .                         # stage everything (or a specific path)
zm commit -m "describe the change"   # checkpoint
zm push                          # publish

Every zm command is the git command of the same name, with git's arguments, outputs and refusals — zm branch -a, zm log --oneline, zm reset --hard HEAD~1, zm restore --staged <path>, zm stash pop, zm checkout --theirs <path> all mean what they mean in git, and an option git does not take is git's unknown switch error. zm --help lists them. Two things git has no verb for stay in zm: zm trash (the world's shared trash) and zm pr (pull requests, as gh pr names them). Accounts, worlds and installs are the zeromind toolbox.

With more than one of you authoring the world at once, add --stage <name> to add / commit / restore / status / diff / push and each of you commits and publishes your own paths under your own message — the core/worlds guide covers what the staging area is and what naming one changes.

The same verbs exist as the zm Luau toolbox, and the full surface is world.*:

zm.status()                       -- what's changed
zm.add("/zero/source/foo.luau")   -- stage a path        (zm.add(".") for everything)
zm.restore("/zero/source/foo.luau", { staged = true }) -- unstage, keeping your working edits
zm.restore("/zero/source/foo.luau") -- throw away a file's uncommitted edits (recoverable)
zm.commit("describe the change")  -- checkpoint
zm.reset("HEAD~1")                -- move HEAD back       (recoverable via zm.trash("restore", handle))
zm.push()                         -- publish

-- the full Luau surface, same operations:
world.add(path, { force = true? })   world.add_all()   world.unstage(path)
world.discard()                      world.discardFile(path)   -- drop the whole stage / one file's edits
local id  = world.commit("message")  -- returns the commit id
local zmId, verdict = world.push()   -- pushes the unpushed stack (or world.push(commitId));
                                     -- verdict is "published", or "already-published" when the
                                     -- remote already carries what the call would have pushed

Commit stages only what you've added (git semantics, not commit-all). Inspect with world.vcsStatus() ({dirty, staged, ignored, untracked, unmerged}), world.log({limit, rev}) (a revision's whole history, merged branches and pulled commits included), world.diff(...), world.show(...), world.head() and world.revParse(rev) (HEAD~1, a branch, origin/main, a commit id or a unique prefix of one). A file listed with reason = "references moved" did not change its bytes: a library pin moved (world.updateLibrary) or an asset it references changed, and its reference table moved with it. A commit holding the change that moved it records the file with it: when you staged the file, the commit takes its bytes; otherwise it takes the bytes the branch head holds with the pins that change moved advanced, so another author's unstaged edit of the file stays out of your commit. The staged set lists such a file with references_only = true.

Push also refuses while user scripts are broken: if any script in the world's own content (every script under /zero/source outside the library mounts /source/libs and /source/deps, session scratch under /source/tmp and this machine's own content under /source/local) carries an error-severity diagnostic, zm.push raises N script error(s) in user content — refusing to publish until they are fixed, naming each path:line — message. This is unconditional — the lsp.strict overrides that relax the edit→play gate (the engine guide) do not apply to publish. Fix the errors (lsp.checkAll({ scope = "user" }) lists them) and push again.

A push whose commits are already on the remote is the state you asked for, so it reports already up to date and returns the "already-published" verdict — a sibling session that published your commit as an ancestor of theirs leaves you here. A push ZeroMind refuses names the condition and the command that clears it: a branch that moved under you says so and points at zm pull, after which zm push lands. A push only moves the remote branch forward, as git's does: a remote head your branch does not hold is ! [rejected] <branch> -> <branch> (fetch first), one your branch is behind is (non-fast-forward), and zm pull first is the fix; zm push --force moves the remote branch to your published head anyway (it needs the Maintainer role, and drops whatever the remote held past it).

A push reports every class of blocker it finds in one refusal, so one round of fixes clears the push: broken scripts, assets missing files their type requires, computed asset references on the gameplay path, a component or scene entrypoint that reaches a dynamic resolve over require / tools.use (resolve.dynamic_on_gameplay_path), references pinned to an asset the world no longer holds, and error-severity problems recorded on published content. world.publishBlockers() (printed by zm status) reads the same list with no push attempted, and the LSP marks each offending line as an error.

A dynamic resolve is one of these call arguments, in the direct call or its pcall(f, ...) form, written as anything other than one string literal (a variable, a table field, a module constant, a concatenation, an interpolated string):

CallThe argument that decides it
require(name)the module name
asset.resolve(name), asset.tryResolve(name), asset.ref(name), asset.source(name)the asset name
asset.list(category) or asset.list({ type = category, ... })the category
tools.use(toolbox, tool, ...)the toolbox or the tool name

These four rows are the whole rule. In tools.use("entityOps", "fromAsset", O.BUNDLE) the toolbox and tool name are literals, so it is a static resolve, and O.BUNDLE is the tool's input. A computed value passed to an API parameter declared to take an asset reference is a separate finding, asset-ref-not-literal, which the push also refuses on the gameplay path; world.publishBlockers() lists it in its own class.

A dynamic resolve is refused exactly where a game runs it: in a component or scene entrypoint and in any source one of them reaches. A tool, or a module no component or scene entrypoint reaches, may resolve dynamically; the LSP marks such a line as a warning. A source on the gameplay path that requires (or tools.uses) a module reaching one is marked on that require, and the finding names the require path down to the file holding the dynamic resolve and that file's line and call, so the fix is one read away. Fix it there and the finding on every source above it clears with the same write.

Branches & merging

A session is bound to one branch (world.branch(), "main" by default). Branching and merging are git, verb for verb:

zm.branch("feature")             -- git branch feature   (from HEAD; { start = "<rev>" } to override)
zm.branch()                      -- git branch           (the list; current = true marks yours)
zm.checkout("feature")           -- git checkout feature (your uncommitted changes come with you)
zm.branch("feature", { delete = true })  -- git branch -d feature

Every branch owns its own working tree, so collaborators on other branches keep editing undisturbed. A switch brings your uncommitted changes and staged entries along, as git does, and refuses with git's Your local changes ... would be overwritten by checkout message — moving nothing — when the branch you switch to holds a different version of a path you changed. Bringing another branch's work into yours is git merge:

local r = zm.merge("feature")     -- or world.merge("feature")
-- r.status: "fast_forward" (r.commit = the head the branch moved onto)
--           "clean" (r.commit = the two-parent merge commit id)
--           "conflicts" (r.conflicts = { { path, kind, source_branch } })
--           "up_to_date"

The merge runs locally in the world. When your head is in the source's history it fast-forwards ({ noFf = true } always makes a merge commit, { ffOnly = true } refuses anything else). Local changes follow git's rule: one on a path the merge rewrites refuses the merge with git's message naming the paths; every other one stays in the working tree. A scene's unsaved live edits count as a local change to its scene.json: a fast-forward that rewrites that scene refuses, naming the scene and how to save or discard the edits, and a merge commit refuses over any scene holding them. A clean merge lands a two-parent merge commit and the merged content appears in the working tree. On conflicts, each clashing text file gets git-style <<<<<<< / ======= / >>>>>>> markers, the cleanly-merged remainder is applied as working-tree changes, and world.vcsStatus().unmerged lists what needs attention (git's "Unmerged paths"). Resolve each path — edit out the markers, take one side whole with zm checkout --ours|--theirs <path>, or remove the file. A binary file or a composite asset (a .scene, .package … folder) gets no markers: take a side, which brings that side's bytes and its asset guid, so references to the asset keep resolving; name a composite's folder to take the whole asset — then stage and commit as usual: that commit records the merge (second parent = the source head) and clears the unmerged set. Staging or committing a path that still carries markers is refused.

zm merge --abort (world.mergeAbort()) is git merge --abort: it clears the unmerged set and takes back what the merge wrote — the branch head never moved, and your other local changes stay.

Pushing after a merge sends the merge parent along, so the published history in ZeroMind records both parents.

Staying in sync with ZeroMind (origin)

ZeroMind is the world's origin remote, and content can advance there outside your session: a collaborator pushes, a pull request merges, a fork syncs. Fetch is how that drift becomes visible, and pull is how it reconciles. Verb for verb with git:

local f = zm.fetch()      -- or world.fetch(): git fetch
-- f.status: "fetched" | "up_to_date" | "no_remote_commits"
-- f.behind: origin commits you don't have    f.ahead: local commits origin doesn't have
-- f.diverged: both at once

local r = zm.pull()       -- or world.pull(): git pull
-- r.status: "up_to_date"
--           "fast_forward" (r.commit = the new head; no merge commit)
--           "clean"        (r.commit = the two-parent merge commit)
--           "conflicts"    (same markers/resolve flow as a branch merge)

Fetch mirrors the origin head into local history without touching the working tree, so it is always safe. Pull follows the same local-change rule as a branch merge. When the branch is strictly behind, pull fast-forwards and local history stays identical to origin's; when both sides moved, pull runs the same three-way merge as zm.merge with the origin head as the source, and the resolve-then-commit flow is unchanged.

A branch that took another branch's published commits — it fast-forwarded or pulled onto them — pushes as git's does: the push lands on top of the branch's own head on origin, recording the merge. When origin holds work the branch does not have, the push is refused (fetch first): zm pull, then zm push.

zm status shows the remote-tracking line as of the last fetch. The counts are a snapshot: run zm fetch again to refresh them.

Reading a published asset without installing it

Any published asset can be read where ZeroMind holds it, the way a repository is browsed on the web before it is cloned. Its address is @<guid> (the guid a zeromind_search hit carries), at a version @<guid>@<ref> (a commit, a checksum, a branch or a tag), and a file in it @<guid>:<path>, the path relative to the asset. The asset is its whole closure: its own files and everything it depends on, files from other worlds tagged with the world they come from.

zm ls-tree @<guid> | head -40              # kind, size, path of every entry
zm show @<guid>                            # the asset's primary file
zm show @<guid>:API.module/init.luau       # one file
zm grep -n "applyCommand" @<guid>          # grep over its text files
zm grep -l trick @<guid> '*.luau' | wc -l  # narrowed by a path glob
zm log @<guid> -n 5                        # its world's published commits

zm ls-tree reads the nearest 1000 entries unless --limit asks for more (up to 20000) and says on stderr when more exists. zm grep reads the closure's text files only, never its meshes, textures or other binary files, takes grep's own options, and stops at --max-files / --max-bytes, saying on stderr where and why. These reads go through the engine's own ZeroMind connection, read anonymously, and answer for public content; a file read once is kept in the engine's content store, so the next read of it is local.

Contributing changes upstream

When you improve content you installed from another world, you can send the change back to the world that owns it — the fork sends a pull request to its upstream:

zm status                          # "Installed content ahead of upstream" lists each install you changed
zm diff /source/Doom.component upstream   # your changes against the version you installed
zm pr create /source/Doom.component       # open (and by default merge) the pull request upstream

The same in Luau is zm.status().upstream / world.forkStatus(), world.diffUpstream(root) and zm.prCreate({ root }) / world.contribute({ roots = { root } }). The ahead-of-upstream view lists every installed asset whose content diverges from the version you pinned when you installed it, grouped by the true origin world. Provenance is transitive: a fix to a deep dependency is attributed to the world that authored it, not the intermediary world you happened to install through, so the pull request lands where it can actually be merged.

zm pr create does the rest per origin: it remaps your changed files to the origin's canonical paths, three-way merges them against the origin's current content, pushes a contrib-<id> branch into the origin world, and opens a pull request there. If the origin changed the same content in the meantime, those regions come back as local conflicts with markers — resolve them like a pull conflict and run zm pr create again. When you have write access the pull request is merged immediately (otherwise it is left open for review), and afterwards the local fork re-syncs so the asset reads as level with its origin again instead of perpetually ahead.

The pull-request surface is directly drivable too: zm.prList(world?) and zm.prMerge(world, number, strategy?) (also world.prList / world.prMerge).

Throwaway working state: .zmignore

Some files need to sync to collaborators live but should never enter history — a scene editor's per-keystroke scene_dirty/… files, for instance. Drop a .zmignore (or .gitignore — both honored) under /source with gitignore(5) patterns (*, **, !negation, anchored /, dir-only /):

scene_dirty/
*.tmp

Matching files still sync (the filter is a staging gate, not a sync gate) and show up in world.vcsStatus() under ignored, but world.add refuses them and world.add_all skips them. world.add(path, { force = true }) is the escape hatch. The gate also checks dependencies: staging or committing a file whose refs point at an ignored path is refused, so an ignore rule can't slip a broken-reference commit through.

Safety rails — the editor is shared

Because edits affect everyone, cleanup is backed by a shared safety net rather than a wall of confirmations:

  • A shared 7-day trash. Destructive operations snapshot what they remove into a world trash first, so they're recoverable: zm trash (world.trash()) lists entries, zm trash restore <handle> (world.restore(rowId)) brings one back, zm trash empty (world.trashEmpty()) deletes them for good. Moving a branch head (zm reset) and throwing away a file's uncommitted edits (zm restore <path>) run in one step; the commits a reset leaves behind restore in chain order (oldest first).
  • Affirmation tokens. A destructive operation the engine does not run in one step returns a one-shot XXX-XXX-XXX token you confirm with world.affirm(token).
  • Stashes. zm stash sets your changed and staged paths aside and takes them back to their committed version; zm stash list, zm stash pop, zm stash apply and zm stash drop (world.stashes(), world.stashPop(ref?), world.stashApply(ref?), world.stashDrop(ref?)) default to the newest stash, stash@{0}.

world.list() enumerates your worlds; world.installLibrary / world.installAsset bring shared content in (the worlds guide), world.assetInstallable answers whether one published asset resolves a closure of its own before you pull it, and world.installedAssets() lists what this world carries from ZeroMind keyed by published guid — the identity to check a pull against, since an installed asset's local path is chosen by the installer.

The model

Edit freely — it's durable and shared instantly. Commit to checkpoint, push to publish. Use .zmignore for live-but-throwaway state, and trust the affirmation/trash rails: in a shared world they're what keep one person's cleanup from being everyone's loss. Discover the full surface with lsp and by reading modules/api/engine (the discovering guide).

Events for embedded coding agents

The native editor's Claude Code and Codex CLI slots can receive engine events while idle or working. Each event is either actionable — the agent is expected to act on it — or background, and the receiver decides from that whether to start a turn.

  • An actionable event starts a turn of its own when the agent is idle. The agent's own work is actionable for it: the background tasks it started, the notices its calls, tasks and callbacks raised (a notice's default audience is the caller that raised it), and messages addressed to it. The turn waits until the agent can act: for Claude, until a turn has completed in this launch; for Codex, until its engine MCP server reports connected.
  • A background event joins the agent's context without starting a turn: on Claude's next tool result or prompt, and in Codex's thread history. A notice for every caller is background, and so is everything already waiting when the agent launched.

notices.post's actionable option states which a notice is. The Events control in each agent pane pauses delivery. Paused, the slot's notices arrive on its tool results, as they do for any other MCP client, and its own messages and task completions stay queued until delivery resumes. Its status reports connection failures, the number of queued events and whether one of them wakes the agent.

Use the editor agents module to deliver a durable conversation message:

local agents = require("@builtin::modules.api.editor.agents")
local result = agents.deliver(2, {
    id = "asset-job-42-complete",
    text = "The requested model is available at /zero/source/models/chair.glb.",
    actionable = true, -- the default; false hands it to the agent's context instead
})
assert(result.ok, result.error)
return agents.deliveryStatus(2)

Slot numbers identify this engine's panes. The receiving slot must have an active event receiver. Reusing an id with identical text is an idempotent retry; changing its text is refused. A successful enqueue means Zero accepted the message. The pending count drops after the CLI accepts it, before the model necessarily replies. A full queue returns an error so the sender can retain and retry its message. Messages are held in this engine process.

Engine notices and background task handles returned through either MCP transport also feed these receivers. Completion messages identify the task whose result the agent can inspect. Event text is marked as automated world data.

Codex runs its installed app server with an authenticated loopback connection and attaches its stock terminal UI. Claude uses a launch-local event plugin. Claude installations without function-hook support report the receiver as unavailable. These launch settings are supplied by Zero. The browser editor reports that embedded terminal agents require the native desktop engine.

Fresh context before a reply

Zero adds the current engine state, performance snapshot and available/held skills to each outgoing model request for supported embedded CLI providers. This also runs on turns that call no tools. Each request gets one current snapshot; the CLI's saved conversation contains its own messages and tool results.

agents.deliveryStatus(slot) reports contextActive, contextRequests and contextError. Once that slot's request channel is active, its tool results omit the duplicate state, performance and skill blocks. Other MCP clients retain their normal tool-result context. A ready, unpaused event receiver receives notices through its mailbox, and a notice it delivered is read for that slot, so the slot's tool results do not repeat it; pausing events restores normal notice delivery on tool calls.

The session uses the account's configured Responses provider for Codex or Messages API endpoint for Claude. The CLI owns authentication and the engine forwards its request credentials to that endpoint. Unsupported provider modes report contextError and retain tool-result context.

  • documentation
  • guide