Server Lua and capabilities
Keep Lua in the workspace's separate server source. Declare exported handlers in
XML with a mode, and give each function one explicitly typed input. Return types
are inferred when omitted. Functions use the existing exports convention. The compiler validates the source contract; the server runs
handlers with the same Lua and the same ctx as every other script, and validates their inputs and outputs.
Explicit input and output contracts can embed a schema element in the urn:cococo:json-schema:1 namespace. Prefer this XML syntax when writing contracts by hand. Existing JSON text and CDATA remain supported. The XML schemas manual in Authoring Docs describes the shared syntax; handler contracts retain the restrictions below.
Typed handler contracts
A handler without input/output children derives its contract from a direct,
top-level exports.name function declaration in server Lua. Inputs must be explicitly typed object types; inline records and named aliases
are supported. Outputs are inferred from the checked function body unless a
return annotation is supplied. Explicit return annotations are checked. Both
contracts must describe objects. Type aliases, nested records, arrays,
optional values, scalar types, literal enums, and discriminated object unions
are supported. Objects are closed: unknown properties are rejected. Recursive
types, any, maps, function-valued fields and ambiguous unions are rejected.
Inference requires a bounded JSON-compatible result: unresolved any values
(including untyped empty arrays) need a local or return annotation. GraphQL
results are typed from your instance schema. Use one export assignment per statement when inferring outputs.
Dynamic export registration requires an explicit legacy JSON Schema contract.
FormResult is built in: an object containing an errors array whose entries
have a required string message and optional nullable string path. Use
json.array() for an empty array and json.object() for an empty object: a plain
empty table encodes as []. Wrap computed sequences with json.array(values) to
preserve their inferred element type. Do not redeclare FormResult.
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.null() to
send a null.
<workspace id="typed-form" title="Typed note form" role="Operations" xmlns:t="urn:cococo:dataxml">
<handler name="validateNote" mode="action"/>
<panel id="main" title="Note">
<query type="graphql">query { __typename }</query>
<form id="note" mode="create">
<submit type="lua" handler="validateNote" errors="errors"/>
<field path="body" label="Note" control="textarea" min-length="1" max-length="2000"/>
<form-actions save-label="Check note"/>
</form>
</panel>
</workspace>type NoteInput = { body: string }
-- This example validates a note; it deliberately performs no database writes.
function exports.validateNote(input: NoteInput): FormResult
local errors = json.array()
if input.body:match("^%s*$") then
errors[1] = {path="body", message="Write a note before saving."}
end
return {errors=errors}
endField restrictions (required="true", min-length, max-length, minimum,
maximum, min-items, max-items) strengthen the Lua handler input contract.
They are enforced by the server on every call, including direct API calls.
Restrictions from all forms using the same handler are combined before any
form is compiled; contradictory bounds are an authoring error. They cannot
weaken a Lua type. Optional parent objects remain optional unless explicitly
required at the handler boundary. Form restrictions apply to ordinary fields,
including fields in sections/fieldsets; variant-case fields continue to take
constraints from their declared handler type/schema.
For arguments without a visible form field, place a field child with a path
and restrictions inside the handler. GraphQL form constraints continue to
come from the GraphQL contract. Explicit JSON Schema input/output pairs remain
supported for existing sources and advanced contracts; supply both or neither.
Editor assistance
The Workspace editor analyzes XML and Lua together after a short typing pause, without saving drafts, running Lua, or querying business data. It displays source-specific diagnostics and supports a manual check with Cmd/Ctrl+Enter in the Lua editor. XML suggestions come from the generated component catalog. Lua suggestions include the Workspace API, named types, typed handler inputs, and inferred expression members, including GraphQL results. Hover shows types/signatures; the outline lists handlers, and definition navigation connects XML handler references to Lua declarations.
Rename refactoring and project-wide module navigation are not provided. The runtime still validates every handler call.
GraphQL results are inferred
A literal GraphQL operation passed to ctx.graphql.query is checked against the
current schema. Its selected fields, aliases, fragments, list element types,
nullability and variables determine the Lua call's type. Intermediate result
records do not need handwritten type declarations. Immutable local query strings
are supported; a variable assigned anywhere in the script, including a closure,
falls back to dynamic-query checking. Passing a document through an arbitrary
helper function also uses that fallback. The editor reports the loss of static
operation checking rather than guessing a result shape.
Bind both ok and the response. A successful call can still contain GraphQL
errors, absent data or null data: a GraphQL null arrives as Lua nil, so check
data, and the fields you read, before use. A successful call does not prove that
the GraphQL operation succeeded.
Interface and union selections retain their concrete alternatives. Selecting
__typename enables narrowing by its literal value. Conditional selections can
be absent even when the schema field is non-null. Variables follow input object,
enum, default and OneOf rules; recursive input schemas use bounded named type
references. Built-in scalars have known wire types. Custom scalars use their
@iac_scalar(carries:) metadata; an unmapped scalar remains any.
The starter below is checked against your instance schema. Its intermediate GraphQL
responses and the jobNotes output contract are inferred. Its input is typed
inline, and table.map preserves the note element types while adding presentation
data. The action keeps FormResult because its empty error array needs an explicit
element contract. No GraphQL result records are redeclared.
-- Add a Knowledge Note attached to the selected job; never overwrite its description.
exports.addJobNote = function(input: { entityId: string, body: string }): FormResult
local body = input.body:match("^%s*(.-)%s*$") or ""
if body == "" then
local errors = json.array()
errors[1] = {path="body", message="Write a note before saving."}
return {errors=errors}
end
local ok, response = ctx.graphql.query([[
mutation AddJobNote($input: CreateKnowledgeNoteInput!) {
result: createKnowledgeNote(input: $input) {
knowledgeNote { id }
errors { path message }
}
}
]], {input={kind="ENTITY", entityId=input.entityId, body=body, scope="TENANT"}})
if not ok then error(response) end
if response.errors and #response.errors > 0 then error(response.errors[1].message) end
if not response.data then error("GraphQL returned no data") end
return {errors=response.data.result.errors}
end
-- Keep storage vocabulary out of the interface.
exports.jobNotes = function(input: { id: string })
local ok, response = ctx.graphql.query([[
query JobNotes($id: ID!, $jobId: JobID!) {
job: getJob(id: $jobId) { id name }
notes: knowledgeNotes(first: 50, filter: {entityId: $id}, sort: [{field: CREATED_AT, direction: DESC}]) {
edges { node { id body scope createdAt } }
}
}
]], {id=input.id, jobId=input.id})
if not ok then error(response) end
if response.errors and #response.errors > 0 then error(response.errors[1].message) end
if not response.data then error("GraphQL returned no data") end
return {
job=response.data.job,
notes={edges=json.array(table.map(response.data.notes.edges, function(edge)
local note = edge.node
local visibility = "Everyone"
if note.scope == "PERSONAL" then visibility = "Only me" end
return {node={id=note.id, body=note.body, scope=note.scope,
createdAt=note.createdAt, visibility=visibility}}
end))},
}
endThe same analyzer checks Lua when you save a Workspace, validate a Script, validate Lua through MCP, or run IaC checks. It parses and validates operations without executing them, invoking resolvers, or changing authorization. Dynamic operations remain subject to ordinary runtime GraphQL validation and permissions. The request supports at most 64 distinct operations, 256 KiB per operation, 32,768 non-whitespace syntax bytes outside strings/comments, syntax nesting of 64, selection nesting of 32, and 8,192 expanded selections/types. Exceeding a bound produces a diagnostic.
The editor offers schema-driven completion and hover inside GraphQL query strings and checker-derived completion and hover on Lua results after guards and assignments. GraphQL diagnostics point into the original Lua literal, including escaped strings and long-bracket strings. Completion may be unavailable while surrounding Lua syntax is incomplete; rename/refactoring is not part of this service. Handler inputs remain explicitly typed. For a handler declared in XML without legacy JSON Schema children, the compiler derives its output contract from the checked return type, including inferred GraphQL result fields.
A read handler produces data. An action handler performs an explicit write or compound workflow. Separate GraphQL mutations are separate writes: wrapping them in one Lua handler does not create a database transaction. Use a single domain mutation when atomicity is required. Compensation is best-effort and can itself fail; report partial completion rather than claiming rollback. The GraphQL capability runs under the invoking user's permissions; a handler is not an authorization bypass. Handle transport and GraphQL errors before interpreting a successful domain payload.
This source implements the customer-workbench contact action. The complete form and handler contract appear in the recipe chapter.
local function trim(value)
return value:match("^%s*(.-)%s*$")
end
-- Compound input rules stay on the server. GraphQL still owns persistence,
-- tenant scoping, resource permissions, and authoritative domain validation.
exports.createContact = function(input)
local first, last = trim(input.firstName), trim(input.lastName)
local errors = json.array()
if first == "" then errors[#errors + 1] = {path="firstName", message="Enter a first name."} end
if last == "" then errors[#errors + 1] = {path="lastName", message="Enter a last name."} end
if #errors > 0 then return {record=json.null(), errors=errors} end
local contact = {customerId=input.customerId, firstName=first, lastName=last}
if input.email and not json.isnull(input.email) then
contact.email = trim(input.email):lower()
end
local ok, response = ctx.graphql.query([[
mutation CreateContact($input:CreateContactInput!) {
result:createContact(input:$input) {
contact {id firstName lastName email}
errors {path message}
}
}
]], {input=contact})
if not ok then error(response) end
if response.errors and #response.errors > 0 then error(response.errors[1].message) end
if not response.data then error("GraphQL returned no data") end
return {record=response.data.result.contact, errors=response.data.result.errors}
endReturn the declared result shape and path-addressed errors expected by the form. Use the provided JSON helpers when an empty array or JSON null must be preserved across Lua's table/nil boundary. Do not infer a successful write solely from the absence of an exception: check the business mutation's returned errors and record.
For read computations, the KPI recipe's handler asks GraphQL for a metric series. Its output contract covers both empty and populated results.
local function checked_query(document, variables)
local ok, response = ctx.graphql.query(document, variables)
if not ok then error(response) end
if response.errors and #response.errors > 0 then error(response.errors[1].message) end
if not response.data then error("GraphQL returned no data") end
return response.data
end
exports.addContact = function(input)
local function trim(value) return value:match("^%s*(.-)%s*$") end
local first, last, email = trim(input.firstName), trim(input.lastName), trim(input.email):lower()
if first == "" or last == "" then error("First and last names are required") end
if not email:match("^[^%s@]+@[^%s@]+%.[^%s@]+$") then error("Enter an email address") end
local data = checked_query([[
mutation AddContact($input: CreateContactInput!) {
result: createContact(input: $input) { contact { id } errors { message } }
}
]], {input = {customerId = input.customerId, firstName = first, lastName = last, email = email}})
if data.result.errors and #data.result.errors > 0 then error(data.result.errors[1].message) end
if not data.result.contact then error("Contact was not created") end
return {contactId = data.result.contact.id}
end
exports.qualitySummary = function(input)
return checked_query([[
query Quality($id: MetricID!) {
series: getMetricPoints(metricId: $id, limit: 24) { avg min max count points { value } }
}
]], {id = input.metricId})
endSource text is stored with the authoring artifact. Readers receive the compiled block definition, not the server Lua. Publication verifies the artifact against its source. Workspace authoring never runs JavaScript on the server.
Handler JSON Schema vocabulary
Ordinary schema nodes accept: additionalProperties, const, description, enum, items, maxItems, maxLength, maximum, minItems, minLength, minimum, properties, required, title, type.
Alternative nodes accept only: description, oneOf, title, type. oneOf requires 2–256 closed object branches, exactly one common required string discriminator, and unique string const values. Arbitrary schema unions are unsupported.
Schema nesting is limited to 16 levels. JSON is limited to 256 KiB and 24 levels. additionalProperties must be boolean. References, regular expressions, exclusiveMinimum, and unlisted keywords are rejected. Use inclusive minimum/maximum when they express the intended contract; enforce other business rules explicitly in the handler and return field errors.
Returning display values
The following display handler combines a name and formats a numeric amount with its currency code. This is display formatting, not money arithmetic or a locale-aware currency formatter. For schema queries, bind these returned fields just like ordinary GraphQL response fields.
<workspace id="formatted-customer" title="Customer summary" role="Sales" xmlns:t="urn:cococo:dataxml">
<handler name="summary" mode="read">
<input>
<schema type="object" xmlns="urn:cococo:json-schema:1">
<additional-properties boolean="false"/>
<property name="amount" type="number"/>
<property name="currency" type="string"/>
<property name="first" type="string"/>
<property name="last" type="string"/>
<required name="first"/>
<required name="last"/>
<required name="amount"/>
<required name="currency"/>
</schema>
</input>
<output>
<schema type="object" xmlns="urn:cococo:json-schema:1">
<additional-properties boolean="false"/>
<property name="balance" type="string"/>
<property name="name" type="string"/>
<required name="name"/>
<required name="balance"/>
</schema>
</output>
</handler>
<state name="first" default='"Ada"'/><state name="last" default='"Lovelace"'/>
<state name="amount" default="12.5"/><state name="currency" default='"GBP"'/>
<panel id="summary" title="Customer">
<query type="lua" handler="summary"><arg name="first" state="first"/><arg name="last" state="last"/><arg name="amount" state="amount"/><arg name="currency" state="currency"/></query>
<text id="name" t:text="name"/>
<text id="balance" t:text="balance"/>
</panel>
</workspace>exports.summary = function(input)
return {
name = input.first .. " " .. input.last,
balance = string.format("%.2f %s", input.amount, input.currency)
}
endBefore publishing, check how each business handler behaves with empty results, server errors and a partial write. A successful save proves the source is valid, not that the current user has permission for every write, or that separate mutations happen together.
Reporting a partial write
This action handler makes two independent writes. When customer creation succeeds but contact creation fails, the result retains the saved customer ID and reports partial success. Your workspace should offer recovery against that ID; blindly retrying the entire handler can create duplicate customers. A network failure can also leave the outcome unknown, so verify saved records before retrying. This example has no automatic compensation and promises no rollback.
<workspace id="partial-write" title="Compound write contract" role="Sales" xmlns:t="urn:cococo:dataxml">
<handler name="onboard" mode="action">
<input>
<schema type="object" xmlns="urn:cococo:json-schema:1">
<additional-properties boolean="false"/>
<property name="firstName" type="string"/>
<property name="lastName" type="string"/>
<property name="name" type="string"/>
<required name="name"/>
<required name="firstName"/>
<required name="lastName"/>
</schema>
</input>
<output>
<schema type="object" xmlns="urn:cococo:json-schema:1">
<additional-properties boolean="false"/>
<property name="complete" type="boolean"/>
<property name="customerId" type="string"/>
<property name="message" type="string"/>
<required name="complete"/>
<required name="customerId"/>
<required name="message"/>
</schema>
</output>
</handler>
<panel id="explanation" title="Handler fixture"><query type="graphql">query { __typename }</query></panel>
</workspace>local function record(document, variables, root, field)
local ok, response = ctx.graphql.query(document, variables)
if not ok then return nil, "Request failed; verify the saved records before retrying." end
if response.errors and #response.errors > 0 then return nil, response.errors[1].message end
local payload = response.data and response.data[root]
if not payload then return nil, "No mutation result returned." end
if payload.errors and #payload.errors > 0 then return nil, payload.errors[1].message end
local value = payload[field]
if not value then return nil, "No saved record returned." end
return value, nil
end
exports.onboard = function(input)
local customer, problem = record([[mutation($input:CreateCustomerInput!){createCustomer(input:$input){customer{id} errors{message}}}]], {input={name=input.name}}, "createCustomer", "customer")
if not customer then return {complete=false, customerId="", message=problem} end
local contact, failure = record([[mutation($input:CreateContactInput!){createContact(input:$input){contact{id} errors{message}}}]], {input={customerId=customer.id, firstName=input.firstName, lastName=input.lastName}}, "createContact", "contact")
if not contact then
return {complete=false, customerId=customer.id, message="Customer saved; contact not saved: " .. failure}
end
return {complete=true, customerId=customer.id, message="Customer and contact saved."}
endPublished platform manual · revision afba59e225d8