Log inGet started

The tool system

Updated 4 September 2026

What a tool is for

A tool earns its place by doing one of three things:

  • Makes a one-off easy. One call that would otherwise be several: resolve an asset, spawn an entity, add a Model, add a collider, position it → one spawnModel(...).
  • Packs a whole workflow into one call. "Convert every material in this scene to the ps1 shader", "bake every probe in the scene", "run this input sequence". Brute force is fine: tools run on the authoring path, not the gameplay hot path.
  • Hands the agent context an API wouldn't. Spawn a model and get back its position, bounds, and neighbouring entities in one shot.

Its inputs must be settable by an agent blind to how the engine works underneath: words, intents, nil meaning "all", proxies already in hand. If using a tool requires understanding the system beneath it, it's the wrong tool.

A tool composes over a capability, the underlying engine API the tool body itself calls: the entity / component / asset globals, the modules.api.engine.* modules, and the rest of the engine surface. Gameplay logic lives in those capabilities (or a module built on them) and is called directly; a tool calls the same capability and wraps it in a schema an agent can discover and invoke. That split is what lets a tool stay freely renamable: gameplay code reaches for the capability, the agent reaches for the tool.

Three surfaces, one registry

A tool's identity is <toolbox>.<name>, a tool inside a toolbox. The same live registry answers on three surfaces, so reach for whichever one you already hold. None is a subset of the others: each can browse, read a signature, and run.

zero, the shell command, whenever you have bash. Browsing, reading a signature and running are all the same command, and --help answers at every level, so you can walk from "what exists" to a correct call without leaving the shell:

zero                                    # every toolbox and what it covers
zero entityOps                          # the tools in one toolbox (same as `zero entityOps --help`)
zero entityOps fromAsset --help         # one tool: every argument, its type, whether it's required
zero entityOps fromAsset cube           # run it
zero --search spawn                     # keyword search across every toolbox (short: -k)

Arguments are named (--name value) or positional in signature order, and --help prints both runnable forms for the tool in front of you. A value that reads as JSON is passed as JSON (12, true, [0,5,0], {"internal":true}), a bare run of numbers like 0,5,0 becomes an array, and anything else is a string. The dotted identity works too: zero entityOps.fromAsset cube.

use_tool, the MCP tool call, when you're driving the engine from outside. The executing sibling of search_tools: search_tools finds the tool, use_tool runs it, with no execute() snippet to hand-write. Pass the toolbox, the tool, and args — an array for positional arguments in signature order, or an object naming the parameters, the same names --name value uses in the shell. It returns the tool's ZmToolResult envelope ({ ok, value | error, durationMs, tool }):

search_tools { query = "spawn asset" }         -- find it: returns toolbox "entityOps", tool "fromAsset", its signature
use_tool { toolbox = "entityOps", tool = "fromAsset",
           args = ["cube", { name = "ragdoll", position = [0, 0, 0] }] }
-- → { ok = true, value = { id = "<entity id>", ... }, durationMs = 1.8, tool = "entityOps.fromAsset" }

use_tool { toolbox = "MaterialAuthor", tool = "fromColor",
           args = { color = [1, 0, 0], opts = { name = "Red" } } }   -- by name

An args object whose keys are not parameter names is the tool's single table parameter, so capture.oneshot(opts) takes args = { pass = "depth" } as that table. Mixing the two — one parameter name beside fields belonging inside a table — is refused with the parameter list, because either reading would hand the tool something it never asked for.

describe_tool { toolbox, tool } is the MCP spelling of --help: one tool's full schema.

tools.use(toolbox, tool, ...), from Luau, when you're authoring in code. Inside an execute() call or an editor/authoring script, this runs a tool and unwraps the envelope: the raw value on success, a raised error on failure, so you write against the return value directly with no .ok / .value threading:

local r = tools.use("entityOps", "fromAsset", "cube", { name = "ragdoll" })
-- r.id is the entity id; a failing tool raises

Because toolbox and tool are asset identities, a tools.use(...) call written with literal names is statically resolved: the LSP checks the toolbox and tool exist, and the call is captured as a dependency edge so content that ships with a tools.use call carries its tool deps with it. Library toolboxes resolve by root: tools.use("@somelib::toolbox", "name", ...).

Where a tool may run

Tools write the VFS, create persistent assets, make HTTP calls, and queue heavy jobs. That is authoring work, not per-frame work, and it is the line: a tool belongs in an authoring context (an execute() call, the shell, an editor tool or panel), never in runtime code. Runtime code (a component, a scene entrypoint, anything that ticks each frame or runs on load) reaches for the underlying capability, not a tool. The engine enforces this statically:

  • A component reaches for a capability, not a tool. Calling a tool from a component is an error. A tool can opt in with declare { componentSafe = true } at the top of its init.luau, but that switch is an explicit "I acknowledge I'm using this against its intent": it's heavily frowned upon, there only for the rare case where you genuinely can't avoid duplicating the logic. The right move is almost always to extract the operation into a module the component calls directly.
  • A tool reaches for a module, not another tool. Using tools.use inside a .tool/.toolbox source is an error; shared logic goes in a require'd module.

