Log inGet started

Service (asset type)

Updated 6 September 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 and asset_path 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.

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.
  • 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 and is taken verbatim. 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.
  • asset-type
  • reference