Skip to content

Lua Scripting API Guide ​

CoCoCo embeds SwiftLua, a capability-secure Lua subset written in pure Swift. Scripts reach the platform through namespaced functions on the global ctx table.

This guide has three parts: the language, forwarded verbatim from the engine we link (dialect 1:9bee6e9f0e3a36b0); the platform model — roles, handler shape, worked samples; and a per-role reference generated from the live checked surface. Signatures in the reference are never hand-written, and neither is the language section.

The (ok, res) convention — read this first ​

Stated in full in the language guide below, repeated here because it is the single most common mistake against this platform. Every function that reaches a service is async, and every async call returns two values:

lua
local ok, res = ctx.graphql.query("{ ping }", {})
if not ok then error(res) end   -- res is an error message string

Binding a single value binds the boolean, not the data — and both wrong forms type-check unless you go on to index the result, so the compiler will not always save you.

The SwiftLua dialect ​

A guide to the Lua subset you are writing. SwiftLua is not stock Lua: it is a capability-secure, deterministic dialect that runs inside a host application. Code that works in lua5.4 may not compile here, and — more dangerously — a few constructs compile and behave differently. This document is the complete account of that difference.

Written for an LLM authoring scripts, and readable by the humans reviewing them. Every Lua example below is executed against the real engine as part of its test suite, so nothing here can quietly go out of date.

How to read this ​

If you take four things from this document, take these:

  1. Reading an undefined global throws. It does not yield nil. Typos fail loudly.
  2. There are no iterators, no coroutines, no metatable operators, and no io/os. If you reach for one, there is a listed alternative — or the thing genuinely cannot be done, in which case say so rather than emitting code that will not run.
  3. Multiple returns are bounded. They do not expand in argument lists or table constructors. See §5; this is the divergence that most often produces silently wrong results rather than an error.
  4. Only what §8 lists exists. The standard library is a closed set. select, next, string.gmatch and table.sort's in-place form are all absent, and bit32 is opt-in. Do not assume a stock-Lua name is present because it is standard elsewhere.

1. Values and types ​

nil, boolean, integer (64-bit, checked), number (64-bit float), string (immutable Unicode text), table, function. There is no userdata, no coroutine, and no script-visible metatable mechanism beyond a bounded __index (§6).

Integers and floats are distinct. Integer literals stay integers. Arithmetic between integers stays integral, except / and ^, which are always float.

lua
return 7 // 2
lua
return 7 / 2

Integer overflow throws — it never wraps. This is a deliberate divergence: silent wraparound is a bug source a sandbox should not tolerate.

lua
return math.maxinteger + 1

// floors toward negative infinity and % takes the sign of the divisor, as in Lua. Integer // or % by zero throws; float division by zero follows IEEE.

lua
return -7 // 2
lua
return -7 % 2

Strings are Unicode text, indexed by UTF-8 byte. #s, string.sub and string.byte work on bytes; an operation that would split a character mid-sequence throws rather than returning mojibake. .. concatenates and coerces integers and numbers; anything else throws.

lua
return "n=" .. 42
lua
return "flag=" .. true

Truthiness: only nil and false are false. 0 and "" are true. and/or return their operands and short-circuit.

lua
return 0 and "zero is true" or "zero is false"

Equality crosses numeric representations and never throws across types. Ordering requires two numbers or two strings — anything else throws, including nil <= nil.

lua
return tostring(1 == 1.0)
lua
return tostring(1 == "1")
lua
return nil <= nil

2. Variables and scope ​

lua
local x = 1
local a, b = 2, 3
x = x + a + b
return x

Scoping is position-sensitive exactly as in Lua: in local a = a + 1 the right-hand side sees the outer a.

Reading an undefined global throws unknownGlobal. Stock Lua yields nil; SwiftLua treats it as the bug it almost always is.

lua
return somethingNobodyDefined

In the default strict checking mode, creating a global is also a check-time error. Declare locals.

3. Statements ​

FormNotes
if … then … elseif … else … endas in Lua
while cond do … endas in Lua
repeat … until condbody runs at least once; the condition sees the body's locals
for i = a, b [, step] do … endinclusive bounds; step 0 throws; bounds must be numbers
for k, v in ipairs(t) do … endarray part in order — syntactic form only (§6)
for k, v in pairs(t) do … endwhole table, defined order (§6)
breakinnermost loop; outside a loop it is a parse error
do … endexplicit scope block
local function f(…) … endvisible to its own body, so recursion works
function f(…) … endglobal function declaration

There is no goto, no continue, and no compound assignment (+=).

Numeric for promotes to float if any of start/limit/step is a float. The integer loop runs only when all three bounds are integers.

