Log inGet started

Tool (asset type)

Updated 5 September 2026

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 (or init.lua) — exports the function, and carries its schema in --!desc / --!arg / --!return / --!example annotations 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

  1. Registration. Writing init.luau into a .tool/ folder registers the tool with the tools registry. It becomes callable as tools.use("<toolbox>", "<name>", ...) from Luau, use_tool { toolbox, tool, args } over MCP, and zero <toolbox> <name> [args] from the engine shell.
  2. Schema validation. The typed function signature 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.
  3. Return envelope. Tools MUST return ZmToolResult:
    { exitCode = 0|n, data? = any, stdout? = string, message? = string }
    
    exitCode = 0 means success; non-zero means failure, with message carrying the human-readable reason.
  4. Discovery. The tool surfaces in tools.list(), search_tools { query: "..." }, and asset.inspect's exposes: section (parsed from the typed signature and the --! annotations).
  5. Hot reload. Editing init.luau re-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 in init.luau. The toolbox's shared.module/ is for genuinely cross-tool helpers (ok / fail constructors, shared parsers / validators), not per-tool logic. Do NOT make init.luau a one-line forwarder onto a function defined in shared.module/.
  • Use shared.module/ in the parent toolbox for ok / fail result constructors. Don't hand-roll the envelope per tool. Pull it in with local shared = require(".shared") from any tool that needs the helpers.
  • Annotate every public function with --!desc / --!arg / --!return / --!example directly 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 unchecked function.
  • Keep tools focused. One tool, one named workflow. Split rather than grow.
  • Tag tools in the sibling .metadata ({ "tags": [...], "tier": "core|content|specialized" }) so search_tools can 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: string accepts "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: DrawOpts where DrawOpts = { 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 op string 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>, never any and never a bare name string.
  • any is 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 function export. The tool registers and dispatches, so nothing looks wrong — it just never rejects bad input, and every call reports success. Declare it typed 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 expecting ZmToolResult.
  • 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.
  • .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.
  • asset-type
  • reference