Appearance
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 stringBinding 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:
- Reading an undefined global throws. It does not yield
nil. Typos fail loudly. - 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. - 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.
- Only what §8 lists exists. The standard library is a closed set.
select,next,string.gmatchandtable.sort's in-place form are all absent, andbit32is 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 // 2lua
return 7 / 2Integer 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 // 2lua
return -7 % 2Strings 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=" .. 42lua
return "flag=" .. trueTruthiness: 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 <= nil2. Variables and scope
lua
local x = 1
local a, b = 2, 3
x = x + a + b
return xScoping 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 somethingNobodyDefinedIn the default strict checking mode, creating a global is also a check-time error. Declare locals.
3. Statements
| Form | Notes |
|---|---|
if … then … elseif … else … end | as in Lua |
while cond do … end | as in Lua |
repeat … until cond | body runs at least once; the condition sees the body's locals |
for i = a, b [, step] do … end | inclusive bounds; step 0 throws; bounds must be numbers |
for k, v in ipairs(t) do … end | array part in order — syntactic form only (§6) |
for k, v in pairs(t) do … end | whole table, defined order (§6) |
break | innermost loop; outside a loop it is a parse error |
do … end | explicit scope block |
local function f(…) … end | visible to its own body, so recursion works |
function f(…) … end | global 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 count4. 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 #tHole 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 seenBounded 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 totalThe 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 #ttable — 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 throwsinvalidArgument. Wrap it:function(v) return tostring(v) end, nottostring.
lua
return table.map({1, 2}, tostring)[1]lua
return table.reduce({1, 2, 3}, function(acc, v) return acc + v end, 0)
table.sortreturns a NEW array and never mutates, unlike Lua's in-placetable.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" + 0lua
return tonumber("42") + 09. 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:
| Function | Returns |
|---|---|
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.matchreturns 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. Usestring.captureswhen 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) .. ":" .. errThe 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.
- Undefined globals throw instead of reading
nil. - Integer overflow throws instead of wrapping.
- Multiple returns are bounded (§5): no expansion in argument lists or table constructors; forwarding only from a lone bare-call
return. ipairssnapshots its trip count;pairsorder is defined (array part, then map insertion order) and traversal is live and position-based.ipairs/pairsare syntactic — genericforaccepts nothing else, and there are no first-class iterators.nextis not granted for this reason: it is Lua's iterator primitive, not an ambient-authority risk. Usetable.isempty(t)to test emptiness.- Metatables are bounded to a table-valued
__index— no operator overloading, no__call, no__newindex. - No dynamic code from strings;
requireexists only when the host grants a loader. - Strings are byte-based but split-safe — an operation that would split a UTF-8 sequence throws.
- Table holes are defined, and library tables are frozen.
string.matchreturns the whole match; captures come fromstring.captures, andgmatchismatchall, returning an array.table.sortreturns a new array and never mutates.- No string→number coercion in arithmetic — convert explicitly with
tonumber. - No bitwise operators (
& | ~ << >>); the opt-inbit32library covers the work. - Script functions are never variadic; only the main chunk has
.... - 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 thenthrows ifsomeGlobalwas 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;pcallwill not save a runaway loop.{f()}org(f())to move multiple values. Both take one value. Destructure first.string.matchwith a capture group when you want the group. Usestring.captures.- Reaching for
gmatch,select,next, ormath.random. None exist. §8 lists the alternatives where there is one. - Writing
a & bora << n. The bitwise operators are not in the grammar; use the opt-inbit32library, 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 mutatet; 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
| Role | Use case | Surface |
|---|---|---|
SCRIPT | tenant Scripts, playground, workflow overlays, webhooks, plugin seeds | base ctx.* |
CUSTOM_APP | custom app RPC handlers | base + ctx.dataContainer |
INTEGRATION | integration RPC handlers and hooks | base (minus ctx.config/ctx.protobuf) + ctx.integration.*, with devices, templates and loadable scripts all locked to manifest resource bindings |
ARCHIVE | archive job scripts | base + ctx.archive.last_checkpoint |
TEMPLATE_RESOLVER | product-template resolver scripts | stdlib + ctx.time/ctx.json/ctx.log + ctx.script.load only |
EDGE_APP | edge bridge bundle handlers | bridge.* — 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.
| Surface | Export |
|---|---|
| Incoming webhook | exports.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 } }
endOn 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 }
endctx.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 documentationget_lua_type_definitions(role)— full typedefs, rendered from the live surfacevalidate_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) => WorkCenterDevicectx.device.get— sync(string) => Devicectx.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) => anyParses a JSON string into a table/value. Invalid JSON raises a catchable error.ctx.json.encode— sync(any) => stringSerializes 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-textquerybiases the spread semantically.
ctx.log
ctx.log.error— sync(string, { [key: string]: any } | nil) => nilLogs an ERROR message with optional structured attributes.ctx.log.info— sync(string, { [key: string]: any } | nil) => nilLogs an INFO message with optional structured attributes.ctx.log.warn— sync(string, { [key: string]: any } | nil) => nilLogs 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) => MLModelctx.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) => anyLoads 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() => integerCurrent time as integer milliseconds since the Unix epoch (UTC).ctx.time.nowIso— sync() => stringCurrent 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 | nilThe checkpoint value returned by the previous SUCCEEDED run'sexports.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) => anyParses a JSON string into a table/value. Invalid JSON raises a catchable error.ctx.json.encode— sync(any) => stringSerializes a value as a JSON string (sorted object keys).
ctx.log
ctx.log.error— sync(string, { [key: string]: any } | nil) => nilLogs an ERROR message with optional structured attributes.ctx.log.info— sync(string, { [key: string]: any } | nil) => nilLogs an INFO message with optional structured attributes.ctx.log.warn— sync(string, { [key: string]: any } | nil) => nilLogs a WARNING message with optional structured attributes.
ctx.script
ctx.script.load— sync(string) => any
ctx.time
ctx.time.now— sync() => integerCurrent time as integer milliseconds since the Unix epoch (UTC).ctx.time.nowIso— sync() => stringCurrent 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) endDevice
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.getctx.protobuf.decode
Instance-scoped equivalents exist under ctx.integration.* instead, so a secret-bearing read is greppable to one surface.