Plugins

Iskue's plugin system has two layers. The registry is a set of instance-wide extension records an administrator installs, enables, configures and uninstalls. On top of it, each record may carry a sandboxed JavaScript that runs in a locked-down GraalJS context whenever a ticket event fires. Scripts are deliberately narrow: they observe events, may write log lines, and can request exactly one kind of effect — attaching a label to the event's ticket. There is no marketplace and no arbitrary automation surface.

Plugins are an ENTERPRISE edition feature. With a licence installed, every request under /api/v1/admin/plugins on a lower edition is rejected with HTTP 403 and the body {"message":"This feature requires the ENTERPRISE edition"}. An instance with no licence installed is unrestricted — development and evaluation setups keep every feature.

The plugin registry

Each installed plugin is a row with a stable pluginKey, display metadata (name, description, version), an enabled flag, a free-form JSON config object, and an optional script. Two execution models hang off the same row: built-in handlers — trusted code shipped with the application, activated by installing a row whose key matches the handler — and admin-authored scripts, which may be attached to any installed row, including rows you create with your own key. One built-in handler ships today: event-logger, which logs each ticket change, comment and SLA breach while installed and enabled; its level config key ("info" or "debug", default info) controls the log level for ticket changes.

Nothing runs unless its registry row is both installed and enabled. Disabling a plugin stops its built-in handler and its script immediately; uninstalling removes the row entirely.

Managing plugins

Open /admin/plugins (requires the ADMIN role). The screen lists installed plugins with their key, version and enabled state, and offers per-plugin actions: Enable/Disable, Configure (edit the JSON config), Script (edit the sandboxed script) and Uninstall. An install form at the bottom registers a new plugin; built-in handler keys not yet installed appear as one-click + event-logger style shortcuts.

  • pluginKey must match [a-z0-9][a-z0-9.\-]{1,99} — lowercase letters, digits, dots and hyphens, 2–100 characters. It is the stable identity and cannot be changed after install.
  • name is required (up to 200 characters); description (1000) and version (40) are optional.
  • The Configure panel edits the row's JSON config; it must parse as a JSON object.
  • The Script panel edits the sandboxed JavaScript; saving an empty script clears it.

Every install, update and uninstall is recorded in the configuration audit trail under the PLUGIN category, so licence-relevant changes to the extension surface are traceable.

REST API

Method and pathPurpose
GET /api/v1/admin/pluginsList installed plugins (includes config and script)
GET /api/v1/admin/plugins/availableKeys of the built-in handlers this build ships
POST /api/v1/admin/pluginsInstall (register) a plugin
PATCH /api/v1/admin/plugins/{pluginKey}Toggle enabled, replace config, and/or set script
DELETE /api/v1/admin/plugins/{pluginKey}Uninstall

All three fields of the PATCH body are optional: omit a field (or send null) to leave it unchanged. A blank script clears the stored script; anything else replaces it. Scripts are capped at 20,000 characters.

PATCH /api/v1/admin/plugins/{pluginKey} — request body
{
  "enabled": true,
  "config": { "team": "support" },
  "script": "function onComment(event, config) {\n  if (event.body.toLowerCase().includes('urgent')) actions.addLabel('triage-urgent');\n}"
}

Writing a plugin script

On every hook event, the runtime evaluates the whole script top to bottom in a fresh context, then — if the script defined a function with the hook's name — calls it as hook(event, config). A script that does not define the current hook is a no-op for that event, so define only the hooks you care about. Four globals exist: event and config (read-only maps), log(message), and actions. Nothing survives between invocations: top-level variables are re-initialised every time.

A script may implement any subset of the three hooks
function onComment(event, config) {
  if (event.body.toLowerCase().includes('urgent')) {
    actions.addLabel('triage-urgent');
  }
}

function onTicketEvent(event, config) {
  if (event.kind === 'CREATED' && !event.actorId) {
    actions.addLabel('system-created');
  }
}

function onSlaBreach(event, config) {
  log('SLA breached on ' + event.ticketKey);
  actions.addLabel('sla-breached');
}

Hooks and event payloads

HookFiresEvent fields
onTicketEventAfter a ticket change commitskind, ticketKey, actorId (string UUID, or null for system/anonymous changes)
onCommentAfter a comment is added (internal user, or customer via portal or inbound email) and the change commitskind (always COMMENTED), ticketKey, authorId (null for customer-authored comments), body, visibility (PUBLIC or INTERNAL)
onSlaBreachWhen a ticket breaches its response or resolution SLAkind (always SLA_BREACHED), ticketKey

