Service (asset type)
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 }sendsusewhen$nwas 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 optionalidFrom/statusFrom/done/fail/doneWhen/failWhen/progressFromoverrides (sensible defaults cover the common providers).bind/bindFrom— name the value later steps reference;bindFromextracts 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.
Related types
.module— reusable library code yourequireand 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.