Appearance
Integration Builder Guide
Build custom integrations using the Script Actor runtime — a lightweight execution model where each timer, subscription, and lifecycle hook is its own small Lua chunk declared inline on the manifest.
Concepts
Integration Definition
An immutable, versioned package containing everything needed to run an integration:
- Manifest: metadata, resource requirements, permissions, event subscriptions (with inline handlers), timers (with inline handlers), and optional
initSource/shutdownSource/upgradeSource/libraries - Config Schema: JSON Schema describing what settings users provide on install
- Policy: Optional Casbin policy template for fine-grained access control
- Files: helper scripts, workflows, and custom-app assets
Integration Instance
A live installation of a definition. Users bind resources (devices, databases) and provide configuration values. The instance's script actor runs the manifest's timers and subscriptions, and may be upgraded to a newer published definition.
Draft Workflow
- Create a draft (
create_integration_draft) — always v2 - Edit handlers and lifecycle on the manifest (
update_integration_manifest), plus any helper files (update_integration_file) - Validate (
validate_integration) - Publish (
publish_integration) — freezes the definition as ACTIVE
Engine Versioning (v1 vs. v2)
Definitions carry an engineVersion: Int (1 or 2). New drafts are always created at v2 — the model below describes v2 unless explicitly noted. v1 definitions stay runnable but are read-only in the builder; cross-engine v1→v2 upgrades are supported via upgradeIntegrationInstance (see "Instance Upgrades" below).
| v1 (legacy, frozen) | v2 (current default) | |
|---|---|---|
| Entry point | entryScript (filename) | none |
| Hooks | exports.init / exports.on_timer / exports.on_event / exports.shutdown on the entry script | per-handler inline source on each timer + subscription, plus optional initSource / shutdownSource / upgradeSource on the manifest |
| Handler dispatch | global exports.<hook> lookup | each handler is its own Lua chunk; arguments arrive as varargs (local name, config = ...) |
| Reusable code | scripts/<name>.lua files loaded globally | manifest.libraries: { name → source } or the bundle's own scripts/<name>.lua, loaded via ctx.script.load(name). No tenant-Script fallback: an integration loads only code it ships. |
New integrations should follow v2. The rest of this guide assumes v2 unless marked (v1).
Script Actor Mode (v2)
In v2 there is no entry script. Each timer and subscription declares its Lua handler inline as source on the manifest. Optional top-level initSource / shutdownSource / upgradeSource attach lifecycle chunks. Each chunk is compiled once when the instance starts and evaluated with the hook arguments passed as varargs (...) — the chunk pulls them out by hand. There is no exports table indirection.
Argument shapes (varargs):
| Hook | Args |
|---|---|
initSource, shutdownSource | config |
timer source | name, config |
upgradeSource | oldVersion, newVersion, oldConfig |
Example timer handler — pull the args at the top, then write your logic:
lua
local name, config = ...
if name == "sync_check" then
local ok, result = ctx.graphql.query([[
query { listJobs(first: 10) { edges { node { id name } } } }
]])
if not ok then error(result) end
ctx.log.info("Synced " .. #result.data.listJobs.edges .. " jobs")
endExample initSource:
lua
local config = ...
ctx.log.info("Starting with API: " .. config.api_url)v1 lifecycle hooks (legacy, read-only)
v1 integrations populated a global exports table on the entry script and the runtime dispatched lifecycle events via exports.<hook> lookup. v1 instances continue to run unchanged, but you can't author new v1 drafts through the builder or this MCP. (RPC server scripts — server.lua — are a separate surface that still uses the exports table on both engines.)
Available Lua APIs
ctx.graphql.query(query, vars)— execute a GraphQL queryctx.script.load(name)— load a reusable Lua snippet. Lookup order:manifest.libraries[name], this bundle'sscripts/<name>.lua, a boundscriptresource whose resource id isname, then plugin libraries (@plugin/lib). An integration loads ONLY code it ships or was granted: tenant Scripts are NOT reachable by name, by design. Put shared code inmanifest.libraries; for code the operator supplies per instance (a custom mapper), declare ascriptresource and bind it.ctx.json.decode(str)— parse JSON string into Lua tablectx.json.encode(table)— serialize a Lua table to a JSON stringctx.log.info(msg),ctx.log.warn(msg),ctx.log.error(msg)— structured loggingctx.integration.getConfig()— the install-time configuration of this instance, returned as(ok, table)ctx.cache.get(key),ctx.cache.set({key, value, ttl?}),ctx.cache.delete(key)— tenant-scoped key-value cache.settakes a Lua table.ctx.integration.cacheGet(key),ctx.integration.cacheSet({key, value, ttl?}),ctx.integration.cacheDelete(key)— per-instance Redis cache, same shapectx.integration.containersGet(schemaName, name),containersSet({schemaName, name, data}),containersList,containersQuery,containersDelete— integration data containersctx.device.http(resourceId, opts),ctx.device.sql(resourceId, opts)— call a bound device. The first argument is a manifest resource id (e.g."api_device"), resolved to the bound DeviceID via the install-time bindings. Literal Device ids and identifiers are rejected.ctx.template.render(resourceId, vars)— render a bound Template. The first argument is a manifest resource id, NOT a template handle.ctx.jdf.loadTemplate(resourceId),ctx.jmf.loadTemplate(resourceId)(print vertical) — load a bound JDF/JMF-role Script by resource id, NOT by Script name.
Manifest Fields
| Field | Type | Description |
|---|---|---|
| id | string | Reverse domain notation (e.g., com.acme.erp-connector) |
| version | string | Semantic version (e.g., 1.0.0) |
| sdkVersion | string | Platform compatibility (e.g., 1.0) |
| description | string | Human-readable description |
| runtimeMode | string | SCRIPT_ACTOR (default) or BUNDLE |
| resources | array | External system requirements (see below) |
| permissions | array | Casbin scopes (e.g., job:read:*) |
| subscriptions | array | Event subscriptions — each entry has topic, optional filter, and inline source (v2) |
| timers | array | Timer definitions — each entry has name, every/cron, optional jitter, and inline source (v2) |
| initSource | string | (v2) Optional Lua chunk run once on instance start. Receives config as varargs. |
| shutdownSource | string | (v2) Optional Lua chunk run once on instance stop. Receives config as varargs. |
| upgradeSource | string | (v2) Optional Lua chunk run during instance upgrade. Receives (oldVersion, newVersion, oldConfig) as varargs; may return a new config table. |
| libraries | object | (v2) Map of name → Lua source. Loaded by handlers via ctx.script.load(name). |
| entryScript | string | (v1 only, deprecated) Filename of the entry script (e.g., main.lua). Ignored at engineVersion 2. |
| timeoutMs | integer | Maximum Lua execution time in milliseconds for every script path of this integration (script-actor hooks, RPC handlers, action executor). Optional; default 5000, hard-capped at 60000. Set this when the integration talks to a slow upstream and would otherwise blow the 5s default. |
Resources
A resource is a declaration of something the integration may touch. The instance binds each one to a concrete entity in the installing tenant; handlers name the resource id and never the entity. That is what makes an integration portable across tenants — and it can reach nothing nobody bound.
json
{ "id": "erp_api", "type": "protocol/http", "description": "ERP REST API", "optional": false }
{ "id": "label", "type": "template/STENCIL" }
{ "id": "ticket", "type": "script/JDF_TEMPLATE" }
{ "id": "mapper", "type": "script" }Reference one from a handler by id — ctx.device.http("erp_api", { method = "GET", path = "/health" }) — and the bound entity is swapped in at runtime.
The type vocabulary
type is kind or kind/SUB_KIND. The kind decides which capability can resolve the binding; the sub-kind, where the kind has one, is the entity's own existing discriminator.
type | Binds to | Reached by |
|---|---|---|
protocol/http, protocol/mqtt, database/sql, … | a Device | ctx.device.* |
template | any Template | ctx.template.render |
template/STENCIL, template/XMLPDF, template/XML | a Template of that kind | ctx.template.render |
script | a Script whose role is SCRIPT | ctx.script.load |
script/JDF_TEMPLATE, script/JMF_TEMPLATE | a Script of that role | ctx.jdf/jmf.loadTemplate |
Anything whose leading segment is not a known kind is a device — that is why protocol/http and database/sql keep working unchanged, and why a device declaration's second segment names a transport rather than a sub-kind. Omitting the sub-kind means the kind's default: bare script is script/SCRIPT; bare template accepts any TemplateKind.
Bindings are validated when the instance is created, updated or upgraded. A binding whose TypeID prefix does not match the declared kind, whose entity does not exist in the tenant, or whose role/kind does not match a declared sub-kind, is a changeset error naming the resource id.
Binding is an authorization decision at install time, not a guarantee about content. A bound Script or Template can be edited afterwards by anyone with tenant access. Granting a binding grants that entity's future contents too.
Subscriptions (v2)
Subscription handlers are left out of this guide for now: on this release the handler's event argument does not carry the event's topic or record, so a handler cannot tell what happened. Use a timer, or a workflow with an event trigger and a dataQuery, until this is fixed.
Timers (v2)
json
{
"name": "sync_check",
"every": "300s",
"jitter": "10s",
"source": "local name, config = ...\n-- run every 5 minutes ± 10s\n"
}
{ "name": "daily_report", "cron": "0 8 * * *", "source": "local name, config = ...\n-- ..." }Libraries (v2)
json
"libraries": {
"http_helpers": "local M = {}\nfunction M.json_post(...) end\nreturn M\n"
}Handlers load with local helpers = ctx.script.load("http_helpers").
Instance Upgrades
Existing instances move to a newer published version of the same integration via the upgradeIntegrationInstance mutation. The flow:
- Pre-stop validation — the operator's candidate config (current config with optional overrides) is validated against the new manifest's schema. Operator-side errors here bail with the old runtime untouched.
- Stop the old runtime so the upgrade hook has exclusive access to integration data containers.
- Run
upgradeSource(if declared on the new manifest) with the new bundle in scope. The chunk receives(oldVersion, newVersion, oldConfig)as varargs and may return a Lua table that becomes the new config. - Post-hook validation — the resulting config is re-checked against the new schema. A bad return puts the instance into
.errorfor fix-forward. - Swap
definitionId/version/config/bindingson the row. - Start the runtime against the new definition.
Author hooks defensively — they can mutate integration containers via ctx.integration.containersGet / containersSet with exclusive access, but anything raising will leave the instance in .error. The hook should be a small, focused config/data migration; long-running work belongs in initSource on the new version.
File Path Conventions
In v2, lifecycle and per-feature handler bodies are NOT files — they are inline source strings on the manifest, set via update_integration_manifest. The file paths below cover the remaining artefacts (config schema, policy, workflows, custom-app files, and any helper scripts you choose to keep as files instead of manifest.libraries).
| Path | Bundle Field |
|---|---|
scripts/<name>.lua | bundle.scripts["<name>.lua"] — loaded via ctx.script.load("<name>") (after manifest.libraries) |
config_schema.json | bundle.configSchema |
policy.yaml | bundle.policy |
workflows/sync.yaml | bundle.workflows["sync.yaml"] |
app/index.html | bundle.customAppFiles["app/index.html"] |
(v1 only) scripts/main.lua was the entry script — the runtime looked up exports.<hook> against it. v2 does not use this path.
Config Schema (config_schema.json)
The config schema declares what settings an installer must provide when creating an integration instance from your definition. The platform validates the instance's config object against this schema at create and update time and rejects writes that don't conform — so a well-shaped schema is what makes "Install" / "Edit settings" feel like a typed form instead of a free-text JSON blob.
The file is plain JSON — a single JSON Schema (draft-07 style) describing the top-level config object. Keep it as a JSON object ({ "type": "object", ... }), not an array or scalar.
Supported keywords
The platform's validator implements a focused subset of JSON Schema. Stick to these — anything else is silently ignored at validation time and gives a false sense of safety.
| Keyword | Where | Notes |
|---|---|---|
type | property + root | One of string, number, boolean, array, object. Integers count as number. |
properties | object | Map of property name → property schema. |
required | object | Array of property names that must be present. |
additionalProperties | object | false rejects unknown keys; default allows them. Use false on the root for a strict, typo-proof config. |
pattern | string | ECMAScript regex; the value must match somewhere in the string. |
items | array | Schema each element must satisfy (objects only — primitive item schemas are not enforced today). |
description / title | any | Free-form documentation. Surfaced verbatim in error messages and forms. |
default | any | Documentation only — the runtime does not auto-fill missing values. |
Keywords like enum, minimum/maximum, minLength/maxLength, format, oneOf, anyOf, $ref are not enforced. If you need them, validate inside initSource (or the v1 exports.init) and fail fast.
Masking a secret
The config-schema validator that runs when a schema is saved through the builder (update_integration_file on config_schema.json) or createIntegrationDefinition refuses keys it does not know, x-* form extensions included. "format": "password" is the supported way to mask a secret:
json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"required": ["api_url", "api_key"],
"properties": {
"api_url": {
"type": "string",
"title": "API URL",
"description": "Base URL of the upstream API."
},
"api_key": {
"type": "string",
"title": "API key",
"format": "password"
}
}
}Reading config from Lua
Every v2 handler receives the full config table as one of its varargs, and the whole table is also available via ctx.integration.getConfig():
lua
local config = ... -- initSource / shutdownSource
local apiUrl = config.api_url -- direct table access
local ok, cfg = ctx.integration.getConfig() -- async: returns (ok, table)
local timeout = (ok and cfg.timeout_ms) or 5000
ctx.log.info("Starting with API: " .. apiUrl)Secrets are stored separately: set them with the setIntegrationInstanceSecret mutation — a config update cannot carry them. Mark secret fields with "format": "password" so the install form masks the input. At runtime the value arrives in the same config table as a plain string, so don't log it.
Worked examples
Minimal — a single API base URL:
json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"required": ["api_url"],
"properties": {
"api_url": {
"type": "string",
"pattern": "^https?://",
"description": "Base URL of the upstream API (must include scheme)."
}
}
}Typed connector with optional tuning + nested credentials:
json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"required": ["base_url", "credentials"],
"properties": {
"base_url": { "type": "string", "pattern": "^https://" },
"timeout_ms": { "type": "number", "default": 5000, "description": "Per-request timeout (ms)" },
"verify_tls": { "type": "boolean", "default": true },
"credentials": {
"type": "object",
"additionalProperties": false,
"required": ["client_id", "client_secret"],
"properties": {
"client_id": { "type": "string" },
"client_secret": { "type": "string", "description": "OAuth client secret. Treat as sensitive." }
}
}
}
}Repeating items — list of webhook subscriptions:
json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"required": ["webhooks"],
"properties": {
"webhooks": {
"type": "array",
"description": "Outbound webhooks to fan job events out to.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["url", "event"],
"properties": {
"url": { "type": "string", "pattern": "^https://" },
"event": { "type": "string", "description": "e.g. job.created, job.updated" },
"secret": { "type": "string" }
}
}
}
}
}Authoring tips
- Ship your schema before writing handlers. Once the shape is declared, every handler can trust the input.
- Set
additionalProperties: falseon the root object so typo'd keys fail loudly on install instead of silently going nowhere. - Prefer
patternover runtime string parsing for things like URL shape, ID prefixes, or environment names — the error message points at the exact field. - Fill in
descriptionon every property. The descriptions are the only docs your installers see in the UI form, and they show up verbatim in validation errors.
MCP Tools
create_integration_draft(integrationId, version)— start a new v2 draftread_integration_draft(id)— read full draft contentlist_integration_drafts()— list all draftsupdate_integration_file(draftId, path, content)— create/update/delete a file (config schema, policy, workflows, custom-app files, helper scripts)update_integration_manifest(draftId, manifest)— merge manifest fields (this is where v2 handlersourcestrings live:initSource,shutdownSource,upgradeSource,libraries, plus per-entrysourceon each timer/subscription)validate_integration(draftId)— check for errorspublish_integration(draftId)— validate and publish as ACTIVE
Example: Build a Simple Sync Integration (v2)
1. create_integration_draft(
integrationId: "com.acme.order-sync",
version: "0.1.0"
)
2. update_integration_file(
draftId: "igd_...",
path: "config_schema.json",
content: '{"type":"object","required":["api_url"],
"properties":{"api_url":{"type":"string","pattern":"^https://"}},
"additionalProperties":false}'
)
3. update_integration_manifest(
draftId: "igd_...",
manifest: '{
"description": "Pulls fresh orders every minute",
"initSource": "local config = ...\nctx.log.info(\"sync starting against \" .. config.api_url)",
"timers": [
{ "name": "sync", "every": "60s",
"source": "local name, config = ...\n-- pull /orders, write to ctx.integration.containersSet ..." }
]
}'
)
4. validate_integration(draftId: "igd_...")
5. publish_integration(draftId: "igd_...")