Workflows, webhooks and apps
These scripts run in the Normal profile: the whole language and every ctx function.
Each section shows a complete script of that kind.
The playground and stored scripts
The playground runs a script with the JSON you type into its Input box, passed as the script's argument, and shows what it returns and what it logs. The Input box can be empty, so give the argument a default.
-- The playground hands the JSON typed into its Input box to the script as its
-- argument, so read it at the top. It is nil when the box is empty, so give it a
-- default.
local input = ...
local name = "world"
if input ~= nil and type(input.name) == "string" then name = input.name end
ctx.log.info("greeting", { name = name })
-- The returned value is the run's output.
return { greeting = "Hello, " .. name }Workflow script nodes
A script node receives what the node before it produced. With one incoming connection that is the value itself; with several, it is a table keyed by the name of each connection's port. What the script returns is the node's output, which the nodes after it read.
-- A script node receives what its upstream node produced. With one incoming
-- connection that is the value itself; with several, a table keyed by port name.
local input = ...
local total = 0
for _, line in ipairs(input.lines) do
total = total + line.quantity * line.unitPrice
end
-- The returned table is the node's output, which the nodes after it read.
return { lineCount = #input.lines, total = total }Custom workflow actions
A custom workflow action is a script you write once and place in many workflows. It receives two arguments: the node's input, and the configuration set on the node where the action is placed.
-- A custom workflow action receives two arguments: the node's input, and the
-- configuration set where the action is placed in the workflow.
local input, config = ...
local threshold = config.threshold or 100
local flagged = {}
for _, order in ipairs(input.orders) do
if order.total >= threshold then table.insert(flagged, order.id) end
end
return { flagged = flagged, threshold = threshold }Incoming webhooks
An incoming webhook defines exports.handler, and CoCoCo calls it with each request:
its method, path, query and headers, and its body, already decoded when the
caller sent JSON. What the handler returns is the response: a status (200 when
omitted), headers and a body. To reject a request, return an error status rather
than raising an error. The webhook's timeout is set on the webhook.
-- An incoming webhook endpoint is DISPATCHED, not evaluated: the platform runs
-- this file once to set up, then calls `exports.handler(request)` for every
-- request. A top-level `return` is therefore ignored — the response is whatever
-- the handler returns. Same convention as a custom app's `exports.<method>`.
--
-- Work done out here runs ONCE per request, before the handler, and shares the
-- endpoint's timeout with it. Keep it to constants; do the work in the handler.
local ACCEPTED_METHODS = { POST = true, PUT = true }
exports.handler = function(request)
-- `request.body` arrives already decoded when the caller sent JSON, so there
-- is no json.decode step. A non-JSON (or malformed) body arrives as the
-- raw string instead, which is why the type is worth checking before indexing.
local body = request.body
local isTable = type(body) == "table"
if not ACCEPTED_METHODS[request.method] then
-- Returning a status is how a handler rejects: raising an error would be a
-- 500, which is not what a wrong method deserves.
return {
status = 405,
headers = { ["content-type"] = "application/json" },
body = { error = "method not allowed", method = request.method },
}
end
if not isTable then
return { status = 400, body = { error = "expected a JSON body" } }
end
-- Query parameters and headers are plain string maps. Header names are
-- lower-cased for you, so index them in lower case whatever the caller sent.
local source = request.query.source or "unknown"
local signed = request.headers["x-signature"] ~= nil
-- Omitting `status` means 200 and omitting `headers` means JSON, so the
-- minimal successful answer is just a `body`. Both are spelled out here to
-- show where they go.
return {
status = 200,
headers = { ["content-type"] = "application/json" },
body = {
received = body.orderId,
quantity = body.quantity,
source = source,
signed = signed,
},
}
endCustom app server functions
A custom app calls the functions its server script puts on exports, by name, with
the arguments the app passes, and receives what the function returns.
-- A custom app calls the functions its server script puts on `exports`, by name,
-- with the arguments the app passes. What a function returns goes back to the
-- app. The top level runs before every call, so keep it to definitions.
exports.customerCount = function(options)
local first = 10
if options ~= nil and type(options.first) == "number" then first = options.first end
local ok, result = ctx.graphql.query([[
query CustomerCount($first: Int!) {
listCustomers(first: $first) { totalCount }
}
]], { first = first })
if not ok then error(result) end
if result.errors then error(json.encode(result.errors)) end
local data = result.data
if data == nil then error("GraphQL returned no data") end
return { total = data.listCustomers.totalCount }
endMCP tools
An MCP tool defines exports.handler. CoCoCo calls it with the tool's JSON input
and returns its JSON-shaped result to the MCP client. Declare the tool's input
schema alongside the script so callers know which arguments to supply.
-- Declare an input schema with an orderNumber string and lines containing
-- quantity numbers on the MCP tool. CoCoCo passes that input to this handler.
exports.handler = function(input)
local quantity = 0
for _, line in ipairs(input.lines) do
quantity = quantity + line.quantity
end
return { orderNumber = input.orderNumber, quantity = quantity, lineCount = #input.lines }
endWorkspace handlers
A Workspace's server Lua puts one function on exports for each handler, and the
Workspace calls it with the handler's input. What it returns is the handler's
output, checked against the handler's declared output. Input and output are
JSON-shaped, and a handler's input keeps its JSON exactly: a null field arrives as
json.null() (test it with json.isnull) and an absent field is nil. Return
json.array() or json.object() for an empty array or object. The Workspace manual
describes how handlers are declared and called from a Workspace.
-- A Workspace's server Lua puts one function on `exports` for each handler. The
-- Workspace calls it with the handler's input, and what it returns is the
-- handler's output. Both are JSON-shaped: tables, strings, numbers and booleans.
exports.summarise = function(input)
local open, overdue = 0, 0
for _, task in ipairs(input.tasks) do
if not task.done then
open = open + 1
if task.dueInDays < 0 then overdue = overdue + 1 end
end
end
return { open = open, overdue = overdue }
endArchive jobs
An archive job copies rows out to a file on a schedule. Its script is dispatched:
CoCoCo asks exports.schema() for the columns, each with a name and a type such
as string, int64 or timestamp, then calls exports.next_chunk() until it
returns nil, writing each chunk's rows. At the end it keeps what exports.checkpoint()
returns, and the next run receives that as input.last_checkpoint, so a job can pick
up where the last successful run stopped.
-- An archive job is dispatched: CoCoCo sets the script up, asks exports.schema()
-- for the columns, calls exports.next_chunk() until it returns nil, and at the
-- end keeps what exports.checkpoint() returns.
--
-- The next run receives that as `input.last_checkpoint`. It is nil on the first
-- run, and its shape is whatever an earlier version of the script returned, so
-- check it before use.
local since = 0
local checkpoint = input.last_checkpoint
if checkpoint ~= nil then
if type(checkpoint) == "table" and type(checkpoint.lastId) == "number" then
since = checkpoint.lastId
end
end
-- The rows would normally come from ctx.sql.query or ctx.graphql.query; a fixed
-- list keeps the example self-contained.
local pending = {}
for id = since + 1, since + 5 do
table.insert(pending, { id = id, status = "shipped" })
end
local sent = false
local lastId = since
exports.schema = function()
return {
{ name = "id", type = "int64", nullable = false },
{ name = "status", type = "string" },
}
end
exports.next_chunk = function()
if sent then return nil end
sent = true
lastId = since + #pending
return pending
end
exports.checkpoint = function()
return { lastId = lastId }
endQuote price tuning
A price-tuning script runs after a quote is estimated. It receives the customer, the
estimate and its lines, and returns a list of adjustments, each naming a line by its
lineIndex and giving a new unitPrice or a discountPercent. If the script fails,
the quote keeps its estimated prices.
-- Price tuning runs after a quote is estimated. It receives the customer, the
-- estimate and its lines, and returns a list of adjustments: one entry per line
-- to change, naming the line by its lineIndex.
local input = ...
local adjustments = {}
for _, line in ipairs(input.lines) do
local cost = line.estimatedCost
local floor = nil
if cost ~= nil and line.quantity > 0 then floor = cost / line.quantity * 1.25 end
if floor ~= nil and line.unitPrice < floor then
-- Keep at least a 25% margin over the estimated cost.
table.insert(adjustments, { lineIndex = line.lineIndex, unitPrice = floor })
elseif line.quantity >= 1000 then
-- Large runs get a volume discount instead.
table.insert(adjustments, { lineIndex = line.lineIndex, discountPercent = 5 })
end
end
return adjustmentsWorkflow identity and failures
Every workflow requires a botUserId, including workflows that only compute
values. Assign a bot in the same tenant and attach the IAM policies its actions
need. Saving, activating, or running a legacy workflow without a bot fails;
assign its bot before retrying. Integration scripts that call GraphQL without a
bot receive an explicit identity error in res.errors.
For Lua GraphQL calls, ok means the request completed. Check res.errors
before using res.data. A dedicated workflow graphql node instead fails on
any top-level GraphQL errors, including when partial data accompanies them.
continueOnError allows independent branches to continue after a node fails.
The failed node stays failed, and dependent branches without a live input path
are skipped. It does not invent a successful output or an error branch.
resumeWorkflowExecution resumes waiting runs. Terminal runs return an explicit
error; start a new execution to retry them.
Workflow webhook triggers
A workflow WEBHOOK trigger is served on the tenant API origin at
/api/workflow-hooks followed by its configured path. For path: "/orders",
call /api/workflow-hooks/orders. Read config.endpointPath from GraphQL to
get the canonical path. The method must match the configured method.
When authRequired is true, supply a valid tenant bearer token or authenticated
session (browser sessions also require the usual CSRF token). Setting it false
explicitly permits public invocation. The workflow must still be active, have a
bot identity, and have an executable version. This bot's permissions govern the
execution; the incoming caller does not replace the bot.
HTTP 202 acknowledges queued evaluation, not successful workflow completion.
Existing concurrency rules can defer or skip evaluation. Workflow input contains
webhook.method, webhook.path, webhook.query (raw query text), and
webhook.body (decoded JSON, or the raw text for a non-JSON body). Bodies are
limited to 1 MiB. Request headers are not retained in workflow history.
Duplicate enabled triggers for the same path and method return HTTP 409.
Hashing and signing
Every script can hash, sign and verify text with the hash, hex and base64
libraries. They are part of the language, not platform capabilities, so they
return their value directly, without an ok tuple. Invalid arguments raise an
error catchable with pcall. Digests and signatures are lowercase hex.
-- hash, hex and base64 are language libraries, not platform capabilities:
-- each function returns its value directly, with no ok flag to bind. Invalid
-- arguments raise, so wrap a call in pcall when the inputs come from outside
-- the script.
local digest = hash.sha256("hello")
-- Digests and signatures are lowercase hex. The key is UTF-8 text by default;
-- pass "hex" or "base64" as the third argument for a binary key, such as the
-- previous step of a chained signature.
local signature = hash.hmac_sha256("secret", "hello")
-- Verification compares in constant time and answers false, never raises, for
-- a malformed or mismatched hex signature.
local valid = hash.verify_hmac_sha256("secret", "hello", signature)
local tampered = hash.verify_hmac_sha256("secret", "hello!", signature)
-- The signature's RAW BYTES, not its hex text, are what a provider usually
-- wants base64-encoded. hex.to_base64 converts the bytes directly; they are not
-- UTF-8 text, so they could not pass through hex.decode and base64.encode.
local encoded = hex.to_base64(signature)
-- Converting back to hex proves the round trip kept the bytes intact. A
-- provider's base64 signature is checked the same way:
-- hash.verify_hmac_sha256(secret, body, base64.to_hex(header)).
local decoded = base64.to_hex(encoded)
return {
digest = digest,
signature = signature,
valid = valid,
tampered = tampered,
encoded = encoded,
roundTripped = decoded == signature,
}Strings are UTF-8 text. For a binary signing key, pass "hex" or "base64" as
the third argument to hash.hmac_sha256 (fourth to hash.verify_hmac_sha256).
This chains HMACs without corrupting raw digest bytes, as AWS Signature V4 key
derivation requires. Raw bytes are not UTF-8 text, so move them between
encodings with hex.to_base64 and base64.to_hex rather than decoding them.
Verification compares in constant time; a malformed hex signature returns false.
These primitives do not implement provider request canonicalization, credential storage or automatic device authentication. Native AWS SigV4 device auth remains a separate integration feature.
Published platform manual · revision 6d0114293a3d