So when a guide shows a component spawning an entity, driving a light, or reading input, it uses the capability (entity.spawn, modules.api.engine.lighting, zinput); the tool form is for the interactive authoring you do from the shell and from execute().

Toolboxes, the namespace

Tools live inside a toolbox, which gives each tool its <toolbox>.<name> identity. A toolbox can hold a shared.module for helpers more than one of its tools reuse, reached with require(".shared") from inside the toolbox. Browse the built-in toolboxes with zero in the shell, search_tools with no arguments over MCP, or asset.list("toolbox") from Luau: each gives a grouped overview of every toolbox and what it is for.

The right tool often lives in a toolbox you would not guess (re-skinning a hierarchy is under appearance, screenshots under capture, scene structure under scene), which is why browsing is worth a call before searching for a name you have imagined.

The result contract is system-enforced

A tool body returns a raw value (or calls error(msg) to fail). The asset type wraps every call into the { ok, value | error, durationMs, tool } envelope and times it. The contract lives in the type, not in each tool, so every tool behaves the same way: returning a value is success, error(...) is failure, and the envelope is added for you. use_tool hands you that envelope; tools.use and zero unwrap it.

Anatomy, and creating one

A tool is an ordinary asset folder: <toolbox>.toolbox/<name>.tool/ with an init.luau. The schema lives entirely in that Luau: the typed function signature is the tool's signature, and the --!desc / --!arg / --!return / --!example annotations above it are its per-function docs; tags live in the sibling .metadata. The function whose name matches the .tool folder stem is the canonical entry. Edit the file like any source and it hot-reloads, so a toolbox added to the world after boot is listed and callable on every surface with no rebuild.

local M = {}

export type PlaceOpts = { position: { number }?, parent: EntityRef?, count: number? }

--!desc Spawn a primitive and return the entities it created.
--!arg kind Primitive shape to spawn.
--!arg opts Placement and repetition options.
--!return The spawned entity refs.
--!example "cube", { count = 4 }
typed function M.spawn(kind: ("cube" | "sphere" | "plane"), opts: PlaceOpts?): { EntityRef }
    -- ...
end

return M

The typed keyword is what makes the signature real, and every exported tool function carries it. It compiles to a dispatch wrapper that checks each argument at call time and raises a message naming the argument, the expected type, and what arrived. A plain function is checked by nothing — including one written function M.spawn(kind: string), where the annotation informs the LSP and enforces nothing. Such a tool takes any input, runs its body on it, and returns a success envelope, which is why a wrong call to it looks like a working one.

--!closed above the declaration says its records spell every key it reads. A record type is the minimum shape an argument must have, so by default a table carrying keys beyond it is taken and the extra keys reach the body, where a tool that reads its options by name looks at none of them — a misspelled rooots or a key borrowed from a sibling tool is dropped and the call reports success. Written above the typed function, --!closed turns that into a refusal naming the key and the fields the record does spell, the answer a wrong field type already gets. It reaches every table argument of that declaration, through T?, (T), the branches of a union and a named alias — and stops at a field's own type, which is a different declaration and answers for itself. Write it on a tool whose declared record is the whole set of keys it reads; leave it off one that hands an argument on to something that reads more — pp.add reads its options against the property schema of the effect being added, which a static record cannot spell, so it names that schema's own set in its refusal.

--!desc Assign a material to every renderable under the named roots.
--!closed
typed function M.swapMaterials(material: string | AssetRef<material>, opts: SwapMaterialsOpts?): AssignSummary
appearance.swapMaterials: argument #2 'opts': no field 'targets' — the fields
it reads are: first, match, roots

Type each argument as narrowly as the operation accepts: a fixed set of accepted words is a literal union (("cube" | "sphere")), never string; a structured argument is a named export type, never { [string]: any }; an entity or asset is EntityRef / AssetRef<kind>, never any. An op-keyed dispatch table is several tools wearing one signature — split it. Reserve any for a value the tool genuinely does not constrain.

Those annotations are what zero <toolbox> <tool> --help and describe_tool print, so writing them well is what makes the tool usable by the next reader.

To make tools programmatically, tools.createToolbox and tools.create write the folder for you (lsp.describe gives their exact signatures). Whatever the route, the live surface is the reference:

tools.list()                 -- toolboxes and their tools
tools.get("entityOps.spawn") -- a tool's full schema

Reading a built-in tool's init.luau is the best way to see how one is built, and asset.inspect("<toolbox>.<name>") shows any tool's schema and docs. The asset system behind tools (identities, hot-reload, sharing) is in the assets guide.

  • documentation
  • guide