The filesystem (VFS)
The trees
/zero/source/…— the authored world: your assets, scenes, components, code, plus.world_settingsand.world_entrypoint.luau. Durable and shared (the engine + worlds guides)./zero/home/<username>/…: your own folder, carried from world to world in the editor and stored in your home world. Private to you, saved as you write it, and never a dependency of world content (home.status(), the home guide)./zero/runtime/…— live engine state: the loaded scene layers and their entities, lighting, generated meshes, render surfaces, logs. Transient — it reflects the running world, not saved content.
Two directories under source belong to the machine rather than to the world, and are always listed even when empty:
/zero/source/tmp/…— scratch. Writable and hot-reloading like anything else, excluded from every save, and gone when the process ends./zero/source/local/…— the runtime write path: where a running session puts data it produces and wants back later. It never syncs, and it survives restarts, kept per world and per user on that device.
Reading runtime state
Entities are folders under /zero/runtime/layers/<layer>/entities/. Read them like files:
ls /zero/runtime/layers/main/entities/ # every entity in the layer
cat /zero/runtime/layers/main/entities/lantern # summary: name, id, transform
cat /zero/runtime/layers/main/entities/lantern/position # {"x":..,"y":..,"z":..}
ls /zero/runtime/layers/main/entities/lantern/components # what's attached
This is the fastest way to answer "what's in the scene right now," "where is this entity," "what components does it have" — and it composes with the shell: grep, find, pipes, and .sh scripts all work over /zero.
Reading source
/zero/source is your authored content as files. Asset folders carry a type suffix (Foo.component/, Bar.material/) and are directories like any other: ls one for its contents, zero assets describe it for what the asset is, and edit files inside to change them. Components, materials, modules, and the like hot-reload. A read of a directory, an asset folder included, reads no bytes on any surface: vfs.read answers nil and the directory's refusal, cat fails, and read_file and the VFS HTTP route answer with its listing.
ls /zero/source # libs, scenes, .world_settings, …
zero assets describe /zero/source/Spinner.component # what the asset is
When a read finds nothing
A read that finds no bytes says why. vfs.read and vfs.readBounded answer nil and the reason: one sentence naming the path and what the engine found there. The read_file tool and the VFS HTTP route (the detail of its 404) state the same sentence for the same path, so a reason read on one surface reads the same on the others.
local data, why = vfs.read("/zero/source/notes.txt")
if data == nil then
print(why) -- /zero/source/notes.txt: not found
end
The reason tells apart a path nothing holds (not found), a directory (is a directory, not a file), a file the world holds whose bytes are not on this engine, and a /runtime node's own account of a subpath it does not hold, such as /zero/runtime/active_audio/nonsense: 'nonsense' names nothing under active_audio.
A read handed straight to another call as its last argument passes the reason on as the next argument too, and the call can refuse it: a typed function such as Json.decode or Test.expect raises too many arguments, and tonumber(s, base) or table.insert(t, pos, v) reads the reason as its second argument and raises on it. Wrap the read in parentheses to hand on the bytes alone: Json.decode((vfs.read(path))).
Reading bytes that are still on their way
A file can be known before its bytes are here: a synced binary on a machine with no local copy of it, a large mesh or texture the web build keeps in the browser's store, a render surface still being captured. vfs.read of such a file fetches the bytes and waits for them, which takes a yield, so where the reading code can yield (coroutine.isyieldable()) the read answers the bytes a moment later and the code carries on. That covers a task and the component hooks the engine runs on a coroutine: awake, start, update, fixedUpdate, onEnable, onOwnerChanged, onSyncReceived, a replicated event's handlers, and a world or scene entrypoint's hooks.
Code that cannot yield cannot wait: onDisable and onDestroy (the engine acts on the instance the moment they return), onPropertyChanged (it runs inside the property write), a received synced-function call, a vfs.watch callback, a module's top level while require runs it, a metamethod, a table.sort comparator. A read there of bytes still on their way (a file vfs.exists knows, a render surface being captured, a /runtime/sync node whose current reading the next frame assembles) raises VFS_READ_CANNOT_WAIT, naming the path. Read the file where code can wait, or hand the read to a task:
function onDestroy()
task.spawn(function()
local bytes = vfs.read("/zero/source/level.bin") -- waits for the bytes
-- ...
end)
end
A path the VFS does not know answers nil and the reason wherever it is read. A wait that ends without the bytes answers nil and the reason too.
Writing source
A path whose last segment carries an asset-type suffix names the ASSET, on every
write surface. Hand Foo.module to vfs.write or to the write_file tool and
the body lands in the entry file that type declares — init.luau for a
.module or .component, mat.yaml for a .material, shader.wgsl for a
.computeShader — with the files the type declares required written beside it.
Naming the entry file yourself reaches the same file.
vfs.write("/zero/source/Spinner.module", "return {}\n") -- → Spinner.module/init.luau
vfs.write("/zero/source/Spinner.module/init.luau", "return {}\n") -- the same file
So both spellings leave one folder that asset.validate passes, and neither
leaves a file wearing an asset's name. Two kinds of type declare no one entry
file inside a folder, and a write to their root path goes where it says: a
container (Plant.bundle), whose inner assets carry their own types, and a type
whose asset is itself a file — .rig, .animation, .avatar.
Bringing a file or folder in from disk
How a file that exists on disk reaches the VFS depends on which agent is asking.
- An agent running inside the engine calls
write_file { path, local_path }for a file or folder on the engine's own machine. A native engine readslocal_pathfrom its transfer directory,<data_local_dir>/OrigoZero/Transfers, so put the file there first. A browser engine refuseslocal_path; the editor's upload button brings the file in. - An IDE agent connected through the zeromind plugin runs
npx -y @origozero/zeromind upload <path> --world <world> [--to <dir>]in its own shell. - A chat connector such as ChatGPT passes the attached file through the hosted
write_file'ssource.
content_b64 is for small binary generated in memory. A file that exists on disk
goes in by one of the routes above, because base64 inflates it by a third and
fills the agent's context with bytes it cannot read.
Keeping a source write made while play runs
A write to authored /source while play is running lands on the play shadow:
the bytes are live in the session immediately, and a guarded play-exit puts the
pre-play copy back unless they were kept. Where the bound world syncs the path,
the write is carried to it as it lands, like an edit-mode write: collaborators
see it, zm add stages it, and a restart or crash before the guarded play-exit
keeps it as the path's content and loses the pre-play copy. Where nothing syncs
the path (a session-only boot, a frozen world version, the built-in library), a
restart ends it. The report's survivesRestart says which of the two a write is.
The shadow takes the writes of the callers taking part in play: the one that
entered it, any that asked for play while it ran (engine.mode = "play"), and
every write no caller is named for. Another agent driving the same engine
writes canonical source while play runs, and the play-exit leaves its files
alone; core/engine has the whole rule.
A write answers with where its bytes went, and the answer carries the way out:
local ok, report = vfs.write("/zero/source/game/Vent.component/init.luau", src)
-- report.durable == false in play, report.survivesRestart whether a restart
-- keeps it, report.warning names the state, report.playShadow names these
-- routes, report.shadowed which of the paths the call landed the shadow is
-- holding
vfs.copy and vfs.move answer the same way about what they landed, per leaf
when the call took a folder, and vfs.durability(path) asks the same question
of any path afterwards, in the same fields and the same words.
vfs.playShadowPaths() lists everything the session currently holds, and
vfs.playShadowAuthors() says whose each one is — several agents drive one
engine, and their edits share the set.
Every call that lands a file answers the same way, so the report arrives with
the work rather than being asked for afterwards. vfs.write, vfs.copy,
vfs.move and asset.create return it as a second value, av.record as a
third, and the capture tools as their last, reading
{ durable = false, survivesRestart, warning, playShadow, shadowed } while play is running. The
report a write returns carries diagnostics beside those when the static
passes read something in the Luau it landed, which is the answer the MCP
write_file tool gives for the same bytes at the same path.
A take is filed when it ENDS, so av.record answers for a write that has not
happened yet; ask that question anywhere else with
vfs.durability(path, { pending = true }), which reads the verdict the write
will get rather than the bytes that are not there.
There are three ways to keep a write, and they differ in what they cost the rest of the session:
vfs.promotePlayShadow(path), orvfs.promotePlayShadow({paths})— takes THOSE paths onto canonical source. Play keeps running, the mode and the clock do not move, and every entry the call did not name stays where it stood.vfs.write(path, bytes, { durable = true })— states the destination up front, so the next write skips the shadow and there is nothing to promote. Use it when the write itself is the authoring step. Thewrite_fileandedit_filetools take the samedurable: true.tools.use("sceneAuthoring", "changes")thenacceptChanges— reaches pending ENTITY changes as well, and the session keeps running from the review to the verdict, so settling your own work costs nobody else on the world their frames.
vfs.revertPlayShadow takes the same one path or list and does the opposite:
the pre-play copy goes back into the live slot, or the file goes away when play
created it. vfs.discardPlayShadow() does that for every pending edit at once,
which is what the flip to edit runs once engine.discardPlayChanges() has
armed it. The discard names every path it throws away: discarded in its
return, a notice per path, and one engine log line listing them all.
engine.discardPlayChanges() returns the paths the flip would throw away as it
stands (paths) with the routes that keep them (keep), while keeping them is
still possible; the flip takes whatever the shadow holds when it runs, an edit
made after the arm included. A write made with { durable = true } lands on
canonical source, and every discard keeps it. A path whose pre-play copy is not
on this engine yet (the world's blob, still being pulled) keeps the play bytes
until the copy lands and is put back then; owed in the return and
vfs.playDiscardsOwed() name each such path and why.
A folder asset settles whole
A path with an asset-type suffix names the asset, and writing one lands several
files: Vent.component/init.luau shadows the entry file, the README.md and
the .metadata together. The asset's type requires them together, so the
shadow settles them together. Promoting or reverting any one file of a folder
asset settles every shadowed file of that asset, and the call's second return
value names each path it settled. An asset nested inside another (an
.inputBinding inside its .inputMap) settles with its own files, apart from
the outer asset's. When play made the outer asset, the nested one stands on
source only inside it: keeping the nested asset keeps the outer asset's own
files too, and reverting the outer asset reverts every asset inside it. No
verdict leaves part of a play-written asset on source.
A path an earlier call settled, by name or with its asset, answers as settled
when it is named again the same way, so walking a vfs.playShadowPaths()
snapshot one path at a time settles every path in it. Named the other way (a
revert of a file a promotion settled), it raises naming the call that settled
it.
The folder asset's own path names the asset, so it settles the same way: every shadowed file of it, and of the assets nested in it. A generated material or texture is kept, or dropped, in one call by its folder path.
local _, settled = vfs.promotePlayShadow("/zero/source/game/Vent.component/init.luau")
-- settled: the entry file, .metadata and README.md of Vent.component
local _, kept = vfs.promotePlayShadow("/zero/source/game/Gold.material")
-- kept: every shadowed file of Gold.material
A path with nothing on the play shadow at it or beneath it settles nothing and
raises nothing, since no play edit stands there to keep or discard. The call
names it in its third return value (and, for a single path, answers nil in
the first), so a list of paths you are unsure were written in play can be
handed over whole:
local named, settled, unshadowed = vfs.promotePlayShadow("/zero/source/game/notes.md")
-- nothing written there in play: named == nil, #settled == 0,
-- unshadowed == { "/zero/source/game/notes.md" }
vfs.durability(paths) answers about the paths you hand it; the set a session
is holding comes from vfs.playShadowPaths():
-- Everything on the shadow under one path.
local function heldUnder(prefix: string): { string }
local set = {}
for _, held in ipairs(vfs.playShadowPaths()) do
if string.find(held, prefix, 1, true) == 1 then table.insert(set, held) end
end
return set
end
local settled = vfs.promotePlayShadow(heldUnder("/zero/source/game/Vent.component"))
A plain folder, one that is not an asset, is refused rather than settled. The
refusal names the first five folder assets and loose files under it by the
paths that settle them, and counts the rest. heldUnder(<folder>) above is
the whole set, and passing it as one array settles every one of them.
On an engine other people are running in, the prefix to name is your own work.
vfs.promotePlayShadow(vfs.playShadowPaths()) is everything the engine holds,
which is yours to take only when the engine is yours alone; widen the prefix
instead:
local settled = vfs.promotePlayShadow(heldUnder("/zero/source/game/"))
Every named path is attempted, and one that refuses does not stop the ones after it. The call raises when any promotion could not happen (a folder covering shadowed edits, a read-only workspace, a write that failed), naming each refusal alongside what settled, and the settled paths are canonical source either way. A named path that held nothing on the play shadow is no refusal; the third return value lists it.
The scratch and local trees sit outside all of this: a write under
/zero/source/tmp/ or /zero/source/local/ lands where it says in either mode,
and neither is shadowed. They answer by where they persist. local/ is kept by
this machine, so a write there answers durable = true. tmp/ is scratch: it
reads back while the engine runs and is gone when it stops or restarts, so a
write there answers durable = false, survivesRestart = false, a warning
opening SCRATCH, and scratch naming the paths, in edit mode and in play
alike. Write anything you mean to keep outside tmp/.
Removing source while play runs
While play is running, a write to authored /source lands on the play shadow,
live in the session and undone by a guarded play-exit, and a REMOVAL is answered by what
stands behind the path:
- Content this play session created removes. A folder counts as created by
the session when everything it holds was, whichever layer the bytes ended up
in — the importer's
Shot.texture/from acapturetaken in play, a recording written into a folder that did not exist a minute ago, an asset the session authored. Nothing durable stands behind it, so nothing is lost. - Content that predates play is refused, and the refusal says so: the shadow
carries changed bytes and holds no copy of a path that was taken away, so a
guarded play-exit would have nothing to restore it from. Leave play
(
engine.mode = "edit") or pause it (engine.paused = true) and the removal lands.
Every surface answers this identically — vfs.remove and the shell's rm read
one gate — so neither reports a removal that did not happen. vfs.remove
RAISES the refusal rather than returning it: the play lock is a property of the
running session rather than of the path, and a value returned beside it is a
value a caller reads as an outcome and skips past. vfs.mkdir and vfs.move
answer the same verdict the same way. A failure that IS a property of the path
— it is protected, its route is read-only, it is a plain directory and
recursive was not passed — still comes back as (false, message), and so
does a missing path on a route the lock leaves open (/source/tmp/ scratch,
the runtime routes). The lock answers ahead of whether the path is there, so a
locked /source path raises whether or not it holds anything.
Mutation applies on the next frame
The filesystem isn't read-only — writes to runtime state take effect, but asynchronously, applied on the next frame. Writing an entity's position file moves it; removing its folder despawns it. The Luau API does the same things synchronously (visible the same frame), which is why it's what you reach for in code — but the filesystem is a genuine write surface too, useful from the shell and scripts.
entity(id).position = { 0, 5, 0 } -- API: applies this frame
echo '{"x":0,"y":5,"z":0}' > /zero/runtime/layers/main/entities/lantern/position # VFS write: applies next frame
rm -rf /zero/runtime/layers/main/entities/lantern # despawns it (next frame)
One practical consequence of the async path: right after a VFS write, a same-call API read can still see the old value — it settles on the next frame. Read it back a frame later, or mutate through the API when you need same-frame certainty.
Knowing when a write has landed
The queue reports where it has got to, so waiting for a write's effects is a poll on a number rather than a guessed number of frames.
vfs.mutationSeq()is the drain's clock — the count of VFS mutations the engine has applied. Read it, write, then poll for a larger value.asset.reloadSeq(ref)is the per-asset reading: how many content-change reloads that asset has been through. Writes inside one asset are collected for a short settle window and reload it once, on a later frame, so this is what tells you the reload actually ran.asset.reloadPending(ref)answers the other half — true while a write still owes the asset a reload. It is recorded synchronously with the write, so it already reads true on the line after one, and goes false only once every change written so far has reached its subscribers. That is the wait to make before binding a consumer to an asset you just wrote, so a late reload cannot restart it.
vfs.write(asset.source(graph) .. "/graph.json", encoded)
repeat task.wait() until not asset.reloadPending(graph)
entity(owner).component.add("Generator", { graph = graph })
vfs.watch(path, callback) is the other side of this: it fires with the write itself, in the VM that registered it, rather than when the engine's own side-effects for that write have run. It reads a path the way vfs.read and vfs.write do, so /source/x, /zero/source/x and a bare x under the default root subscribe to one file, and a path ending in / watches everything under that subtree. modules.watch(path, callback) is the same idea one layer up, and its path is a require spelling: whatever spelling loads the module is a spelling a watch on it fires for, and a spelling whose module has not been written yet is recorded under its own canonical form, warns, and is called by the write that creates it.
The shape of /zero
/zero/
source/ authored world — assets, scenes, code, .world_settings, .world_entrypoint.luau (durable, shared)
local/ the runtime write path — never synced, kept per world + per user on this machine
tmp/ scratch — never synced, gone with the process
runtime/ live state — layers/<layer>/entities/, lighting, generated_*, render_surfaces, logs, events/ (component-event publishers + subscriptions)
docs/ API reference + guides
Where a running session keeps what it makes
A session that produces data has two durable places, and they differ in how far the data travels:
runtime_data | /zero/source/local/ | |
|---|---|---|
| shape | JSON leaves, path-keyed, size-bounded | files and asset folders, any bytes |
| survives a restart | yes | yes |
| follows the user to another machine | yes — it replicates | no — this machine only |
| scope | per world; per user for runtime_data.player | per world and per user |
Settings, progress, and small state go in runtime_data, so a new device picks them up. Bulk the device can produce again or does not need elsewhere — caches, recordings, generated assets, anything too large for a JSON store — goes in /zero/source/local/, where it costs the backend nothing because it never reaches it.
Content under local/ is ordinary source: an asset folder written there registers, and asset.list finds it. It is also the only durable write a shipped runtime build has, since that profile never leaves play and a play-mode write anywhere else under /source/ lands on the play shadow.
Nothing outside local/ may reference into it. A world asset that depends on something there resolves for exactly one machine and dangles for every collaborator and player, so the dependency is refused where it would be recorded — dropped from the .refs and replaced with an error-severity dep.local_content problem, which refuses the publish. The other direction is free: local content names world content as much as it likes. To share something local, promote it with the localContent toolbox and reference the copy that lands under /source/.
A file's size and age without its bytes
vfs.stat(path) describes a path without reading it: { path, isDirectory, isSymlink, size, modified }, or nil and the reason when nothing is there. For a file, the shell's stat and ls -l print the same size and time.
sizeis how many bytesvfs.readreturns. Where the layer holding the file states the length (a file held in memory, a synced or staged file) no byte is read, so measuring a large texture in the world costs nothing; a runtime node and a built-in library file are measured from the bytes the engine holds, which costs a read of them. A directory has nosize, and neither has a file whose length nothing states and whose bytes are not on this engine yet, such as a built-in binary an engine reading the library from ZeroMind has not fetched; avfs.readfetches it, and itssizeis there from then on.modifiedis when this engine's copy last changed, in seconds since the Unix epoch (os.time()'s scale): the write or arrival that put the current content there, which a move carries along with the content. The memory layer records it for every file it holds the bytes of: text up to the inline size threshold, and everything on an engine with no world bound. A file whose bytes the blob store holds (binary content, and long text, on a world-bound engine), a runtime node, and a directory carry none.
Deciding whether a cached file is stale is a question about bytes rather than about time, and each storage layer has a number for it that needs no read: vfs.contentAddress(path) is the sha-256 of the bytes the blob store holds, and vfs.contentVersion(path) moves on every change this session to a file held in memory. Keep the one you read beside your cached copy and compare.
local s = vfs.stat("/zero/source/local/previews/hero.png")
if s and s.size then
print(s.size, s.modified)
end
To list every file under a directory with its size, walk it with vfs.list, whose entries carry { name, path, isDirectory }, and vfs.stat each file it names:
local function sizesUnder(dir: string, out: { { path: string, size: number } })
for _, entry in vfs.list(dir) do
if entry.isDirectory then
sizesUnder(entry.path, out)
else
local s = vfs.stat(entry.path)
if s and s.size then
table.insert(out, { path = entry.path, size = s.size })
end
end
end
return out
end
local files = sizesUnder("/zero/source/local", {})
table.sort(files, function(a, b) return a.size > b.size end)
The shell answers the same question: find <dir> -type f | xargs stat -c '%s %n'.
The model to carry: the engine describes itself as a filesystem you can read — use it to inspect and discover (alongside lsp.* and the registries, the discovering guide), and use the API to change things.