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.
pluginKeymust 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.nameis required (up to 200 characters);description(1000) andversion(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 path | Purpose |
|---|---|
GET /api/v1/admin/plugins | List installed plugins (includes config and script) |
GET /api/v1/admin/plugins/available | Keys of the built-in handlers this build ships |
POST /api/v1/admin/plugins | Install (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.
{
"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.
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
| Hook | Fires | Event fields |
|---|---|---|
onTicketEvent | After a ticket change commits | kind, ticketKey, actorId (string UUID, or null for system/anonymous changes) |
onComment | After a comment is added (internal user, or customer via portal or inbound email) and the change commits | kind (always COMMENTED), ticketKey, authorId (null for customer-authored comments), body, visibility (PUBLIC or INTERNAL) |
onSlaBreach | When a ticket breaches its response or resolution SLA | kind (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.
| Function | Effect |
|---|---|
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
addLabelwith 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,javaandPolyglotinterop namespaces do not exist. - No filesystem, network or process access —
fetch,requireandprocessare 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 —
eventandconfigare 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.
| Limit | Value | On breach |
|---|---|---|
| Statements per invocation | 100,000 | Invocation cancelled: "script exceeded its statement limit" |
| Wall-clock time per invocation | 5,000 ms | Context force-closed: "script exceeded its 5000ms time limit" |
| Actions per invocation | 10 | Further actions.addLabel calls silently ignored |
log() / label value length | 200 characters | Truncated |
| Script size | 20,000 characters | Rejected 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), andscript 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>.
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.