Log inGet started

Importer (asset type)

Updated October 17, 2026

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 .importer suffix strips).
  • Folder shape:
    • init.luau (or init.lua) — exports canImport and import, and extensions: the file extensions it claims, lower-case without the dot. An importer that makes one asset beside its source also exports produces, 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 with Field descriptors. 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

  1. Registration. Writing init.luau into a .importer/ folder indexes the importer with the importer registry.
  2. 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 its import function.
  3. 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.
  4. Idempotency. import MUST 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.
  5. 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.
  6. 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 reads owed (reason play-lock), a notice names it, and it imports when play ends or pauses. tools.use("importers", "queue") lists owed imports and importers.cancel drops one.
  7. 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 as error; 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 reads owed (reason session-lost), the notice import owed until the live session re-establishes names it, and it imports once the session is back, when the notice import resumed: the live session re-established names it and import finished (or import 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.svg once more re-imports it into the same plate.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. Its import provenance 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 writeOutcome answers skipped with reason unchanged-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.container names it), so rewriting plate.texture/plate.svg and running it re-bakes the texture. A run that makes nothing says why: each skipped result carries a reason (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>.importer lists 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 canImport cheap — it's called for every VFS write. Match on extension or a quick header probe; defer real parsing to import.
  • Declare the extensions the importer claims as M.extensions and have canImport claim by that list. The Inspector lists them, and require("@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 import pure 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.
  • .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.

  • asset-type
  • reference