Skip to content

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 ​

  1. Create a draft (create_integration_draft) — always v2
  2. Edit handlers and lifecycle on the manifest (update_integration_manifest), plus any helper files (update_integration_file)
  3. Validate (validate_integration)
  4. 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 pointentryScript (filename)none
Hooksexports.init / exports.on_timer / exports.on_event / exports.shutdown on the entry scriptper-handler inline source on each timer + subscription, plus optional initSource / shutdownSource / upgradeSource on the manifest
Handler dispatchglobal exports.<hook> lookupeach handler is its own Lua chunk; arguments arrive as varargs (local name, config = ...)
Reusable codescripts/<name>.lua files loaded globallymanifest.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):

HookArgs
initSource, shutdownSourceconfig
timer sourcename, config
upgradeSourceoldVersion, 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")
end

Example 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 query
  • ctx.script.load(name) — load a reusable Lua snippet. Lookup order: manifest.libraries[name], this bundle's scripts/<name>.lua, a bound script resource whose resource id is name, 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 in manifest.libraries; for code the operator supplies per instance (a custom mapper), declare a script resource and bind it.
  • ctx.json.decode(str) — parse JSON string into Lua table
  • ctx.json.encode(table) — serialize a Lua table to a JSON string
  • ctx.log.info(msg), ctx.log.warn(msg), ctx.log.error(msg) — structured logging
  • ctx.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. set takes a Lua table.
  • ctx.integration.cacheGet(key), ctx.integration.cacheSet({key, value, ttl?}), ctx.integration.cacheDelete(key) — per-instance Redis cache, same shape
  • ctx.integration.containersGet(schemaName, name), containersSet({schemaName, name, data}), containersList, containersQuery, containersDelete — integration data containers
  • ctx.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 ​

FieldTypeDescription
idstringReverse domain notation (e.g., com.acme.erp-connector)
versionstringSemantic version (e.g., 1.0.0)
sdkVersionstringPlatform compatibility (e.g., 1.0)
descriptionstringHuman-readable description
runtimeModestringSCRIPT_ACTOR (default) or BUNDLE
resourcesarrayExternal system requirements (see below)
permissionsarrayCasbin scopes (e.g., job:read:*)
subscriptionsarrayEvent subscriptions — each entry has topic, optional filter, and inline source (v2)
timersarrayTimer definitions — each entry has name, every/cron, optional jitter, and inline source (v2)
initSourcestring(v2) Optional Lua chunk run once on instance start. Receives config as varargs.
shutdownSourcestring(v2) Optional Lua chunk run once on instance stop. Receives config as varargs.
upgradeSourcestring(v2) Optional Lua chunk run during instance upgrade. Receives (oldVersion, newVersion, oldConfig) as varargs; may return a new config table.
librariesobject(v2) Map of name → Lua source. Loaded by handlers via ctx.script.load(name).
entryScriptstring(v1 only, deprecated) Filename of the entry script (e.g., main.lua). Ignored at engineVersion 2.
timeoutMsintegerMaximum 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.

typeBinds toReached by
protocol/http, protocol/mqtt, database/sql, …a Devicectx.device.*
templateany Templatectx.template.render
template/STENCIL, template/XMLPDF, template/XMLa Template of that kindctx.template.render
scripta Script whose role is SCRIPTctx.script.load
script/JDF_TEMPLATE, script/JMF_TEMPLATEa Script of that rolectx.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:

  1. 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.
  2. Stop the old runtime so the upgrade hook has exclusive access to integration data containers.
  3. 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.
  4. Post-hook validation — the resulting config is re-checked against the new schema. A bad return puts the instance into .error for fix-forward.
  5. Swap definitionId/version/config/bindings on the row.
  6. 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).

PathBundle Field
scripts/<name>.luabundle.scripts["<name>.lua"] — loaded via ctx.script.load("<name>") (after manifest.libraries)
config_schema.jsonbundle.configSchema
policy.yamlbundle.policy
workflows/sync.yamlbundle.workflows["sync.yaml"]
app/index.htmlbundle.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.

KeywordWhereNotes
typeproperty + rootOne of string, number, boolean, array, object. Integers count as number.
propertiesobjectMap of property name → property schema.
requiredobjectArray of property names that must be present.
additionalPropertiesobjectfalse rejects unknown keys; default allows them. Use false on the root for a strict, typo-proof config.
patternstringECMAScript regex; the value must match somewhere in the string.
itemsarraySchema each element must satisfy (objects only — primitive item schemas are not enforced today).
description / titleanyFree-form documentation. Surfaced verbatim in error messages and forms.
defaultanyDocumentation 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: false on the root object so typo'd keys fail loudly on install instead of silently going nowhere.
  • Prefer pattern over runtime string parsing for things like URL shape, ID prefixes, or environment names — the error message points at the exact field.
  • Fill in description on 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 draft
  • read_integration_draft(id) — read full draft content
  • list_integration_drafts() — list all drafts
  • update_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 handler source strings live: initSource, shutdownSource, upgradeSource, libraries, plus per-entry source on each timer/subscription)
  • validate_integration(draftId) — check for errors
  • publish_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_...")

Connect machines, automate workflows, and build custom apps for print production.