lua
local out = {}
for i = 1, 3.0 do out[#out + 1] = tostring(i) end
return string.join(" ", out)

Assigning the loop variable inside the body does not affect iteration — the visible variable is a per-iteration copy.

lua
local count = 0
for i = 1, 3 do i = 100; count = count + 1 end
return count

4. Functions ​

Functions are first-class with full lexical closures. Extra arguments are dropped; missing parameters are nil.

lua
local function add(a, b) return a + b end
return add(3, 4)

Script functions are never variadic. ... is a parse error inside any function; only the main chunk may use it, bound to the arguments the host passed.

function t.m() end and function t:m() end are sugar for field assignment; the : form prepends an implicit self. Method-call sugar receiver:name(args) works without metatables.

lua
local greeter = {greeting = "hello"}
function greeter:say(name) return self.greeting .. ", " .. name end
return greeter:say("world")

5. Multiple returns — the bounded subset ​

This is the divergence most likely to produce wrong output rather than an error. Multiple returns exist, but in a bounded form chosen so every call site has a statically predictable shape. The rules, completely:

Rule 1 — a lone bare-call return forwards. return f(args) as the sole return expression passes all of f's results through, bounded by what the caller wants.

lua
local function two() return 1, 2 end
local function via() return two() end
local a, b = via()
return tostring(a) .. ":" .. tostring(b)

Rule 2 — a multi-value return list does not forward. With two or more expressions, every expression contributes exactly one value, and a trailing call is truncated. Destructure first if you need to forward.

lua
local function two() return 1, 2 end
local function via() return 0, two() end
local a, b, c = via()
return tostring(a) .. ":" .. tostring(b) .. ":" .. tostring(c)

Rule 3 — a call expands only in the last position of a local declaration or assignment value list, padding with nil and truncating extras.

lua
local function divmod(a, b) return a // b, a % b end
local a, b, c = divmod(9, 2)
return tostring(a) .. ":" .. tostring(b) .. ":" .. tostring(c)

A call in a mid-list position adjusts to one value.

lua
local function divmod(a, b) return a // b, a % b end
local x, y = divmod(9, 2), 7
return tostring(x) .. ":" .. tostring(y)

Rule 4 — there is NO expansion inside argument lists or table constructors. g(f()) passes one value. {f()} has one element. This is where stock-Lua habits silently break: the second value becomes nil and nothing raises.

lua
local function two() return 1, 2 end
local function count(a, b) return b == nil and "one arg" or "two args" end
return count(two())
lua
local function two() return 1, 2 end
return #{two()}

To pass both, bind to locals first:

lua
local function two() return 1, 2 end
local function count(a, b) return b == nil and "one arg" or "two args" end
local a, b = two()
return count(a, b)

Rule 5 — the host boundary takes one value. The value the host receives, and a module's export, is a single value: the first, if a list.

6. Tables ​

One table type with two parts: a dense array part (keys 1…n, where #t == n) and a map part for everything else. Float keys holding integral values unify with integer keys; nil and NaN keys throw.

lua
local t = {10, 20, 30, name = "x"}
return #t

Hole semantics are defined (Lua leaves them unspecified): writing nil at the end pops; writing nil mid-array truncates there and spills the tail to the map part; writing at #t + 1 appends.

Iteration is defined, deterministic, and syntactic. Only ipairs(expr) and pairs(expr) written directly in a generic for exist. You cannot pass an iterator around, store one, or write your own — there are no first-class iterators in this dialect.

  • ipairs(t) walks the array part in index order. The trip count is snapshotted at loop entry — mutating the array mid-loop does not change how many iterations run. Stock Lua is live here.
  • pairs(t) walks the array part in index order, then the map part in insertion order. Lua leaves this unspecified; SwiftLua's order never varies between runs or platforms.
lua
local t = {1, 2, 3}
t.alpha = "a"
t.beta = "b"
local out = {}
for k, v in pairs(t) do out[#out + 1] = tostring(k) end
return string.join(",", out)
lua
local t = {1, 2, 3}
local seen = 0
for _, v in ipairs(t) do seen = seen + 1; t[#t + 1] = v end
return seen

Bounded metatables. setmetatable/getmetatable exist and honour exactly one metamethod: __index whose value is a table. Reads that miss follow the chain, depth-bounded. Writes always go to the table itself. A function-valued __index throws. __newindex, __call, __eq and every operator metamethod do not exist — a script cannot overload operators. This is exactly enough for the class pattern:

lua
local Lib = {}
Lib.__index = Lib
function Lib.new(setup)
  local self = setmetatable({}, Lib)
  self.setup = setup
  return self
end
function Lib:describe() return self.setup end
return Lib.new("configured"):describe()

Sealed nominal types are a separate mechanism: the host may declare a type whose operators it defines (Decimal is the shipped one). That is not metatables and not script-declarable, and a look-alike table is not one.

Needs the optional extras library, which the standard roles do not load — check the surface first.

lua
local price, qty, shipping = decimal.new("19.99"), 3, decimal.new("4.95")
return tostring(price * qty + shipping)

7. Type annotations and checking ​

Scripts are type-checked before they run. A pragma on the first line sets the mode: --!strict (default), --!nonstrict (boundary findings become warnings — but an unknown field on a record whose fields are fully known stays an error in every mode, because the checker knows that field cannot exist), --!nocheck, or --!strict-nil (array and map reads type as optional — guard or default them).

Locals, parameters and return types accept annotations — a SwiftLua extension to Lua syntax:

lua
local function apply(amount: number, rate: number): number
  return amount * rate
end
local total: number = apply(100, 0.2)
return total

The annotation grammar is Name, T?, {T} (array), {[K]: V} (map), A | B (union), (T, …) -> R (function), (T) for grouping, and literal terms ("active", 42, true, nil). | and -> exist only inside annotations — they are not operators.

Name your own shapes with type, which is file-scoped and compiles to nothing:

lua
type Line = {sku: string, qty: number}
local function sku(line: Line): string return line.sku end
return sku({sku = "widget", qty = 2})

A record may omit its optional fields — an absent key is nil, and writing k = nil stores nothing, so the two are the same thing:

lua
type Message = {subject: string, body: string, recipient: string?}
local function send(m: Message): string return m.subject end
return send({subject = "ok", body = "b"}) == "ok" and "ok" or "no"

expr :: T casts — it asserts a type the checker cannot see and evaluates to its inner expression at run time. A cast is not a call form, so f() :: T in last position adjusts to one value and does not expand.

--!doc <text> documents intent for readers and tooling; it has no runtime effect.

8. The standard library ​

Only what is listed here exists, and every library table is frozen — assigning to one throws. Everything is deterministic: no locale, no clock and no randomness except through an explicit host capability.

Basic: assert(v [, message]), error(message), type(v), tostring(v), tonumber(v [, base]), inspect(v), pcall(f, …), setmetatable/getmetatable/rawget/rawset.

tonumber parses a numeric string, passes a number through, and returns nil for anything else — it never throws, so it is safe to use as a guard on untrusted input. Leading zeros are accepted, and surrounding whitespace is tolerated.

lua
return tonumber("42")
lua
return tonumber("08")
lua
return tonumber("not a number") or "fallback"

An optional base of 2–36 parses an integer string in that base; a base outside that range throws.

lua
return tonumber("ff", 16)

Note that arithmetic still refuses to coerce a string — tonumber is the sanctioned way across, and the strictness below stays.

inspect(v) renders structure for debugging with an exact, platform-stable format — array part first, then map entries in pairs order, nesting bounded, cycles marked.

lua
return inspect({1, 2, key = "value"})

print exists only when the host grants a log sink. If it is absent, the name does not exist at all.

math: floor, ceil, round (half away from zero), trunc, abs, min, max, sqrt, clamp(x, lo, hi), sign(x), lerp(a, b, t), approx(a, b, eps), isinteger(x); constants pi, huge, maxinteger, mininteger. round(x, places) takes 0–12 places. There is no math.random — randomness is not deterministic.

lua
return math.round(2.5)
lua
return math.clamp(12, 1, 5)

string (byte-based): len, upper, lower, sub, rep, find, byte, char, format, split, join, isempty, lines, trim/ltrim/rtrim, startswith/endswith/contains, replace (plain text, never patterns), padleft/padright, reverse, plus the pattern functions in §9.

lua
return string.join("|", string.split("a,b,c", ","))
lua
return string.format("%6.2f", 7.5)

string.format supports exactly %d %i %f %g %s %x %% with -/0 flags and width/precision up to 99; anything else throws. string.char and string.format take at most 4 value arguments, because script functions cannot be variadic.

table — queries and slices: insert(t, v), remove(t), concat(t [, sep]), keys/values/items, indexof, contains, has, size (total entries — #t counts only the array part), isempty, slice, reverse, take/drop, append, clear, flatten.

Both insert and remove take an optional position, as in Lua: table.insert(t, value) appends, table.insert(t, pos, value) shifts up, table.remove(t) pops, and table.remove(t, pos) shifts down and returns the removed element. A position outside 1…#t+1 (insert) or 1…#t (remove) throws a catchable invalidArgument rather than corrupting the array part.

lua
local t = {"a", "c"}
table.insert(t, 2, "b")
return string.join(",", t)
lua
local t = {"a", "b", "c"}
return table.remove(t, 2)
lua
local t = {1, 2, 3}
table.insert(t, 9, "far")
return #t

table — copying and merging: clone (shallow), deepcopy (cycles preserved), merge(a, b) (new table, b wins), extend(dst, src), defaults(t, d).

table — callback-takers operate on the array part in order and receive (value, index): map/collect, filter, find/findindex, reduce(t, f, initial), each, any/all, count, partition, group_by, tomap, unique/unique_by, sort(t [, comparator]) and sort_by.

lua
local doubled = table.map({1, 2, 3}, function(v) return v * 2 end)
return string.join(",", doubled)

A callback-taker passes (value, index). A script function may declare fewer parameters and the extras are dropped — but a host function has a declared arity, so passing one directly throws invalidArgument. Wrap it: function(v) return tostring(v) end, not tostring.

lua
return table.map({1, 2}, tostring)[1]
lua
return table.reduce({1, 2, 3}, function(acc, v) return acc + v end, 0)

table.sort returns a NEW array and never mutates, unlike Lua's in-place table.sort. It is stable and deterministic. The default order requires all numbers or all strings — mixed types throw.

lua
return string.join(",", table.sort({3, 1, 2}))

set: new(list), has, add/remove (mutate and return the set), union, intersection, difference, tolist (first-insertion order). Sets are plain tables mapping member → true.

Convenience globals: default(v, fallback) (fallback only when v is nil, so false passes through), coalesce(a [, …d]), pipe(x, f1 [, …f4]), assertf, typecheck.

lua
return tostring(default(false, true))

time and uuid ship in the standard bundle, but their non-deterministic entry points are capability-gated: time.now() exists only when the host grants a clock, and uuid.v4/uuid.v7 only when it grants entropy. The pure functions are always present.

lua
return time.new({year = 2026, month = 1, day = 15}):format("%Y-%m-%d")

Opt-in extras — present only if the host adds Library.extras(): json, base64, hex, url, csv, decimal, stats, template, bit32, hash, tpath, validate, text. Do not assume they exist; check the host surface section of the document you were given.

bit32 — unsigned 32-bit bitwise operations with Lua 5.2 semantics: band, bor, bxor (up to four operands each), bnot, lshift, rshift, arshift, lrotate, rrotate, extract, replace. Operands are taken modulo 2^32 and results land in 0…2^32-1. A shift of 32 or more yields 0 rather than throwing; arshift propagates the sign bit.

Needs the optional extras library, which the standard roles do not load — check the surface first.

lua
return bit32.band(255, 15)

A shift of 32 or more is 0 rather than an error, and arshift propagates bit 31 — the two boundaries hand-rolled arithmetic shims usually get wrong:

Needs the optional extras library, which the standard roles do not load — check the surface first.

lua
return bit32.lshift(1, 32)

Needs the optional extras library, which the standard roles do not load — check the surface first.

lua
return bit32.arshift(4294967295, 31)

A seeded, fully deterministic PRNG — the case math.random's absence otherwise blocks:

Needs the optional extras library, which the standard roles do not load — check the surface first.

lua
local function xorshift32(x)
  x = bit32.bxor(x, bit32.lshift(x, 13))
  x = bit32.bxor(x, bit32.rshift(x, 17))
  return bit32.bxor(x, bit32.lshift(x, 5))
end
return xorshift32(1)

Needs the optional extras library, which the standard roles do not load — check the surface first.

lua
return json.encode({ok = true})

Not present — do not reach for these ​

select, next, rawequal, xpcall, load/loadstring/dofile, collectgarbage, _G, getfenv/setfenv, require (unless granted), print (unless granted), math.random, string.gmatch, io, os, debug, package, coroutine, goto, and first-class iterators of any kind.

The bitwise operators & | ~ << >> do not exist — they are lex or parse errors, not runtime ones. Bitwise work goes through the opt-in bit32 library instead (below).

There is no string→number coercion in arithmetic — use tonumber:

lua
return "42" + 0
lua
return tonumber("42") + 0

9. String patterns ​

SwiftLua supports the full Lua 5.4 pattern language — classes (%a %d %s %w %l %u %p %c %x and uppercase complements), ., sets, quantifiers * + - ?, anchors ^ $, captures including position captures, %1–%9 back-references, %b and %f. The matcher is metered: a backtracking bomb raises the uncatchablepatternComplexityExceeded.

The function names and shapes differ from stock Lua, because the dialect has no iterators:

FunctionReturns
find(s, pat [, init [, plain]])(start, end) or nil; plain = true forces literal search
match(s, pat [, init])the whole matched substring, or nil
captures(s, pat [, init])array of capture groups, or [wholeMatch] if the pattern has none, or nil
matchall(s, pat)array of every match — the replacement for gmatch
gsub(s, pat, repl [, n])(result, count)

string.match returns the whole match even when the pattern has captures. It does not return the capture groups. This differs from stock Lua and fails silently — you get plausible-looking wrong data. Use string.captures when you want the groups.

lua
return string.match("batt=55%", "batt=(%d+)")
lua
return string.captures("batt=55%", "batt=(%d+)")[1]
lua
return string.join(",", string.matchall("a1b2c", "%a"))
lua
return (string.gsub("x y", " ", "-"))

Note the parentheses in that last example: gsub returns two values, and a bare call in a return list forwards both (§5, rule 1). Wrapping it is how you keep one — or destructure and return the piece you want.

10. Errors and budgets ​

error("message") and failed assertions raise script errors, as does any runtime violation. An uncaught error ends the run and hands the host a structured error with a script stack trace.

pcall(f, …) calls f protected, returning true, result or false, message:

lua
local ok, err = pcall(function() error("boom") end)
return tostring(ok) .. ":" .. err

The safety rule that keeps pcall sandbox-compatible:

  • Catchable: error/assert, and value errors — typeMismatch, unknownGlobal, notCallable, invalidArgument, arithmeticOverflow, moduleNotFound, and the like.
  • Uncatchable: resource exhaustion and host cancellation propagate through pcall — instructionLimitExceeded, callDepthExceeded, memoryLimitExceeded, tableLimitExceeded, stringLimitExceeded, timeoutExceeded, logLimitExceeded, hostResourceLimitExceeded, cancelledByHost. A script can never outlive its budgets by catching them.

There is no xpcall.

Every run has host-set budgets: instructions, call depth, memory, per-table entries, per-string bytes, log bytes, module loads, and host resources. Exhausting one throws at a deterministic point — the same script with the same input fails in the same place every time, on every platform. Write scripts that do bounded work; unbounded loops over host data will be cut off, not slowed down.

11. Modules ​

When the host grants a loader, require loads host-approved code. There is no filesystem, no search path, and no way to build code from a string.

Illustrative only — needs a host-granted module loader.

lua
local tax = require("tax")
return tax.apply(total)

Modules memoize per run, share the run's globals, and run under your budgets. Cycles throw moduleError; unknown names throw moduleNotFound. require is a one-argument call form only — storing it in a variable is not supported. When the name is a literal, the checker infers the module's exported shape, so a typo'd field is caught.

12. The host's surface ​

Everything above is the dialect and is true for every SwiftLua host. What differs per host is the set of values, functions and types it grants — inputs, host functions, host objects, capability-gated libraries. When this guide is delivered with a rendered type surface appended, that section is the authoritative list of what is available to you in that environment. Treat a name absent from both this document and that section as unavailable.

Host functions may be async, which is transparent: a call looks ordinary and the engine suspends the run around it. Async functions and methods return a uniform (ok, res) tuple.

13. Divergences from stock Lua, consolidated ​

Each is deliberate, tested, and here to stay. Do not "fix" code toward stock-Lua behaviour.

  1. Undefined globals throw instead of reading nil.
  2. Integer overflow throws instead of wrapping.
  3. Multiple returns are bounded (§5): no expansion in argument lists or table constructors; forwarding only from a lone bare-call return.
  4. ipairs snapshots its trip count; pairs order is defined (array part, then map insertion order) and traversal is live and position-based.
  5. ipairs/pairs are syntactic — generic for accepts nothing else, and there are no first-class iterators. next is not granted for this reason: it is Lua's iterator primitive, not an ambient-authority risk. Use table.isempty(t) to test emptiness.
  6. Metatables are bounded to a table-valued __index — no operator overloading, no __call, no __newindex.
  7. No dynamic code from strings; require exists only when the host grants a loader.
  8. Strings are byte-based but split-safe — an operation that would split a UTF-8 sequence throws.
  9. Table holes are defined, and library tables are frozen.
  10. string.match returns the whole match; captures come from string.captures, and gmatch is matchall, returning an array.
  11. table.sort returns a new array and never mutates.
  12. No string→number coercion in arithmetic — convert explicitly with tonumber.
  13. No bitwise operators (& | ~ << >>); the opt-in bit32 library covers the work.
  14. Script functions are never variadic; only the main chunk has ....
  15. Everything is deterministic unless the host grants a clock or entropy.

14. Anti-patterns ​

Do not do these — each one either fails to compile, throws, or silently misbehaves.

  • Assuming a nil global. if someGlobal then throws if someGlobal was never defined. Use a local with a default, or ask the host to grant the name.
  • local ok = pcall(f) for resource errors. Budget errors are uncatchable by design; pcall will not save a runaway loop.
  • {f()} or g(f()) to move multiple values. Both take one value. Destructure first.
  • string.match with a capture group when you want the group. Use string.captures.
  • Reaching for gmatch, select, next, or math.random. None exist. §8 lists the alternatives where there is one.
  • Writing a & b or a << n. The bitwise operators are not in the grammar; use the opt-in bit32 library, and say so if the host has not granted it.
  • Writing to a library table (string.trim = …). They are frozen; it throws.
  • In-place sorting. table.sort(t) does not mutate t; use its return value.
  • Emitting a shim for a missing feature and hoping. If the dialect genuinely cannot do what was asked — coroutines, filesystem access, a library the host did not grant — say so plainly instead of producing code that will not run.

Platform model ​

Which calls are (ok, res) ​

The dialect guide explains the async (ok, res) tuple. What is specific to this platform is which functions are async: everything that reaches a service. The reference below marks every function async or sync, and the luaApiSurface GraphQL query carries the same flag as isAsync.

Pure functions — ctx.json.*, ctx.time.*, ctx.log.*, ctx.archive.last_checkpoint — are synchronous and return a single value. ctx.script.load is also single-valued: it is a module loader, and a missing Script raises a catchable error rather than returning (false, message).

Execution roles ​

RoleUse caseSurface
SCRIPTtenant Scripts, playground, workflow overlays, webhooks, plugin seedsbase ctx.*
CUSTOM_APPcustom app RPC handlersbase + ctx.dataContainer
INTEGRATIONintegration RPC handlers and hooksbase (minus ctx.config/ctx.protobuf) + ctx.integration.*, with devices, templates and loadable scripts all locked to manifest resource bindings
ARCHIVEarchive job scriptsbase + ctx.archive.last_checkpoint
TEMPLATE_RESOLVERproduct-template resolver scriptsstdlib + ctx.time/ctx.json/ctx.log + ctx.script.load only
EDGE_APPedge bridge bundle handlersbridge.* — gopher-lua on the bridge, no ctx at all

Entry points: evaluated vs dispatched ​

A role says which ctx.* you may call. It does NOT say how your script is entered, and the two shapes are not interchangeable.

Evaluated — the chunk runs and its return value is the result. Playground, workflow overlays, template resolvers, archive jobs.

Dispatched — the chunk runs once as a setup pass to populate a global exports table, then the host calls one export. Write no top-level return: it is discarded.

SurfaceExport
Incoming webhookexports.handler(request) — returns {status, headers, body}
Custom app (v2)exports.<method>(...)
Integration (v2)no exports — each timer, subscription and lifecycle hook is an inline source on the manifest, arguments as varargs (local name, config = ...); exports.on_timer / exports.on_event are v1 only
lua
-- an incoming webhook
exports.handler = function(request)
  return { status = 200, body = { ok = true, method = request.method } }
end

On a dispatched surface keep the setup pass cheap — constants and function definitions. It runs once per invocation before your export and shares the same budget, so work placed there is charged to every request.

Budgets granted here ​

The capability model is described in the language guide above. What this platform sets are the limits: every run is bounded by instruction, memory, table, string, call-depth and wall-clock budgets (LUA_MAX_EXECUTION_MS, default 50; LUA_MAX_MEMORY_MB, default 50).

A clock and randomness ARE granted to every service-backed role: time.now() and uuid.v4() / uuid.v7() all work. What is absent is the ambient stdlib form - os.time(), os.date(), math.random - for the reason the language guide gives: those are capabilities, and a capability has to be granted by name rather than assumed.

TEMPLATE_RESOLVER is the exception, and it is not that its clock is frozen: it is granted no Clock and no Entropy at all, so time.now() and uuid.v4() / uuid.v7() do not exist there. Use ctx.time.*, which a resolver does get.

Handler shape ​

Custom apps and integrations expose handlers on a global exports table, and the handler's return value is the RPC result:

lua
exports = {}

function exports.getStatus(input)
    return { status = "success", count = 42 }
end

ctx.rpc.respond(...) exists only for grandfathered v1 custom apps. Every other path — workflow actions, plugin seeds, integration RPC, script-actor hooks, template resolvers, the playground — returns values from the chunk or handler.

Name-or-ID resolution ​

ctx.device.* accepts a TypedID (dev_01ABC…) or a device identifier ("my-printer"). ctx.notify.send accepts a group id (ngr_…), a recipient id (nrc_…), or a group name. Name lookups go through GraphQL, so tenant isolation and authorization are enforced on the way.

Worked examples ​

Do not write from the signatures below alone — fetch runnable, tested samples:

graphql
query { listLuaSamples(role: SCRIPT, tier: RECIPE) { id title description apis } }
graphql
query { getLuaSample(id: "graphql-list-customers") { source } }

Every sample in that catalog is type-checked against this surface and executed by tests with its return value asserted, so its call shapes are correct by construction. A coverage gate fails the build when a host function has no sample.

Tools ​

  • search_lua_api(query) — keyword search over the API documentation
  • get_lua_type_definitions(role) — full typedefs, rendered from the live surface
  • validate_lua_script(script, role) — type-check before deploying

Base API — every role ​

Generated from the checked surface.

ctx.cache ​

  • ctx.cache.delete — async(string) => [boolean, nil | string] Deletes a tenant-scoped cache value.
  • ctx.cache.get — async(string) => [boolean, any | string] Reads a tenant-scoped cache value.
  • ctx.cache.set — async({ key: string, ttl: number | nil, value: any }) => [boolean, nil | string] Writes a tenant-scoped cache value (single-table input).

ctx.config ​

  • ctx.config.get — async(string, any | nil) => [boolean, any | string] Reads a tenant config value, with an optional default.

ctx.device ​

  • ctx.device.forWorkcenter — sync(string) => WorkCenterDevice
  • ctx.device.get — sync(string) => Device
  • ctx.device.http — async(string, { auth: { basic: { password: string, username: string } | nil, bearer: string | nil } | nil, body: any | nil, headers: { [key: string]: string } | nil, method: string | nil, path: string | nil, query: { [key: string]: any } | nil, timeoutMs: number | nil, url: string | nil }) => [boolean, { body: any, headers: { [key: string]: string }, status: number } | string] Sandboxed HTTP request through a device connection.
  • ctx.device.sql — async(string, { bindings: any[] | nil, query: string }) => [boolean, { columns: string[], executionTimeMs: number | nil, rows: any[][], rowsAffected: number | nil } | string] Sandboxed SQL through a device connection.

ctx.edgeApp ​

  • ctx.edgeApp.invoke — async(string, any | nil) => [boolean, any | string] Invokes a deployed edgeApp by handle.

ctx.graph ​

  • ctx.graph.impact — async(string, { direction: string | nil, maxDepth: number | nil } | nil) => [boolean, { depth: number | nil, id: string, label: string | nil, status: string | nil, type: string }[] | string] Returns entities reachable up/downstream from a fulfillment entity.
  • ctx.graph.provenanceImpact — async(string, { direction: string | nil, maxDepth: number | nil } | nil) => [boolean, { depth: number | nil, id: string, label: string | nil, status: string | nil, type: string }[] | string] Returns entities reachable up/downstream from a provenance entity, over the structural composite (operational + fulfillment + provenance). Requires provenance:read.
  • ctx.graph.provenanceTrace — async(string, { direction: string | nil, maxDepth: number | nil } | nil) => [boolean, { edges: { rel: string, source: string, target: string }[], nodes: { depth: number | nil, id: string, label: string | nil, status: string | nil, type: string }[] } | string] Returns the cross-layer lineage subgraph (nodes + edges) around a provenance entity, over the structural composite (operational + fulfillment + provenance). Requires provenance:read.
  • ctx.graph.trace — async(string, { direction: string | nil, maxDepth: number | nil } | nil) => [boolean, { edges: { rel: string, source: string, target: string }[], nodes: { depth: number | nil, id: string, label: string | nil, status: string | nil, type: string }[] } | string] Returns the fulfillment lineage subgraph (nodes + edges) around an entity.
  • ctx.graph.whatIf — async(string[], { rel: string | nil, source: string, target: string, weight: number | nil }[], { direction: string | nil, maxDepth: number | nil } | nil) => [boolean, { affected: { depth: number | nil, id: string, label: string | nil, newlyReachable: boolean, status: string | nil, type: string }[], introducesCycle: boolean } | string] First-class hypothetical impact: the delta between the baseline reachable set from seeds and the reachable set with extraEdges unioned in (over the structural composite by default), plus whether the injected edges introduce a cycle. Requires provenance:read.

ctx.graphql ​

  • ctx.graphql.query — async(string, { [key: string]: any } | nil) => [boolean, { data: any | nil, errors: { message: string }[] | nil } | string] Runs a GraphQL operation with the caller's authorization and tenant scope.

ctx.jdf ​

  • ctx.jdf.build — async(any[]) => [boolean, string | nil | string] Renders a JDF build-operation list to JDF XML. Returns (ok, xml) — ok=false with the error message on failure.
  • ctx.jdf.loadTemplate — async(string) => [boolean, any | string] Loads a jdf-template-role tenant Script by name and returns its exports.
  • ctx.jdf.parse — async(string, any | nil) => [boolean, any | nil | string] Parses JDF XML into a table. Returns (ok, doc) — ok=false with the error message on failure.

ctx.jmf ​

  • ctx.jmf.build — async(any[]) => [boolean, string | nil | string] Renders a JMF build-operation list to JMF XML. Returns (ok, xml) — ok=false with the error message on failure.
  • ctx.jmf.loadTemplate — async(string) => [boolean, any | string] Loads a jmf-template-role tenant Script by name and returns its exports.
  • ctx.jmf.parse — async(string, any | nil) => [boolean, any | nil | string] Parses JMF XML into a table (kind = jmf or jdf_stub). Returns (ok, doc) — ok=false with the error message on failure.

ctx.json ​

  • ctx.json.decode — sync(string) => any Parses a JSON string into a table/value. Invalid JSON raises a catchable error.
  • ctx.json.encode — sync(any) => string Serializes a value as a JSON string (sorted object keys).

ctx.knowledge ​

  • ctx.knowledge.query — async(string[], { limit: number | nil, query: string | nil } | nil) => [boolean, { graph: { body: string, conceptId: string | nil, confirmations: number, contradicts: string[], entityId: string | nil, entityType: string | nil, id: string, provenance: string, scope: string, score: number }[], supporting: { body: string, conceptId: string | nil, confirmations: number, contradicts: string[], entityId: string | nil, entityType: string | nil, id: string, provenance: string, scope: string, score: number }[] } | string] Retrieves tenant knowledge relevant to the given bounded-entity seeds (TypeID strings; graph spread + attached notes, ranked). Optional free-text query biases the spread semantically.

ctx.log ​

  • ctx.log.error — sync(string, { [key: string]: any } | nil) => nil Logs an ERROR message with optional structured attributes.
  • ctx.log.info — sync(string, { [key: string]: any } | nil) => nil Logs an INFO message with optional structured attributes.
  • ctx.log.warn — sync(string, { [key: string]: any } | nil) => nil Logs a WARNING message with optional structured attributes.

ctx.ml ​

  • ctx.ml.clusterSeries — async(number[][], { epsilon: number | nil, metric: string | nil, minClusterSize: number | nil, window: number | nil } | nil) => [boolean, { labels: number[], nClusters: number } | string] DBSCAN clustering of series by DTW distance (augurs).
  • ctx.ml.detectChangepoints — async(number[], { method: string | nil } | nil) => [boolean, { changepoints: number[] } | string] Structural-break detection on a univariate series (augurs).
  • ctx.ml.detectOutliers — async(number[][], { method: string | nil, sensitivity: number | nil } | nil) => [boolean, { isOutlier: boolean[], outlyingSeries: number[] } | string] Outlier detection across a group of aligned series (augurs).
  • ctx.ml.detectSeasonality — async(number[]) => [boolean, { periods: number[] } | string] Periodogram seasonal-period detection on a univariate series (augurs).
  • ctx.ml.dtwDistance — async(number[], number[], { metric: string | nil, window: number | nil } | nil) => [boolean, { distance: number } | string] Dynamic-time-warping distance between two series (augurs).
  • ctx.ml.dtwDistanceMatrix — async(number[][], { metric: string | nil, window: number | nil } | nil) => [boolean, { matrix: number[][] } | string] Pairwise DTW distance matrix over a group of series (augurs).
  • ctx.ml.get — sync(string) => MLModel
  • ctx.ml.interpolate — async(number[]) => [boolean, { values: number[] } | string] Linear gap-fill of NaN holes in a series (augurs).
  • ctx.ml.predict — async(string, string) => [boolean, { confidence: number | nil, predictedClass: string | nil, prediction: number | nil, warnings: string[] | nil } | string] Prediction against a tenant-trained model.
  • ctx.ml.systemPredict — async(string, string) => [boolean, { confidence: number | nil, predictedClass: string | nil, prediction: number | nil, warnings: string[] | nil } | string] Prediction against a system model role.

ctx.notify ​

  • ctx.notify.send — async({ body: string, bodyHtml: string | nil, group: string | nil, recipient: string | nil, subject: string } | string, { body: string, bodyHtml: string | nil, subject: string } | nil) => [boolean, { recipientCount: number, status: string } | nil | string] Sends a notification to a group or recipient. Pass a single table { group?/recipient?, subject, body, bodyHtml? }. The legacy (targetId, options) two-arg form is deprecated.

ctx.protobuf ​

  • ctx.protobuf.decode — async(string, string) => [boolean, any | string] Decodes a protobuf payload (base64) against a registered descriptor.

ctx.script ​

  • ctx.script.load — sync(string) => any Loads another tenant Script by name and returns its exports. Raises a catchable error when the Script does not exist — wrap in pcall to fall back.

ctx.sql ​

  • ctx.sql.query — async(string, { [key: string]: any } | nil) => [boolean, { headers: string[], rowCount: number, rows: any[][] } | string] Read-only SQL over the tenant's reporting tables.

ctx.template ​

  • ctx.template.render — async(string, { [key: string]: any } | nil) => [boolean, string | string] Renders a named template with the given variables.

ctx.time ​

  • ctx.time.now — sync() => integer Current time as integer milliseconds since the Unix epoch (UTC).
  • ctx.time.nowIso — sync() => string Current time as an ISO-8601 UTC string with millisecond precision.

Custom apps add ​

Generated from the checked surface.

ctx.dataContainer ​

  • ctx.dataContainer.delete — async(string) => [boolean, any | string] Deletes a value from the custom app's persistent container.
  • ctx.dataContainer.get — async(string) => [boolean, any | string] Reads a value from the custom app's persistent container.
  • ctx.dataContainer.put — async(string, any) => [boolean, any | string] Writes a value into the custom app's persistent container.

Integrations add ​

Generated from the checked surface.

ctx.integration ​

  • ctx.integration.cacheDelete — async(string) => [boolean, any | string] Deletes an instance-scoped cache value.
  • ctx.integration.cacheGet — async(string) => [boolean, any | string] Reads an instance-scoped cache value.
  • ctx.integration.cacheSet — async({ key: string, ttl: number | nil, value: any }) => [boolean, any | string] Writes an instance-scoped cache value (single-table input).
  • ctx.integration.containersDelete — async(string, string) => [boolean, any | string] Deletes one instance-scoped container.
  • ctx.integration.containersGet — async(string, string) => [boolean, { createdAt: string | nil, data: any, name: string, schemaName: string, updatedAt: string | nil } | nil | string] Reads one instance-scoped container.
  • ctx.integration.containersList — async(string | nil, { limit: number | nil, offset: number | nil } | nil) => [boolean, { createdAt: string | nil, data: any, name: string, schemaName: string, updatedAt: string | nil }[] | string] Lists instance-scoped containers.
  • ctx.integration.containersQuery — async({ schemaName: string | nil, taggableId: string | nil, taggableType: string | nil }) => [boolean, { createdAt: string | nil, data: any, name: string, schemaName: string, updatedAt: string | nil }[] | string] Queries instance-scoped containers by tagged refs.
  • ctx.integration.containersSet — async({ data: any, name: string, schemaName: string }) => [boolean, { createdAt: string | nil, data: any, name: string, schemaName: string, updatedAt: string | nil } | string] Writes one instance-scoped container (single-table input).
  • ctx.integration.getBindings — async() => [boolean, { [key: string]: string } | string] The manifest resource bindings of this instance.
  • ctx.integration.getConfig — async() => [boolean, { [key: string]: any } | string] The install-time configuration of this integration instance.
  • ctx.integration.getId — async() => [boolean, string | string] The integration instance ID.
  • ctx.integration.getIntegrationId — async() => [boolean, string | string] The integration definition ID.
  • ctx.integration.getVersion — async() => [boolean, string | string] The installed integration version.
  • ctx.integration.protobufDecode — async(string, string) => [boolean, any | string] Decodes a protobuf payload against a manifest-declared descriptor.

Archive scripts add ​

Generated from the checked surface.

ctx.archive ​

  • ctx.archive.last_checkpoint — sync() => any | nil The checkpoint value returned by the previous SUCCEEDED run's exports.checkpoint(), JSON-decoded. Nil if there's no prior run, no prior success, or no checkpoint.

Template resolvers get ONLY ​

Generated from the checked surface.

ctx.json ​

  • ctx.json.decode — sync(string) => any Parses a JSON string into a table/value. Invalid JSON raises a catchable error.
  • ctx.json.encode — sync(any) => string Serializes a value as a JSON string (sorted object keys).

ctx.log ​

  • ctx.log.error — sync(string, { [key: string]: any } | nil) => nil Logs an ERROR message with optional structured attributes.
  • ctx.log.info — sync(string, { [key: string]: any } | nil) => nil Logs an INFO message with optional structured attributes.
  • ctx.log.warn — sync(string, { [key: string]: any } | nil) => nil Logs a WARNING message with optional structured attributes.

ctx.script ​

  • ctx.script.load — sync(string) => any

ctx.time ​

  • ctx.time.now — sync() => integer Current time as integer milliseconds since the Unix epoch (UTC).
  • ctx.time.nowIso — sync() => string Current time as an ISO-8601 UTC string with millisecond precision.

Handle objects ​

Some calls return a HANDLE rather than data. The handle is built synchronously and resolves LAZILY — an unknown name fails when a method runs, not when the handle is made, and it raises there rather than returning a false ok. Methods take the handle as self, so they are called with a colon, and they are async like any other service call.

lua
local printer = ctx.device.get("my-printer")
local ok, resp = printer:http({ method = "GET", path = "/api/status" })
if not ok then error(resp) end

Device ​

From ctx.device.get.

  • Device:http — async(Device, { auth: { basic: { password: string, username: string } | nil, bearer: string | nil } | nil, body: any | nil, headers: { [key: string]: string } | nil, method: string | nil, path: string | nil, query: { [key: string]: any } | nil, timeoutMs: number | nil, url: string | nil }) => [boolean, { body: any, headers: { [key: string]: string }, status: number } | string]
  • Device:id — async(Device) => [boolean, string | string]
  • Device:jmf — async(Device, string, string | nil) => [boolean, { body: any, headers: { [key: string]: string }, status: number } | string]
  • Device:sql — async(Device, { bindings: any[] | nil, query: string }) => [boolean, { columns: string[], executionTimeMs: number | nil, rows: any[][], rowsAffected: number | nil } | string]

MLModel ​

From ctx.ml.get.

  • MLModel:forecast — async(MLModel, number, number | nil) => [boolean, { level: number | nil, lower: number[] | nil, point: number[], upper: number[] | nil } | string]
  • MLModel:info — async(MLModel) => [boolean, { backend: string, concept: string, modelType: string, name: string, status: string } | string]
  • MLModel:predict — async(MLModel, { [key: string]: any } | string) => [boolean, { confidence: number | nil, predictedClass: string | nil, prediction: number | nil, warnings: string[] | nil } | string]

WorkCenterDevice ​

From ctx.device.forWorkcenter.

  • WorkCenterDevice:http — async(WorkCenterDevice, { auth: { basic: { password: string, username: string } | nil, bearer: string | nil } | nil, body: any | nil, headers: { [key: string]: string } | nil, method: string | nil, path: string | nil, query: { [key: string]: any } | nil, timeoutMs: number | nil, url: string | nil }) => [boolean, { body: any, headers: { [key: string]: string }, status: number } | string]
  • WorkCenterDevice:id — async(WorkCenterDevice) => [boolean, string | string]
  • WorkCenterDevice:jmf — async(WorkCenterDevice, string, string | nil) => [boolean, { body: any, headers: { [key: string]: string }, status: number } | string]
  • WorkCenterDevice:sql — async(WorkCenterDevice, { bindings: any[] | nil, query: string }) => [boolean, { columns: string[], executionTimeMs: number | nil, rows: any[][], rowsAffected: number | nil } | string]

Integrations deliberately do NOT get ​

  • ctx.config.get
  • ctx.protobuf.decode

Instance-scoped equivalents exist under ctx.integration.* instead, so a secret-bearing read is greppable to one surface.

Connect machines, automate workflows, and build custom apps for print production.