Importer (asset type)
When to use one
- You're bringing a new file format into the engine and want to derive engine-native assets from it (meshes, textures, animations, data tables).
- You want one VFS write to fan out into multiple derived assets (e.g. a GLB write derives mesh, texture, and animation assets).
- You want format ingestion to be idempotent + hot-reloadable — importers re-run on source-file changes.
If you only need to consume an existing engine asset type, you don't
need an importer; just resolve it via asset.resolve. If you're
building an authoring tool that emits assets, that's a .tool, not
an importer.
Where it lives
- Source:
/zero/source/.../<name>.importer/ - Identity:
<name>(the.importersuffix strips). - Folder shape:
init.luau(orinit.lua) — exportscanImportandimport, andextensions: the file extensions it claims, lower-case without the dot. An importer that makes one asset beside its source also exportsproduces, the type of that asset ("texture"makes<stem>.texture). Required.README.md— instance documentation. Required..metadata— agent-editable tags + free-form fields. Required.settings.luau— the importer's own settings schema, declared withFielddescriptors. Optional; an importer without one takes no settings.settings.yaml— the importer's settings values. Optional.
Settings
An importer that takes settings declares them in its own settings.luau and
receives the resolved values as ctx.settings in import(ctx).
asset.schema(importerRef) reports the schema, asset.settings(importerRef)
the values, and asset.setSettings(importerRef, patch) changes them. What an
importer stamps onto the assets it creates is typically one of these: the
texture, SVG and model importers each declare textureSettings = Field.settingsOf("@builtin::assetTypes.texture", NoSync) — the texture type's
own fields — and pass it to asset.create as importerSettings, the importer
layer of each texture's settings. A change to an importer's settings counts as
a change to what an import is made from, so an unchanged source is imported
again after one.
How to create one
asset.create("importer", "<name>")
-- Creates: /zero/source/<name>.importer/
-- init.luau (canImport / import stubs)
-- README.md (instance README template)
-- `folder` places it in a subfolder of /source instead of the root:
asset.create("importer", "<name>", { folder = "importers" })
How it operates
- Registration. Writing
init.luauinto a.importer/folder indexes the importer with the importer registry. - Dispatch. When a file is written to VFS, the engine asks every
registered importer
canImport(path, bytes)(cheap predicate). The first importer that accepts dispatches to itsimportfunction. - Derivation.
import(path, bytes)returns a list of{ path, kind }pairs naming the assets to derive. The engine instantiates each derived asset against its registered category. - Idempotency.
importMUST be deterministic — importing the same source bytes twice produces identical derived assets. The importer pipeline uses content-hashing to short-circuit re-imports when bytes haven't changed. - Hot reload. Editing the importer reloads it; subsequent writes
use the new logic. Already-imported assets stay imported with the
old derivation until their source is imported again — writing the
source path re-dispatches the importers, and
tools.use("importers", "run", <path>)re-imports a target, many targets, or a whole folder on demand. - Play. A source written while play runs lands on the play shadow
and imports there, so the source and its asset are kept or discarded
together at play-exit. A source play holds where it stands (written
before play, or by a durable write during it;
vfs.playHoldsInPlace) cannot be moved into a new asset while play runs, so its import is owed: the job readsowed(reasonplay-lock), a notice names it, and it imports when play ends or pauses.tools.use("importers", "queue")lists owed imports andimporters.canceldrops one. - A lost live session. While the live world session is lost and
reconnecting (
vfs.sessionLost()), every write to authored source is refused, so an import asked for then is owed rather than started, and one a drop catches part way is owed rather than failed, its job carrying the refused write aserror; when the import had already moved its source into the asset it makes, it runs again on the source there, regenerating that asset around it. The job readsowed(reasonsession-lost), the noticeimport owed until the live session re-establishesnames it, and it imports once the session is back, when the noticeimport resumed: the live session re-establishednames it andimport finished(orimport failed) settles it. A generation waiting on such an import completes with the asset it produces. Content this machine keeps to itself (vfs.isSaveExcluded:/source/tmp/,/source/local/) is written while the session is lost, so an import of a source there into an asset there runs at once.
Re-importing over an asset an import made
An import keeps its source inside the asset it makes: art/plate.svg becomes
art/plate.texture/ holding plate.svg. import(ctx) reads where to write
from ctx.output (<dir>/<stem>.<produces> on a first import) and keeps the
source with ctx.retainSource(dest, opts?), which moves it to dest and
returns where it is now. import returns { output, source }, source being
the file the asset keeps, which the asset's import provenance records as its
source.
- The source written again beside the asset. Writing
art/plate.svgonce more re-imports it into the sameplate.texture: the new bytes replace the svg the texture retains (which keeps its guid), the written file leaves, and the asset is made again from the new bytes. Itsimportprovenance counts the iteration on. - The same bytes written again. When the source's checksum and the
importer's settings match the import that made the asset, no import runs:
the asset already holds those bytes, so the written copy is folded into it,
and
writeOutcomeanswersskippedwith reasonunchanged-content, naming the asset and the file that holds the bytes. - The asset itself.
tools.use("importers", "run", <asset>)re-imports a produced asset in place from the source it retains (ctx.containernames it), so rewritingplate.texture/plate.svgand running it re-bakes the texture. A run that makes nothing says why: each skipped result carries areason(missing,no-source,no-claimant,unclaimed).
Discovery
asset.list("importer")— every registered importer.asset.inspect("<name>")— claimed extensions, derivation conventions, source, this type README.ls /zero/source/<name>.importerlists the files the folder holds.tools.use("importers", "list")— every registered importer, with its name and identity.tools.use("importers", "explain", <path>)— which importers claim a given source and which gate would block the import.
Where a write's bytes end up
An importer that claims a written source may move it into the asset it
makes (the texture importer turns shots/a.png into shots/a.texture/
holding a.png), so the path the write named stops holding the bytes.
require("@builtin::assetTypes.importer.shared").writeOutcome(path, sinceFrame, timeoutSecs) answers where they went, for a write made on
engine frame sinceFrame (time.frameCount() read before it): nil when
no importer claims them, else the import's state, the importer, the
asset made (output), the file the bytes are in now (source) and a
note saying all of it. Every write that fires its side effects is
offered to the importers by the same rule, whichever route made it, and
the MCP write_file and upload_file answers carry this outcome as
import. A quiet write ({ quiet = true }, and every capture save)
is offered to no importer, and its bytes stay where they were written;
writeOutcome answers not-started for one an importer would claim.
A write whose import the running play session holds back (a fresh
import moves its source into the asset it makes, and play holds a source
that predates it, or that a durable write made canonical, where it
stands) answers owed at once, with reason play-lock: the bytes stay
at the written path, and the import runs when play ends or pauses. One
the lost live session holds back answers owed with reason
session-lost, and runs when the session re-establishes.
writeOutcomes(paths, sinceFrame, timeoutSecs) answers a run of writes
made together (a model, then its buffer and textures) once all of them
have landed, within one wait: a folder write_file or upload_file
lands every file without waiting on any import, then answers each this
way. write_file { awaitImport = false } lands one file of such a run
without waiting on its import.
Authoring conventions
- Keep
canImportcheap — it's called for every VFS write. Match on extension or a quick header probe; defer real parsing toimport. - Declare the extensions the importer claims as
M.extensionsand havecanImportclaim by that list. The Inspector lists them, andrequire("@builtin::assetTypes.importer.shared").extensionsOf(importerRef)answers them (lower-case, without the dot; nil for an importer that declares none). - Name derived assets predictably (e.g.
imported/<source-stem>/<asset-name>). Predictable paths let callers reference derived assets directly. - Make
importpure relative to its inputs. Don't read mutable globals or side-channel state. - Log derivations via
print/ the script log so authors can see what got produced from each source.
Common pitfalls
- Expensive
canImport. It runs on every VFS write; an expensive predicate slows down the entire authoring loop. - Non-idempotent
import. Re-importing produces different output → content hashing falsely caches stale results. Use only deterministic transforms. - Conflicting importers. Two importers claiming the same extension causes ambiguous dispatch. First-registered wins, but this is fragile — coordinate ownership of file extensions.
- Derived-asset suffixes. Make sure derived paths end in the
right
.<category>suffix; otherwise they won't register as recognized assets.
Related types
.module— for plain ingestion helpers that don't need the importer dispatch path..tool— for one-off conversions an agent invokes manually..service— if your importer needs persistent state (e.g. a cache); the service owns it, the importer reads from it.
In the Inspector
Imports: the extensions the importer claims (M.extensions), each file it imported in this session with the state its job reached, and Find assets it produced, which lists the assets in the world whose import provenance names this importer.