Tool (asset type)
Every exported tool function is declared typed function. The
typed keyword is what makes the signature real: it compiles to a
runtime dispatch wrapper that checks each argument against its
declared type and raises on a mismatch. A plain function — even one
carrying Luau type annotations — is checked by nothing at call time,
so the tool accepts any input and reports success on garbage. See
"The typed signature is the schema" below.
When to use one
- You're composing several engine APIs into a workflow that's worth
naming and discovering (
tools.list,search_tools). - You want an agent to be able to call this function by name without reading source code — the typed signature is the API contract.
- The operation has clear success / failure modes that fit
{ exitCode, data?, stdout?, message? }.
If the function is just a Luau utility called from other code, use a
.module. If it's stateful background work, use a .service. If
it's pure GPU compute, use a compute .shader.
Where it lives
Tools nest under a .toolbox/ parent — toolboxes are the namespace.
- Source:
/zero/source/.../<Toolbox>.toolbox/<name>.tool/ - Identity:
<Toolbox>.<name>(dotted form — the toolbox name is part of the tool identity). - Folder shape:
init.luau(orinit.lua) — exports the function, and carries its schema in--!desc/--!arg/--!return/--!exampleannotations above it. The typed signature IS the tool's signature. Required.README.md— instance documentation. Required..metadata— tool-level tier and tags. Required.
How to create one
Use the workflow tool that scaffolds the right .tool/ shape under
an existing toolbox:
-- The `tools` global authors a tool inside an existing toolbox:
tools.create({
name = "<name>",
toolbox = "<Toolbox>",
description = "What this tool does, agent-facing.",
code = "return shared.ok(true)", -- the function body
})
-- Or scaffold the folder manually:
local box = asset.resolve("<Toolbox>.toolbox")
asset.create("tool", "<name>", { into = box })
Always nest under an existing toolbox — if you don't have one yet,
call tools.createToolbox({ name = "<Name>", description = "..." })
first.
How it operates
- Registration. Writing
init.luauinto a.tool/folder registers the tool with the tools registry. It becomes callable astools.use("<toolbox>", "<name>", ...)from Luau,use_tool { toolbox, tool, args }over MCP, andzero <toolbox> <name> [args]from the engine shell. - Schema validation. The
typed functionsignature declares the parameter shape. Its runtime dispatch wrapper checks each argument before the body runs and raises a message naming the argument, the expected type, and what arrived. - Return envelope. Tools MUST return
ZmToolResult:{ exitCode = 0|n, data? = any, stdout? = string, message? = string }exitCode = 0means success; non-zero means failure, withmessagecarrying the human-readable reason. - Discovery. The tool surfaces in
tools.list(),search_tools { query: "..." }, andasset.inspect'sexposes:section (parsed from the typed signature and the--!annotations). - Hot reload. Editing
init.luaure-registers the tool. Subsequent calls see the new behaviour / schema.
Discovery
tools.list()— every registered tool (any toolbox).search_tools { query: "..." }— MCP-side tool search.asset.list("tool")— same set, asset-style listing.asset.inspect("<toolbox>.<name>")— signature, parameters, return shape, this type README.cat /zero/source/<Toolbox>.toolbox/<name>.tool— same summary.
Authoring conventions
- Implementation lives in
init.luau. The tool's full function body — argument validation, the call into engine APIs, the return value — goes ininit.luau. The toolbox'sshared.module/is for genuinely cross-tool helpers (ok/failconstructors, shared parsers / validators), not per-tool logic. Do NOT makeinit.luaua one-line forwarder onto a function defined inshared.module/. - Use
shared.module/in the parent toolbox forok/failresult constructors. Don't hand-roll the envelope per tool. Pull it in withlocal shared = require(".shared")from any tool that needs the helpers. - Annotate every public function with
--!desc/--!arg/--!return/--!exampledirectly above the function — the LSP and toolbox surfaces extract these. - Declare every exported function
typed function M.name(arg: T, opt: U?): R. This is not conditional on the signature being "clear" — a loose argument is the case that needs the checked contract most. Give the argument the type it actually accepts (a union, a named table type, a literal enum) instead of dropping to an uncheckedfunction. - Keep tools focused. One tool, one named workflow. Split rather than grow.
- Tag tools in the sibling
.metadata({ "tags": [...], "tier": "core|content|specialized" }) sosearch_toolscan find them by domain (e.g.entity,scene,assets,physics).
The typed signature is the schema
The typed keyword compiles the function into a dispatch wrapper
that validates arguments at call time. Without it there is no check
at all, so the tool runs its body on whatever arrived and returns a
success envelope built from garbage. Type each argument as narrowly
as the operation actually accepts:
export type Mode = "edit" | "play"
export type SpawnOpts = {
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: SpawnOpts?): { EntityRef }
- A fixed set of accepted words is a literal union, never
string.("cube" | "sphere")rejects"Cube"at the call and lists the accepted values in the error;kind: stringaccepts"banana"and fails somewhere deeper, or not at all. - A structured argument is a named
export type, never{ [string]: any }. Naming it puts every field and its type on the tool's page and checks them on entry. - A named type is only as strong as its fields.
opts: DrawOptswhereDrawOpts = { value: any }passes for a table and checks nothing inside it — the same hole, one level down. The argument checker walks nested fields, so type each one; a type name is not a place to put the work off. - A dispatch table keyed by an
opstring is several tools.{ op: string, [string]: any }is one unchecked entry point wearing N signatures — split it so each operation carries its own checked arguments. - An entity or asset argument is
EntityRef/AssetRef<kind>, neveranyand never a bare name string. anyis correct only for a value the tool genuinely does not constrain — a passthrough payload, arbitrary user data. It is not a placeholder for a type not yet written down.
Common pitfalls
- A plain
functionexport. The tool registers and dispatches, so nothing looks wrong — it just never rejects bad input, and every call reports success. Declare ittyped function. - Annotations without
typed.function M.f(x: string)reads as typed and checks nothing at runtime; the annotation only informs the LSP. The keyword is what enforces it. - Wrong return envelope. Returning a raw value instead of
{ exitCode = 0, ... }will be flagged by callers expectingZmToolResult. - Tool identity. The fully-qualified identity is
<toolbox>.<name>, NOT just<name>. Toolbox-less tools don't exist by design. - Missing dependency. Tools requiring another package / service must declare it; otherwise the call fails at runtime with a missing-module error.
Related types
.toolbox— the required parent namespace for tools..module— for Luau libraries with no agent-facing schema..service— for stateful background work, not callable request/response operations.