event.kind in onTicketEvent is one of CREATED, UPDATED, STATUS_CHANGED, ASSIGNED, COMMENTED, LABEL_ADDED. Note that a new comment fires both onTicketEvent (with kind COMMENTED) and onComment; only the latter carries the comment body and visibility. Portal and email comments arrive with visibility PUBLIC and a null authorId. Plugins are instance-wide — every enabled script runs for events from all projects — so branch on event.ticketKey (the project key is its prefix) if a script should act on some projects only.

config is the plugin row's JSON config, passed to every hook. Use it for tunable values — team names, label names, keyword lists — so behaviour can be changed from the Configure panel without editing the script.

What scripts can do

Be clear-eyed about the current scope: the action vocabulary is add-label only. Scripts never touch application services directly — they collect actions through the actions.* surface, and after the script returns, a separate applier performs them. That applier is the plugin runtime's only write path into the application, so a script's power is exactly the actions it implements, no more.

FunctionEffect
log(message)Writes one line to the application log at INFO, prefixed [plugin <pluginKey>]. Coerced to a string and truncated to 200 characters.
actions.addLabel(name)Requests that a label named name be attached to the event's ticket after the script returns.
  • Label names are matched case-insensitively against the project's existing labels; if none matches, the label is created in that project.
  • Attaching is idempotent — a label already on the ticket is skipped.
  • Names are trimmed; blank names are ignored; values are truncated to 200 characters — but label names are additionally capped at 60 characters by the schema, so an addLabel with a longer name fails at apply time (logged as a warning; no label is attached).
  • At most 10 actions are collected per invocation; further calls are silently dropped.
  • For an SLA-breach event that cannot be resolved to a concrete ticket, collected actions are discarded rather than applied.

A label added by a script does not re-fire any hook: the applier writes the label directly and publishes no ticket-changed event, so scripts cannot trigger themselves or each other in a loop.

Do not design processes that assume more than this. Scripts cannot call external services, send notifications, update fields, transition tickets, create tickets or comments, or schedule work — and there is no plugin marketplace. If a workflow needs more than labelling and logging, it does not belong in a plugin script today.

Sandbox and isolation

Scripts run in a GraalJS polyglot context (GraalJS community 24.1.2, interpreted on a stock JDK) created with all host access disabled. The sandbox is deny-by-default: the only capabilities a script has are the four injected globals, and the only inputs are immutable copies of the event and config maps.

  • No host classes — the Java, java and Polyglot interop namespaces do not exist.
  • No filesystem, network or process access — fetch, require and process are all undefined; there is no IO surface of any kind.
  • Fresh context per invocation — scripts are stateless and cannot leak data between events or between plugins.
  • Read-only inputs — event and config are immutable copies; mutating them cannot affect the application or other plugins.

Execution budgets

Two independent guards bound a runaway script. A statement limit cancels interpreted loops outright. A wall-clock watchdog — a daemon thread that force-closes the context when the time budget elapses — bounds total time and catches work the statement counter cannot see: a single built-in call over a huge input advances the counter by one while running arbitrarily long. Either guard tripping fails that one invocation; the next event starts clean.

LimitValueOn breach
Statements per invocation100,000Invocation cancelled: "script exceeded its statement limit"
Wall-clock time per invocation5,000 msContext force-closed: "script exceeded its 5000ms time limit"
Actions per invocation10Further actions.addLabel calls silently ignored
log() / label value length200 charactersTruncated
Script size20,000 charactersRejected at save time

There is no hard per-context memory cap on the community GraalVM runtime (that requires GraalVM Enterprise); the time and statement limits bound how much a script can allocate before it is cancelled. Also note that all enabled scripts run for every hook event, each with its own 5-second budget — keep the set of enabled scripted plugins small and the scripts fast.

Error handling and logging

Ticket-change and comment hooks are dispatched after the triggering transaction commits, and every plugin invocation is isolated: a syntax error, thrown exception, sandbox violation or budget breach is caught, logged and swallowed. A failing script can never roll back or break the operation that triggered it, and never prevents other plugins from running.

  • Script failures surface with safe, self-describing messages: script error: … (thrown JS errors and denied host access), script exceeded its statement limit (possible infinite loop), script exceeded its 5000ms time limit (possible infinite loop or heavy computation), and script rejected: …. Cause chains from plugin code are deliberately discarded.
  • Failures are logged at WARN as Script plugin '<pluginKey>' failed handling <hook> on <ticketKey>: <message>.
  • log() output appears at INFO as [plugin <pluginKey>] <message>.
Example application-log lines from a scripted plugin
INFO  ... [plugin triage-urgent] SLA breached on SUP-42
WARN  ... Script plugin 'triage-urgent' failed handling onComment on SUP-42: script exceeded its 5000ms time limit (possible infinite loop or heavy computation)

To verify the runtime end to end, install and enable the built-in event-logger plugin and watch the application log while changing a ticket — then attach a small script to a plugin of your own and confirm its log() lines and labels appear.