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

```lua
-- 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.

```lua
-- 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.

```lua
-- 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.

```lua
-- 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.

```lua
-- 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.

```lua
-- 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.

```lua
-- 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.

```lua
-- 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.

```lua
-- 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.

```lua
-- 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.