scripting / scripting-5Markdown ↓

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,
    },
  }
end

Custom 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 }
end

MCP 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 }
end

Workspace 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 }
end

Archive 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 }
end

Quote 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 adjustments

Workflow 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