Log inGet started

Service (asset type)

Updated October 13, 2026

A <name>.service/ is a declaration, not code. The asset type ships the whole runtime — the metered invoke, the submit → poll → download → write pipeline, the async generation handle, error handling — in its shared.module/. An instance only declares its surface: which provider offering it meters, and the operations it offers. There is no HTTP, polling, credit, or secret code in a service.

Using a service

Every service is used the same way, whatever it generates:

local mesh = asset.resolve("mesh_gen", "service")

mesh:operations()   -- callable operations, each with its inputs + credit cost
mesh:cost()         -- credits the next run costs (estimate)
mesh:balance()      -- credits you have

local g = mesh:invoke({ prompt = "a wooden treasure chest" })
--> { id, task, asset_path, operation }   -- async: the run is still going

local row = tools.use("services", "status", { id = g.id })   -- poll until it settles
-- once row.status == "completed", row.asset is the spawnable asset

:invoke returns immediately with a handle; the run finishes in the background. The services toolbox (status, jobs) is the agent-facing way to track a run to completion, and a completed row's asset is the asset to spawn — see the generating-assets-and-content guide. The handle's asset_path is where the run directs the raw generated file, which the importer turns into the asset that row names.

A run outlives the engine that started it. The gateway keeps the job and its result for hours after the charge, and the run keeps its own record in the world — what it was asked for, the job doing the work, and the step it had reached — so when the world next loads, every run that did not finish is picked up where it left off and lands its asset the way an uninterrupted run does. Nothing is submitted, or paid for, a second time, and nothing is asked of the caller.

A service with several operations selects via input.operation (mesh:invoke({ operation = "from_image", image = "/zero/source/.../ref.png" })); a single-operation service just takes its input.

Authoring one

-- <name>.service/init.luau
local Service = asset.containing(__FILE__).modules.shared

return Service.define({
    name = "<name>",
    description = "Generate <thing> from a text prompt.",
    offering = "<provider>/<name>",      -- the offering ZeroMind meters
    output = { kind = "mesh", ext = "glb", dir = "/zero/source/generated/meshes" },
    operations = {
        generate = {
            description = "What this makes, agent-facing.",
            input = { prompt = { type = "string", required = true } },
            cost = 5,
            steps = { … },                -- the pipeline (see below)
            result = { url = "$url" },     -- the framework downloads + writes it
        },
    },
})

An operation takes exactly the fields it declares in input, plus the operation, asset_path and durable the framework itself reads. A call carrying any other field is refused by name — ahead of the credit pre-flight and ahead of any endpoint — with the fields the operation does take named in the message, so a field the steps have no $ref for costs a message rather than a run. An operation that declares no input states nothing about its surface and takes any field.

An input declaring enum = { "2k", "4k", "8k" } accepts those values and no others, and the set is published on the input so a reader learns it from the listing rather than from a refusal. A value outside it is refused with the same timing as an undeclared field — before the credit pre-flight, before any endpoint. That timing is the point where a pipeline spends per step: a value the last step's upstream rejects is one the earlier steps were already charged for.

An input declaring dimensions takes an image size as a WIDTHxHEIGHT string in pixels, inside every bound the declaration states: multipleOf (each edge), maxEdge, minPixels / maxPixels (width times height) and maxAspect (the long edge over the short one). The words also lists (also = { "auto" }) are accepted beside a size. It is refused with the same timing for any other value, and the refusal names every bound, so a size the provider would refuse costs a message:

size = {
    type = "string",
    dimensions = { multipleOf = 16, maxEdge = 3840, minPixels = 655360, also = { "auto" } },
}

A set may hold strings or numbers (enum = { 24, 30, 60 }), and a value matches only a member equal to it: the number 30 and the string '30' are different values, and the refusal quotes strings and leaves numbers bare so it reads that way. Whenever the provider honours a fixed set of values for an input, the set is its enum and the input's desc says what the choice does to the result, so the values a reader learns and the values the call accepts are one list.

An input declaring min / max accepts numbers inside that span and is refused with the same timing for a figure outside it. Either bound stands on its own: min alone is a floor, max alone a ceiling. Both are published on the input beside enum and default. A span an upstream honours belongs here for the same reason a value set does: a figure the pass that reads it refuses is one every earlier pass has already been paid for.

Declaring the pipeline

steps is an ordered list of metered calls. Each step:

  • call — the offering's logical endpoint name.
  • body / params / headers — templated. "$name" injects an input or a prior step's bound value; "${name}" interpolates inside a string (a missing value drops the key); "$name?" is optional. { fromFile = "$path", dataUri = "image/png" } sends a local asset; { firstOf = { a, b } } uses the first present value; { scale = "$n", by = 1000 } multiplies a number; { truncate = "$text", to = 64 } clamps a string to at most that many characters, for an upstream that caps a field's length; { whenSet = "$n", use = true } sends use when $n was named and drops the key when it wasn't, for an upstream that reads one field only while a companion flag is on — naming such a field alone is otherwise accepted, charged for, and ignored.
  • poll — when the call submits a job: { endpoint, param } plus optional idFrom / statusFrom / done / fail / doneWhen / failWhen / progressFrom overrides (sensible defaults cover the common providers).
  • bind / bindFrom — name the value later steps reference; bindFrom extracts it (a dotted path, a fallback list { "a", "b" }, or an array-find { find = "assets", where = { type = "x" }, get = "url" }). For a polled step the default bind is the submitted job id.

result is either { url = "$bound" } (download the URL and write it) or { bytes = "$bound" } (write bytes a step returned directly).

output sets where the asset lands (<dir>/<safe(prompt)>.<ext>); a caller's asset_path overrides it. The run writes the file at that path and imports it like any other, so the completed row names the asset the importer produced. A path ending in an asset type's extension (clips/door.soundClip) names that asset: the file is written beside it under output.ext (clips/door.mp3) and imported into it. Two destinations keep the written file as the result: one inside another asset's folder, which is that asset's own content (a .package holds assets of their own, so a file inside one imports as usual), and a file no importer claims. The derived name is a truncation of the request, so several runs can derive one name — the same line under two voices, two prompts that open on a shared phrase. The framework hands each run a name no other run holds and no file in <dir> already spends, appending _2, _3, … when it must, and the handle's asset_path is the name that run will write. So a set of related generations can run at once and each result is readable at its own path. The written file is then imported, and the name is derived by the rule the importer names an asset with, so the name a run reserves is the name its finished asset carries.

Read mesh_gen as the worked template: asset.resolve("mesh_gen","service"):getDefinition().

Discovery

  • asset.list("service") — every registered service.
  • asset.resolve("<name>","service"):info() — offering, output, operations.
  • asset.resolve("<name>","service"):operations() — each operation's inputs + cost.
  • .module — reusable library code you require and call.
  • .tool — an agent-callable function that composes multiple engine steps.
  • .component — per-entity behaviour.

In the Inspector

Service: what the service does and each operation it offers, with its credit cost and inputs, parsed from its declaration without running it. Invoking an operation spends credits, so it is not offered here.

  • asset-type
  • reference