Appearance
Lua scripting overview
CoCoCo runs tenant logic as Lua scripts. The engine is SwiftLua: a sandboxed, deterministic subset of Lua, not stock Lua 5.x. Read the dialect guide before porting existing Lua code, because some familiar constructs are missing or behave differently.
Everything the platform offers a script lives under one global table, ctx, next to the standard library and the exports table. Reading a global name that does not exist raises an unknownGlobal error instead of returning nil.
Where scripts run
| Surface | How the script is entered | Role |
|---|---|---|
Lua Playground in the console (/#/lua-playground) | Evaluated: input arrives as ..., the chunk's return value is the output | SCRIPT |
Workflow script node | Evaluated: the node input arrives as ..., the return value becomes the node output | SCRIPT |
| Incoming webhook | Dispatched: the platform calls exports.handler(request) | SCRIPT |
| Custom app server script | Dispatched: the client calls exports.<method>(...) | CUSTOM_APP |
| Integration | Evaluated: each timer and subscription in the manifest has its own inline source, plus the optional initSource and shutdownSource. Arguments arrive as ...: event, config for a subscription, name, config for a timer, config for init and shutdown | INTEGRATION |
| Product-template resolver | Evaluated: reads the typed input global and returns a patch | TEMPLATE_RESOLVER |
Two more roles exist and are not covered here: ARCHIVE (archive job scripts) and EDGE_APP (edge app handlers, which run on the CoCoCo bridge with their own bridge API instead of ctx).
On an evaluated surface the chunk runs once and its return value is the result. On a dispatched surface the chunk runs once as a setup pass and the platform then calls a function you put on exports. A top-level return is ignored there. Keep the setup pass cheap (constants and function definitions), because it runs for every request and shares the request's budget.
lua
-- Workflow script node or playground: evaluated.
local input = ...
local count = 0
if type(input) == "table" and input.count ~= nil then
count = input.count
end
return { doubled = count * 2 }lua
-- Incoming webhook: dispatched. The response is what the handler returns.
exports.handler = function(request)
if request.method ~= "POST" then
return { status = 405, body = { error = "method not allowed" } }
end
return { status = 200, body = { received = true } }
endSee Custom apps and Workflow automation for those surfaces.
Calls return (ok, res)
Every ctx function that reaches a service (GraphQL, SQL, devices, cache, notifications, templates, config) is asynchronous. The engine suspends the script while the call runs, so the code reads as ordinary sequential Lua. These calls return two values:
true, resulton success;false, messagewhen the call failed in a recoverable way (for example a device that cannot be reached or a query the engine rejects).
lua
local ok, res = ctx.graphql.query([[
query ListCustomers($first: Int!) {
listCustomers(first: $first) {
edges { node { id name } }
totalCount
}
}
]], { first = 5 })
if not ok then error(res) end
if res.errors then error(ctx.json.encode(res.errors)) end
local data = res.data
if data == nil then error("GraphQL returned no data") end
ctx.log.info("customers listed", { total = data.listCustomers.totalCount })
return { total = data.listCustomers.totalCount }Binding a single value (local res = ctx.graphql.query(...)) binds the boolean, not the result. This is the most common mistake.
Contract errors raise instead. A wrong argument type, a missing required field, an unknown device, template or notification group, or a service that is not configured raises an error and ends the run, unless you catch it with pcall. An asynchronous function cannot be passed to pcall directly, so wrap the call in a function:
lua
local reachable, result = pcall(function()
local ok, res = ctx.sql.query(
"SELECT orders.id FROM orders WHERE orders.status = :status LIMIT 5",
{ status = "CONFIRMED" })
if not ok then error(res) end
return res
end)
if not reachable then
ctx.log.warn("report unavailable", { reason = tostring(result) })
return { rowCount = 0 }
end
return { rowCount = result.rowCount }Synchronous functions return one value. ctx.json.*, ctx.time.*, ctx.log.*, ctx.script.load and the handle constructors ctx.device.get, ctx.device.forWorkcenter and ctx.ml.get return a single value and have no ok flag.
The ctx API by role
The SCRIPT role is the base surface. The other roles add or remove from it.
| Namespace | Purpose | Page |
|---|---|---|
ctx.graphql | Run GraphQL queries and mutations as the caller | GraphQL |
ctx.sql | Read-only SQL over the tenant's reporting tables | SQL |
ctx.device | HTTP and SQL through a configured device connection | Device |
ctx.cache | Tenant-scoped key/value cache with TTL | Cache |
ctx.notify | Send a notification to a group or recipient | Notify |
ctx.template | Render a stored template by handle | Template |
ctx.json | Encode and decode JSON | JSON |
ctx.time and time | Current time and time values | Time |
ctx.log | Structured log lines | Log |
ctx.config | Read tenant configuration values | Config |
ctx.script.load | Reuse another tenant Script as a module | Script loading |
ctx.jdf, ctx.jmf | Parse JDF/JMF, load JDF/JMF templates | JDF and JMF |
Also available in the base surface, documented in the type definitions in the console rather than on these pages: ctx.ml (model predictions and time-series analysis), ctx.knowledge.query, ctx.graph (lineage and impact queries), ctx.edgeApp.invoke and ctx.protobuf.decode.
Role differences:
CUSTOM_APP: the base surface plusctx.dataContainer.get/put/delete, a persistent store scoped to the app.INTEGRATION: the base surface withoutctx.config.getandctx.protobuf.decode, plusctx.integration.*(instance-scoped containers and cache,getConfig,getBindings,protobufDecodeand metadata). Devices, templates and loadable scripts are limited to the resources the integration manifest declares and the instance binds, andctx.device.forWorkcenterraises.TEMPLATE_RESOLVER: only the standard library,ctx.time,ctx.json,ctx.log,ctx.script.loadand the typedinputglobal. No services, andtime.now(),uuid.v4()anduuid.v7()are absent.ctx.time.now()andctx.time.nowIso()still read the wall clock, so avoid them if the same input must always resolve the same way.
The authoritative list for each role is generated from the platform itself. Ask for it with GraphQL (requires the script:read permission):
graphql
query LuaSurface {
luaTypeDefinitions(role: SCRIPT)
}The platform also ships tested sample scripts. Every sample is type-checked and executed by the platform's test suite:
graphql
query LuaSamples {
listLuaSamples(role: SCRIPT) {
id
title
description
}
getLuaSample(id: "cache-round-trip") {
source
apis
entry
}
}Execution budgets
Every run has limits. Exceeding one stops the script, and pcall cannot catch it. The standard budget for scripts is:
| Limit | Default |
|---|---|
| Wall-clock time | 5 seconds |
| Memory | 50 MB |
| Instructions | 200 million |
| Call depth | 64 |
Module loads (ctx.script.load) | 16 |
Some surfaces change these: an incoming webhook runs with its own configured timeout and a 5 MB memory limit, and an integration runs with the timeout from its manifest. Write scripts that do bounded work.
Recording metrics
There is no Lua metrics module. Record metric points through GraphQL with ctx.graphql.query and the recordMetricPoints mutation (requires the metric:write permission). A point without a timestamp gets the server time.
graphql
mutation RecordTemperature($metricId: MetricID!, $value: Float!) {
recordMetricPoints(input: { metricId: $metricId, points: [{ value: $value }] }) {
recorded
errors { path message }
}
}lua
local RECORD = [[
mutation RecordTemperature($metricId: MetricID!, $value: Float!) {
recordMetricPoints(input: { metricId: $metricId, points: [{ value: $value }] }) {
recorded
errors { path message }
}
}
]]
local input = ...
local ok, res = ctx.graphql.query(RECORD, { metricId = input.metricId, value = input.value })
if not ok then error(res) end
if res.errors then error(ctx.json.encode(res.errors)) end
return res.dataCreate the metric first with upsertMetric if it does not exist; see RecordMetricPointsInput and Mutations.
Checking scripts
The type checker compares a script against the role's surface, so a misspelled ctx function or a wrong argument shape is reported as a diagnostic. The console editor checks as you type; the validateLua GraphQL mutation runs the same checker. Running a script does not type-check it first, so a mistake the checker would report only shows up at run time, when that line executes.