Skip to content

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 ​

SurfaceHow the script is enteredRole
Lua Playground in the console (/#/lua-playground)Evaluated: input arrives as ..., the chunk's return value is the outputSCRIPT
Workflow script nodeEvaluated: the node input arrives as ..., the return value becomes the node outputSCRIPT
Incoming webhookDispatched: the platform calls exports.handler(request)SCRIPT
Custom app server scriptDispatched: the client calls exports.<method>(...)CUSTOM_APP
IntegrationEvaluated: 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 shutdownINTEGRATION
Product-template resolverEvaluated: reads the typed input global and returns a patchTEMPLATE_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 } }
end

See 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, result on success;
  • false, message when 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.

NamespacePurposePage
ctx.graphqlRun GraphQL queries and mutations as the callerGraphQL
ctx.sqlRead-only SQL over the tenant's reporting tablesSQL
ctx.deviceHTTP and SQL through a configured device connectionDevice
ctx.cacheTenant-scoped key/value cache with TTLCache
ctx.notifySend a notification to a group or recipientNotify
ctx.templateRender a stored template by handleTemplate
ctx.jsonEncode and decode JSONJSON
ctx.time and timeCurrent time and time valuesTime
ctx.logStructured log linesLog
ctx.configRead tenant configuration valuesConfig
ctx.script.loadReuse another tenant Script as a moduleScript loading
ctx.jdf, ctx.jmfParse JDF/JMF, load JDF/JMF templatesJDF 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 plus ctx.dataContainer.get/put/delete, a persistent store scoped to the app.
  • INTEGRATION: the base surface without ctx.config.get and ctx.protobuf.decode, plus ctx.integration.* (instance-scoped containers and cache, getConfig, getBindings, protobufDecode and metadata). Devices, templates and loadable scripts are limited to the resources the integration manifest declares and the instance binds, and ctx.device.forWorkcenter raises.
  • TEMPLATE_RESOLVER: only the standard library, ctx.time, ctx.json, ctx.log, ctx.script.load and the typed input global. No services, and time.now(), uuid.v4() and uuid.v7() are absent. ctx.time.now() and ctx.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:

LimitDefault
Wall-clock time5 seconds
Memory50 MB
Instructions200 million
Call depth64
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.data

Create 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.

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