# Plugin development

Source: https://iskue.com/docs/plugin-development/

> Build Java plugins for Iskue in the Jira Data Center model: the alpha plugin API 0.3 (REST endpoints, events, work item panels, workflow rules, TQL functions, own tables, search and settings) and what is planned next.

> **Warning:** **Alpha — plugin API 0.3.** Java plugins run on Iskue today as an internal alpha. The API is still 0.x, so names and signatures can change before 1.0, and the `iskue-plugin-api` artefact is not published yet: design partners get the API and a sample plugin on request ([get in touch](https://iskue.com/contact/)). Uploading plugins is **off by default** and stays off on Iskue's hosted instances until signed packages ship. [Available now](https://iskue.com/docs/plugin-development#available-now-api-0-3/) is what you can build today; [Planned](https://iskue.com/docs/plugin-development#planned/) is the rest of the platform.

Jira Data Center lets partners and customers build apps that run inside Jira. Iskue follows the same model: a plugin is one JAR that runs inside Iskue, adds its own **REST endpoints**, reacts to **work-item changes**, shows a **panel on the work item page**, adds **workflow conditions, validators and post-functions** and **TQL functions**, keeps **its own tables**, and works with work items, search, projects and people through a versioned Java API. Field types, background jobs and a marketplace for Java plugins follow. If you have built a Jira Data Center app, the [mapping table](https://iskue.com/docs/plugin-development#coming-from-jira-data-center/) shows where each concept lands.

## Three ways to extend Iskue

| Kind | Runs | Written in | For | Status |
| --- | --- | --- | --- | --- |
| **Java plugins** | Inside Iskue, like Jira Data Center apps | Java 21 (plus JavaScript for the UI) | Partners porting Data Center apps, internal platform teams | **Alpha** — API 0.3, described below |
| **Script plugins** | In a locked-down JavaScript sandbox inside Iskue | JavaScript | Administrators and customers automating their instance | Available today ([Plugins](https://iskue.com/docs/plugins/)); will gain the same descriptor, read access, storage and allow-listed HTTP |
| **Remote apps** | On the vendor's own servers | Any language | SaaS vendors | Planned, after Java plugins |

All three will share **one descriptor format**, one administration console and one marketplace.

## Available now: API 0.3

A Java plugin is a JAR with an `iskue-plugin.yaml` descriptor at its root, built for **Java 21**. Iskue gives each plugin its own class loader and its own Spring context on every application server. Plugin code sees exactly one Iskue package, **`com.iskue.plugin.api`**, plus a short list of shared libraries; the rest of Iskue is invisible to it, so a plugin cannot come to depend on internals by accident.

### A first plugin

This plugin lists the work items of a project that nobody has touched for a while, lets project administrators choose how long "a while" is, and nudges the stale ones with a comment. It also raises the priority of any new work item whose summary mentions an outage, and shows the stale list in a panel on the work item page. Its [workflow validator](https://iskue.com/docs/plugin-development#workflow-rules/) and [TQL function](https://iskue.com/docs/plugin-development#tql-functions/) are shown further down.

*`src/main/resources/iskue-plugin.yaml`*

```yaml
key: acme.stale-work
name: Stale work
version: 1.0.0
description: Finds, nudges and guards work items nobody has touched for a while, and raises new outages.
vendor: { name: Acme, url: https://acme.example }
apiVersion: "[0.3,1.0)"          # the plugin API versions it was built for
runtime: java
scopes: [read:ticket, write:ticket, write:comment, read:project]
modules:
  - { type: rest, key: api, class: com.acme.stale.StaleResource }
  - { type: event-listener, key: outages, class: com.acme.stale.OutageListener }
  - type: ticket-panel
    key: stale-panel
    entry: web/stale-panel.js#StalePanel
    title: { en: Stale work, fr: Travail en sommeil }
  - type: workflow-validator
    key: needs-description
    class: com.acme.stale.NeedsDescription
    title: { en: Needs a description, fr: Exige une description }
  - type: tql-function
    key: stale-in
    name: staleIn                  # used as  key IN staleIn(OPS)
    class: com.acme.stale.StaleFunction
    title: { en: Stale work items of a project, fr: Éléments en sommeil d'un projet }
```

*A `rest` module, served under `/api/plugins/acme.stale-work/…`*

```java
package com.acme.stale;

import com.iskue.plugin.api.PluginApiException;
import com.iskue.plugin.api.PluginApiException.Reason;
import com.iskue.plugin.api.cluster.ClusterLock;
import com.iskue.plugin.api.permission.PermissionApi;
import com.iskue.plugin.api.project.ProjectApi;
import com.iskue.plugin.api.project.ProjectView;
import com.iskue.plugin.api.search.SearchApi;
import com.iskue.plugin.api.settings.SettingsApi;
import com.iskue.plugin.api.ticket.TicketApi;
import com.iskue.plugin.api.ticket.TicketView;
import java.util.List;
import java.util.Map;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/projects/{projectKey}")
public class StaleResource {

    public record Threshold(int days) {
    }

    private final ProjectApi projects;
    private final SearchApi search;
    private final TicketApi tickets;
    private final SettingsApi settings;
    private final PermissionApi permissions;
    private final ClusterLock lock;

    public StaleResource(ProjectApi projects, SearchApi search, TicketApi tickets,
                         SettingsApi settings, PermissionApi permissions, ClusterLock lock) {
        this.projects = projects;
        this.search = search;
        this.tickets = tickets;
        this.settings = settings;
        this.permissions = permissions;
        this.lock = lock;
    }

    /** GET …/projects/OPS/stale: the stale work items' keys, oldest first. */
    @GetMapping("/stale")
    public List<String> stale(@PathVariable("projectKey") String projectKey) {
        return search.search(staleQuery(project(projectKey)), 0, SearchApi.MAX_PAGE_SIZE)
                .tickets().stream().map(TicketView::key).toList();
    }

    /** PUT …/projects/OPS/threshold {"days": 14}: project administrators only. */
    @PutMapping("/threshold")
    public Threshold setThreshold(@PathVariable("projectKey") String projectKey,
                                  @RequestBody Threshold body) {
        ProjectView project = project(projectKey);
        if (!permissions.canAdminister(project.key())) {
            throw new PluginApiException(Reason.FORBIDDEN, "Only a project administrator can change this.");
        }
        if (body.days() < 1 || body.days() > 365) {
            throw new PluginApiException(Reason.INVALID, "Choose between 1 and 365 days.");
        }
        settings.set(project.key(), "stale.days", Integer.toString(body.days()));
        return body;
    }

    /** POST …/projects/OPS/nudge: one comment per stale work item, one run at a time in the cluster. */
    @PostMapping("/nudge")
    public Map<String, Object> nudge(@PathVariable("projectKey") String projectKey) {
        ProjectView project = project(projectKey);
        int[] nudged = {0};
        boolean ran = lock.tryRun("nudge-" + project.key(), () -> {
            for (TicketView t : search.search(staleQuery(project), 0, 50).tickets()) {
                tickets.comment(t.key(), "Nothing has moved here for a while. Is this still in progress?");
                nudged[0]++;
            }
        });
        return Map.of("ran", ran, "nudged", nudged[0]);
    }

    private ProjectView project(String key) {
        return projects.get(key)
                .orElseThrow(() -> new PluginApiException(Reason.NOT_FOUND, "No project " + key));
    }

    private String staleQuery(ProjectView project) {
        int days = settings.get(project.key(), "stale.days").map(Integer::parseInt).orElse(30);
        return "project = " + project.key() + " AND statusCategory != Done AND updated < -" + days + "d"
                + " ORDER BY updated ASC";
    }
}
```

*An `event-listener` module*

```java
package com.acme.stale;

import com.iskue.plugin.api.event.TicketEvent;
import com.iskue.plugin.api.event.TicketEventListener;
import com.iskue.plugin.api.ticket.TicketApi;
import com.iskue.plugin.api.ticket.TicketUpdate;

/** Raises the priority of a new work item whose summary mentions an outage. */
public class OutageListener implements TicketEventListener {

    private final TicketApi tickets;

    public OutageListener(TicketApi tickets) {
        this.tickets = tickets;
    }

    @Override
    public void onTicketEvent(TicketEvent event) {
        if (!"CREATED".equals(event.kind())) {
            return; // UPDATED, STATUS_CHANGED, ASSIGNED, COMMENTED, … — ignore kinds you do not know
        }
        tickets.get(event.ticketKey())
                .filter(t -> t.summary().toLowerCase().contains("outage"))
                .filter(t -> !"HIGHEST".equalsIgnoreCase(t.priority()))
                .ifPresent(t -> tickets.update(t.key(), TicketUpdate.none().withPriority("HIGHEST")));
    }
}
```

There is no registration code: every module class named in the descriptor becomes a Spring bean, and the API services it needs arrive through its constructor. List your own packages under `scan` to have further `@Component` classes picked up. The panel is shown [below](https://iskue.com/docs/plugin-development#a-panel-on-the-work-item-page/).

### The descriptor

| Field | Required | Meaning |
| --- | --- | --- |
| `key` | Yes | Lowercase `vendor.name`, such as `acme.stale-work`, at most 64 characters. The `iskue.` namespace is reserved. |
| `name` | Yes | Display name, up to 200 characters. |
| `version` | Yes | The plugin's own semantic version, such as `1.0.0`. |
| `apiVersion` | Yes | The plugin API versions the plugin was built for, as a range: `"[0.3,1.0)"`. Iskue refuses a plugin whose range does not include its own API version. |
| `description` | No | Up to 1,000 characters, shown to administrators. |
| `vendor` | No | `{ name, url }`. |
| `runtime` | No | `java`, the only value for now. |
| `scopes` | No | The access the plugin asks for ([below](https://iskue.com/docs/plugin-development#scopes/)). Administrators see them before enabling it. |
| `storage` | No | `migrations` (default `db/migration`) and `tables`: each table the migrations create, as `reference` or `ticket`. |
| `scan` | No | Your own Java packages, scanned for further Spring components. |
| `modules` | Yes | At least one module: `type`, `key` (lowercase letters, digits and hyphens), plus `class` for a Java module or `entry` for a panel. Panels and workflow rules need a `title` (in English and French), shown in the interface; a TQL function needs a `name`, its camelCase name in TQL. |

Unknown fields are refused, so a typo fails at upload instead of being ignored. Iskue checks the descriptor and the JAR together and lists every problem at once.

### Module types

| Module | You implement | What it does |
| --- | --- | --- |
| `rest` | A Spring `@RestController` | Endpoints under `/api/plugins/{key}/…`. Callers are always signed in, and the plugin acts as the caller. |
| `event-listener` | `event.TicketEventListener` | Called once for each committed work-item change, on the server where it happened, on a bounded background pool — never on the request thread. The event carries the work item's key, its project, the kind of change (`CREATED`, `UPDATED`, `STATUS_CHANGED`, `ASSIGNED`, `COMMENTED`, `LABEL_ADDED`, … an open set) and who made it. Exceptions are logged and never undo the change. |
| `lifecycle` | `lifecycle.PluginLifecycle` | `onEnable()` and `onDisable()` on each server. An exception in `onEnable()` marks the plugin failed on that server, and nothing of it is served there. |
| `ticket-panel` | A JavaScript export | A side panel on the work item page ([below](https://iskue.com/docs/plugin-development#a-panel-on-the-work-item-page/)). |
| `workflow-condition` | `workflow.WorkflowCondition` | Whether a transition is offered: `allows(ctx)`, with an optional `reason(ctx)` shown to the person. |
| `workflow-validator` | `workflow.WorkflowValidator` | Checked when someone makes the move: `validate(ctx)` returns the message that stops it, or nothing. |
| `workflow-post-function` | `workflow.WorkflowPostFunction` | Runs once the move has committed: `execute(ctx)`. |
| `tql-function` | `search.TqlFunction` | A TQL function such as `key IN staleIn(OPS)`: `ticketIds(args)` returns the matching work items' ids. |

> **Note:** Every module is installed for the whole instance; project administrators choose which transitions use a plugin's workflow rules. Switching a plugin's features on per project is [planned](https://iskue.com/docs/plugin-development#planned/).

### The API

Inject any of these into a module's constructor. Each one checks the plugin's scopes and the acting user's rights on every call.

| Service | Scope | What it does |
| --- | --- | --- |
| `ticket.TicketApi` | `read:ticket`, `write:ticket`, `write:comment` | `get(key)` → `Optional<TicketView>`, `transitions(key)`, `comments(key)`; `create(NewTicket)`, `update(key, TicketUpdate)`, `transition(key, status)`; `comment(key, body)`, `internalNote(key, body)` |
| `search.SearchApi` | `read:ticket` | `search(tql, page, pageSize)` → `SearchResult` (at most 100 a page, with `total` and `hasNext()`), `count(tql)`, `validate(tql)` — the results the Search page would show that user |
| `project.ProjectApi` | `read:project` | `get(key)`, `visible()` → `ProjectView` (id, key, name, description, lead, type, archived) |
| `user.UserApi` | `read:user` | `current()`, `get(id)`, `byEmail(email)` → `UserView` (id, display name, email, active, admin) |
| `permission.PermissionApi` | None | `isAdmin()`, `canBrowse(projectKey)`, `canAdminister(projectKey)`, `has(projectKey, EDIT_TICKET …)` — about the acting user only |
| `settings.SettingsApi` | None | `get`, `set` and `all`, for the whole instance or per project |
| `storage.StorageApi` | `storage` | `query(sql, args…)` and `update(sql, args…)` on the plugin's own schema |
| `cluster.ClusterLock` | None | `tryRun(name, body)`, `tryCall(name, supplier)`, `run(name, timeout, body)` |
| `PluginInfo` | None | The plugin's own `key`, `name` and `version` |

`TicketView` carries `id`, `key`, `projectKey`, `type`, `summary`, `status`, `statusCategory` (`TODO`, `IN_PROGRESS` or `DONE`), `priority`, `assignee` and `assigneeId`, `reporter`, `description` (Markdown), `labels`, `parentKey`, `dueDate`, `createdAt` and `updatedAt`. Key your own rows by `id`: a work item's key changes when it moves to another project. `NewTicket` and `TicketUpdate` are built step by step, and a field you leave out keeps the project's default or its current value; clearing a field is explicit (`withoutAssignee()`, `withoutDueDate()`).

*Work items and search, as the acting user*

```java
TicketView bug = tickets.create(NewTicket.of("OPS", "Bug", "Disk full on build agent")
        .withPriority("HIGH")
        .withDescription("Raised by the monitoring plugin."));

tickets.update(bug.key(), TicketUpdate.none().withDueDate(LocalDate.now().plusDays(2)));
tickets.transition(bug.key(), "In Progress");   // only a transition the workflow offers
tickets.internalNote(bug.key(), "Agent build-07, volume /var at 100%.");

SearchResult page = search.search("project = OPS AND assignee IS EMPTY ORDER BY created DESC", 0, 25);
long open = search.count("project = OPS AND statusCategory != Done");
```

### Who a plugin acts as

Every call runs **as someone**: the signed-in caller inside a `rest` endpoint, the person who made the change inside an `event-listener` or a post-function, the person moving the work item (or being offered the move) inside a condition or a validator, and the person searching inside a TQL function. It goes through the same membership, permission-scheme, restricted-work-item and workflow checks as Iskue's own REST API, so a plugin can do what its user could do — never more. A work item the user cannot see does not exist for the plugin either. A change made by the system has no user: `UserApi.current()` is empty and `PermissionApi` answers no.

A refusal throws `PluginApiException` with a reason and a message written for the person using the plugin. Let it escape from a `rest` endpoint and Iskue answers with the matching status, as its own API does:

| Reason | HTTP | When |
| --- | --- | --- |
| `NOT_FOUND` | 404 | The thing does not exist, or the user cannot see it — the two look the same on purpose. |
| `FORBIDDEN` | 403 | The user can see it but may not do this. |
| `INVALID` | 400 | The request is wrong: an unknown type or status, a value out of range, a TQL syntax error. |
| `CONFLICT` | 409 | The current state does not allow it: an archived project, a transition the workflow does not offer. |
| `PluginScopeException` | 403 | The plugin called something its descriptor did not ask for. Nothing is read or written. |

### Scopes

| Scope | Administrators see | Unlocks |
| --- | --- | --- |
| `read:ticket` | Read the tickets its user can see | `TicketApi` reads, `SearchApi` |
| `write:ticket` | Create, edit and move tickets as its user | `create`, `update`, `transition` |
| `write:comment` | Comment on tickets as its user | `comment`, `internalNote` |
| `read:project` | See the projects its user can browse | `ProjectApi` |
| `read:user` | Look up people, including their email addresses | `UserApi` |
| `storage` | Keep its own data | `StorageApi` and the plugin's tables |

Settings, permissions, the cluster lock and `PluginInfo` need no scope: they only reach the plugin's own state or the acting user's own rights.

### Workflow rules

Conditions, validators and post-functions appear in the transition rule editor of every project as **Plugin rule** choices, under the `title` the descriptor gives them. A project administrator adds one to a transition and can give it settings, one `name = value` per line (at most 50); the rule reads them with `ctx.config(name, fallback)`. Each rule receives a `TransitionContext`: the work item (before the move for a condition or a validator, after it for a post-function), the status it leaves and the one it enters, who is moving it, and the settings. See [Transition rules](https://iskue.com/docs/projects-and-workflows#transition-rules/) for how rules combine with the built-in ones.

*A `workflow-validator` module*

```java
package com.acme.stale;

import com.iskue.plugin.api.workflow.TransitionContext;
import com.iskue.plugin.api.workflow.WorkflowValidator;
import java.util.Optional;

/** Stops a move until the work item is described; the minimum length is a setting of the rule. */
public class NeedsDescription implements WorkflowValidator {

    @Override
    public Optional<String> validate(TransitionContext ctx) {
        int minLength = Integer.parseInt(ctx.config("minLength", "1"));
        String description = ctx.ticket().description();
        if (description == null || description.strip().length() < minLength) {
            return Optional.of("Describe the work (at least " + minLength + " characters) before moving it to "
                    + ctx.toStatus() + ".");
        }
        return Optional.empty();
    }
}
```

- **Conditions** are asked every time Iskue lists the moves open to someone — on every work item page and board — and again when the move runs. Keep them fast and free of side effects.
- **Validators** run inside the move's transaction, before the status changes. A message stops the move with HTTP 422, exactly like a built-in validator.
- **Post-functions** run after the move has committed, on the plugin pool and never on the request thread, so they can comment, update another work item or call out. A failure is logged and never undoes the move.
- **Fail closed** — a condition or validator that throws, or whose plugin is disabled, uninstalled or not running on that server, blocks the move and says why. A post-function whose plugin is not running is logged and skipped.

### TQL functions

A `tql-function` module adds a function people can use as `key IN staleIn(OPS)` (or `issue IN …`), under the `name` the descriptor gives it, and TQL autocomplete offers it beside the built-in functions. It returns work item ids, at most 10,000 per call; Iskue then keeps only those the person searching may see, so a function can narrow a search but never widen it. It runs on every search that uses it, as the person searching — keep it fast. Throw `PluginApiException` with reason `INVALID` for bad arguments: the message is shown under the query. Function names are camelCase, 2 to 41 characters, and cannot reuse a built-in function's name.

*A `tql-function` module*

```java
package com.acme.stale;

import com.iskue.plugin.api.PluginApiException;
import com.iskue.plugin.api.PluginApiException.Reason;
import com.iskue.plugin.api.search.SearchApi;
import com.iskue.plugin.api.search.SearchResult;
import com.iskue.plugin.api.search.TqlFunction;
import com.iskue.plugin.api.settings.SettingsApi;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/** key IN staleIn(OPS): the project's stale work items, by the threshold its administrators chose. */
public class StaleFunction implements TqlFunction {

    private final SearchApi search;
    private final SettingsApi settings;

    public StaleFunction(SearchApi search, SettingsApi settings) {
        this.search = search;
        this.settings = settings;
    }

    @Override
    public Set<String> ticketIds(List<String> args) {
        if (args.size() != 1 || !args.get(0).matches("[A-Z][A-Z0-9_]*")) {
            throw new PluginApiException(Reason.INVALID, "staleIn() takes one project key, like staleIn(OPS)");
        }
        String projectKey = args.get(0);
        int days = settings.get(projectKey, "stale.days").map(Integer::parseInt).orElse(30);
        String tql = "project = " + projectKey + " AND statusCategory != Done AND updated < -" + days + "d";
        Set<String> ids = new HashSet<>();
        for (int page = 0; ; page++) {
            SearchResult result = search.search(tql, page, SearchApi.MAX_PAGE_SIZE);
            for (var ticket : result.tickets()) {
                if (ids.size() == MAX_RESULTS) {
                    return ids;
                }
                ids.add(ticket.id());
            }
            if (!result.hasNext()) {
                return ids;
            }
        }
    }
}
```

### Plugin data

- **Own tables** — a plugin with `storage` gets its own database schema, `plg_<key>` (dots and hyphens become underscores), created and upgraded from the SQL migrations it ships under `db/migration`. `StorageApi` runs each statement in its own transaction with that schema as the search path, so tables are named without a schema; parameters bind to `?`.
- **Placed for clusters** — the descriptor says what each table is. A `reference` table is copied to every database node; a `ticket` table is distributed by `ticket_id`, next to the work items, on a distributed (Citus) database. The same plugin runs unchanged on a single server.
- **Settings** — `SettingsApi` keeps text values of up to 65,536 characters (store JSON for structure) for the instance and for each project, the same on every server; at most 1,000 per plugin, named with `a-z 0-9 . _ -`. It does not check who changes a setting: check `PermissionApi.isAdmin()` or `canAdminister(projectKey)` first, as the example does.
- **Uninstalling** keeps a plugin's tables and settings, unless the administrator chooses to delete its data.

> **Warning:** Your SQL runs on a distributed database in production. In `ON CONFLICT … DO UPDATE SET`, use only immutable functions: write `updated_at = EXCLUDED.updated_at` with `now()` in `VALUES`, never `updated_at = now()`. On a `ticket` table, every primary key and unique key must include `ticket_id`.

### Built for clusters

Iskue runs several application servers in front of one database. A plugin installed once runs on every server: each server picks up an install, an upgrade or a switch-off within seconds, and the administration page shows each server's state. Events are delivered on the server where the change happened. For work that must not run twice — a sync with an outside system, a clean-up started on every server — take a `ClusterLock`: one holder across the whole cluster, released when the body returns or throws, and when its server dies. Keep the body short, since it holds a database connection.

### A panel on the work item page

A `ticket-panel` module is a JavaScript module in the JAR's `web/` folder. Iskue loads it when the panel appears and calls the export named in `entry` with the panel's element and a `host` object: `apiVersion`, `pluginKey`, `moduleKey`, `ticketKey`, `locale` (`en` or `fr`) and `request(path, init)`, which calls the plugin's own REST endpoints as the signed-in user and resolves to the parsed JSON. Return a function to clean up when the panel goes away. Give the panel's `title` in English and French in the descriptor.

*A panel needs no framework*

```javascript
// web/stale-panel.js, declared as  entry: web/stale-panel.js#StalePanel
export function StalePanel(container, host) {
  const text = { en: "Stale work in this project", fr: "Travail en sommeil dans ce projet" };
  const heading = document.createElement("p");
  heading.textContent = text[host.locale] ?? text.en;
  const list = document.createElement("ul");
  container.append(heading, list);

  const projectKey = host.ticketKey.slice(0, host.ticketKey.lastIndexOf("-"));
  host.request(`/projects/${projectKey}/stale`).then((keys) => {
    for (const key of keys) {
      const item = document.createElement("li");
      item.textContent = key;            // textContent keeps text out of the HTML parser
      list.append(item);
    }
  });
  return () => container.replaceChildren();   // called when the panel goes away
}
```

> **Note:** In the alpha a panel runs in the page itself, not in a sandbox — one reason uploads stay off on hosted instances until signed packages and a Content-Security-Policy for plugin code ship.

### Build and package

- Compile for **Java 21** against `com.iskue.plugin.api`, and put `iskue-plugin.yaml` at the root of the JAR.
- Iskue provides the API and these shared libraries, so mark them `provided` — it refuses a JAR that bundles classes in their packages: `org.slf4j`, `com.fasterxml.jackson.annotation`, `jakarta.annotation`, `jakarta.validation`, `org.springframework.web.bind.annotation`, `org.springframework.http`, `org.springframework.stereotype`, `org.springframework.beans.factory.annotation` and `org.springframework.context.annotation`. Shade any other library you use into your JAR.
- Compile with `-parameters`, or name every `@PathVariable` and `@RequestParam`, as the example does.
- A JAR holds at most 10,000 entries and 64 MB uncompressed. Every module's `class` or `entry` must be in it, and a plugin with storage must ship at least one migration.

> **Note:** There is no project template or `iskue:run` yet. Design partners get the API JAR and a sample plugin that uses every module type — a work item panel backed by its own REST endpoint and tables, an event listener and a lifecycle — with its Maven build.

### Install and run

Global administrators manage Java plugins in **Administration → Plugins → Java plugins (alpha)**: upload a JAR, review the access it asks for, **enable** it (a new plugin arrives disabled), see whether each server runs it, disable it, and uninstall it with or without its data. Java plugins are an **Enterprise** edition feature; an instance with no licence installed is unrestricted.

| Variable | Default | Purpose |
| --- | --- | --- |
| APP_PLUGINS_UPLOAD_ENABLED | false | Allows uploading plugin JARs. Packages are not signed yet, so turn it on only where you trust every administrator and every JAR they upload. |
| APP_PLUGINS_SAFE_MODE | false | Starts Iskue without running any Java plugin — the way back from a plugin that misbehaves. |
| APP_PLUGINS_CACHE_DIR | `iskue-plugins` in the system temporary folder | Where each server keeps the plugin JARs it runs. |

### Versions

- `ApiVersion.CURRENT` is `0.3.0`. API 0.1 brought REST endpoints, work-item events, the lifecycle, the work item panel and plugin storage; 0.2 added work-item writes and comments, TQL search, projects, people, permissions, settings and the cluster lock; 0.3 added workflow conditions, validators and post-functions, and TQL functions.
- Iskue refuses to install a plugin whose `apiVersion` range does not include its own API version. During 0.x a minor version may change the API; from 1.0, changes within a major version stay backwards compatible.
- Types are marked `@PublicApi` (call it) or `@PublicSpi` (implement it); the whole package is `@ExperimentalApi` until 1.0.

## Planned

> **Warning:** **Planned — not available yet.** Everything from here on describes the plugin platform Iskue is building, so partners and customers can plan ahead. Names and details may change.

### More module types

Each module plugs into one named extension point. The ones Data Center apps use most come first; the rest follow in the partner beta.

| Module | What it adds | Jira Data Center equivalent |
| --- | --- | --- |
| `ticket-tab`, `ticket-action` | An activity tab or a menu action on the work item page | Issue tab panel, issue operation |
| `web-item`, `web-section` | Declarative links and menu entries, no code | Web item, web section |
| `admin-page`, `project-settings-page`, `project-page` | Full pages in Administration, Project settings or the project | Admin page, project tab panel |
| `custom-field-type` | A field type end to end: storage, validation, search in TQL, display and editing, export | Custom field type + searcher |
| `automation-trigger`, `-condition`, `-action` | New building blocks in the automation rule builder | Automation extensions |
| `scheduled-job` | Background work on a schedule, run once per cluster | Scheduled job |
| `project-permission`, `global-permission` | Permissions granted in permission schemes like built-in ones | Project / global permission |
| `dashboard-gadget`, `doc-block`, `report` | Dashboard gadgets, blocks for Docs pages, reports | Gadget, Confluence macro, report |
| `board-card-badge`, `sheet-column` | Badges on board cards, computed columns in sheets and structures | — |
| `entity-property-index`, `webhook-event` | Searchable plugin data on work items; plugin events for outbound webhooks | Entity property index |

### A fuller API

- **A published artefact** — `com.iskue:iskue-plugin-api`, semantically versioned, so a plugin builds anywhere.
- **Groups, workflows and fields** — through services that apply the same rules as the user interface.
- **Events** — a typed, versioned event vocabulary for comments, SLAs, sprints, releases, projects and users, independent of Iskue's internal events.
- **Platform services** — translations, transactions, notifications and an HTTP client limited to the hosts the descriptor declares.
- **Cluster services** — a cluster-wide cache and a job scheduler, beside today's lock.
- **A service user** — work a plugin does on its own, such as a scheduled job, runs as the plugin's service user, and its changes show "via <plugin>" in the history.

### User interface

Today a panel is plain JavaScript (above). The planned **`@iskue/plugin-ui`** package adds React components loaded on demand into named **slots** of the Iskue interface, rendered with the same design tokens in light and dark mode, with typed slot registration, an authenticated client for Iskue's and the plugin's own REST API, navigation, dialogs, notifications and base components. Each slot will be isolated: a failing plugin shows a message in its own area and never breaks the page.

*A work item side panel (draft SDK)*

```tsx
import { definePlugin, Panel, useTicket, useApi } from "@iskue/plugin-ui";

export const NotesPanel = definePlugin.ticketPanel(() => {
  const ticket = useTicket();
  const { data } = useApi(`/api/plugins/acme.release-notes/notes/${ticket.key}`);
  return <Panel title="Release notes">{data?.text ?? "No notes yet."}</Panel>;
});
```

### More plugin data

- **Entity properties** — small JSON values attached to work items, projects or users, searchable in TQL when the descriptor indexes them.

### Security and trust

A Java plugin runs inside Iskue with the application's privileges — exactly as a Jira Data Center app does — so installing one is a decision to trust its vendor. Today administrators see the access a plugin asks for before enabling it, every call is checked against those scopes and the acting user's rights, and safe mode starts Iskue without any plugin. Planned on top:

- **Signed packages** — plugins are signed by their vendor and by the marketplace; uploading unsigned packages stays off unless an administrator turns it on.
- **Upgrades that ask** — an upgrade that asks for more access waits for approval.
- **Checked on upload** — packages are scanned for dangerous code before they can be enabled.
- **Contained at run time** — time limits, separate thread pools and circuit breakers switch off a misbehaving module automatically.
- **Isolated user interface** — a Content-Security-Policy and isolated slots for plugin code in the browser.
- **Audited** — every install, upgrade, permission grant and configuration change is recorded, and changes a plugin makes show "via <plugin>" in the history.

> **Note:** For logic you do not want to trust with in-process access, use a script plugin (sandboxed) or, later, a remote app (runs outside Iskue).

### Administration

Global administrators will install, upgrade and remove plugins in **Administration → Apps**, with per-module switches, health metrics and errors. **Project administrators** then switch a plugin's project features on or off and configure them for their own project in **Project settings → Apps** — configuration stays with the people who run the project, as everywhere else in Iskue. Plugins remain an **Enterprise** edition feature.

### Developer tooling

- **Project template** — a Maven archetype with the backend, a frontend, translations, tests and a CI workflow.
- **`iskue:run`** — starts a local Iskue in Docker with your plugin installed, reinstalls it when the code changes and reloads its UI; `iskue:debug` attaches a debugger.
- **Test harness** — a JUnit 5 extension that boots a real Iskue (optionally a two-server cluster) with test data, plus helpers for UI tests.
- **Packaging** — `iskue:validate`, `iskue:package`, `iskue:sign` and an API-compatibility check; a command-line tool installs a build on any instance with an access token.
- **Samples** — one sample per module family, and a worked port of an open-source Jira Data Center app.

*Planned workflow*

```bash
mvn archetype:generate -DarchetypeGroupId=com.iskue -DarchetypeArtifactId=iskue-plugin-archetype
cd release-notes
mvn iskue:run          # http://localhost:8088 with the plugin installed
```

### Compatibility from 1.0

- Iskue releases get a **product version**; plugins declare the API versions they support, and Iskue refuses to enable an incompatible plugin with a clear message.
- Within a major API version, changes are **backwards compatible** — enforced by an automated check on every Iskue build, not by promise.
- Deprecated API stays available for at least **two minor releases and six months**, and every release publishes an API changelog.

### Marketplace and partners

The marketplace that exists today for script plugins will list Java plugins too, with screenshots, categories, changelogs and compatibility. Vendors will publish through a vendor portal, where each version is checked automatically (descriptor, signature, compatibility, code scan) before review. Plugins can be **free or paid**, with **trials**: plugin licences are issued by the marketplace and checked by the plugin through the API. Plugins that pass an automated test on a multi-server cluster earn a **Cluster Ready** badge — the counterpart of Data Center approved apps. A partner programme will set out listing guidelines, revenue share and support expectations.

## Coming from Jira Data Center

| Jira Data Center | Iskue |
| --- | --- |
| `atlassian-plugin.xml` | `iskue-plugin.yaml` |
| Plugin Framework (OSGi bundles, exported packages) | An isolated class loader per plugin; only `com.iskue.plugin.api` and a few shared libraries are visible |
| `component`, `component-import` | Module classes receive the API by constructor injection; `scan` picks up your own components |
| Java API (`@PublicApi`, `@PublicSpi`) | `com.iskue.plugin.api` (`@PublicApi`, `@PublicSpi`, `@ExperimentalApi`); a published `iskue-plugin-api` artefact is planned |
| Shared Access Layer (settings, users, i18n, transactions) | SettingsApi, UserApi and PermissionApi today; I18nApi and TransactionApi planned |
| Active Objects | A per-plugin schema with your own migrations, plus StorageApi |
| Entity properties | Entity properties, TQL-indexable (planned) |
| Web items, sections, panels, web resources | The work item panel today; web items, more slots and the UI SDK planned |
| Workflow conditions, validators and post-functions | `workflow-condition`, `workflow-validator`, `workflow-post-function`, with settings entered on the transition |
| JQL function | `tql-function` |
| Cluster lock, cluster cache, scheduler | ClusterLock today; ClusterCache and JobScheduler planned |
| Universal Plugin Manager | Administration → Plugins → Java plugins (alpha) today; Administration → Apps and Project settings → Apps planned |
| Atlassian SDK (`atlas-run`, `atlas-package`) | Maven archetype and plugin (`iskue:run`, `iskue:package`), planned |
| Marketplace and Data Center approved apps | The Iskue marketplace and the Cluster Ready badge, planned for Java plugins |

A migration guide with side-by-side code for each row will accompany the partner beta.

## Rollout

| Stage | What it brings |
| --- | --- |
| Internal alpha — **now** | The framework and API 0.1–0.3: REST endpoints, work-item events, the work item panel, plugin storage, work items, search, projects, people, permissions, settings, the cluster lock, workflow rules and TQL functions. Still to come in this stage: the project template and `iskue:run` |
| Partner beta | All module types above, signed packages, the published API artefact, administration, security guardrails, the full developer guide — validated with design partners; the API is frozen as version 1.0 |
| General availability | The marketplace for Java plugins, the vendor portal, plugin licensing, the Cluster Ready programme, script plugins v2 |
| Later | Remote apps that run outside Iskue |

> **Note:** **Building Jira Data Center apps today?** We are looking for design partners to try the alpha and shape the API before 1.0. [Get in touch](https://iskue.com/contact/).
