Plugin API¶
The Modeler exposes a JavaScript Plugin API that allows other Drupal modules to extend the modeler UI at runtime. Modules can register toolbar widgets (buttons, toggles) and panels (sidebar or bottom sections) that interact with the modeler's state through a comprehensive public API that supports both reading state and modifying the workflow graph.
Concepts¶
The plugin system has three main parts:
| Concept | Description |
|---|---|
| Global object | window.WorkflowModeler -- the entry point for all plugin operations. Available as soon as the modeler bundle loads. |
| Widgets | Small inline elements (typically buttons) rendered inside the toolbar. Registered with registerWidget(). |
| Panels | Larger content areas rendered alongside the canvas. Registered with registerPanel(). |
| Public API | window.WorkflowModeler.api -- provides access to nodes, edges, selection state, model metadata, available components, contexts, and more. Includes event subscriptions and methods to add, update, and remove nodes and edges. Available once the modeler has mounted. |
Lifecycle¶
Registrations can happen before or after the modeler mounts. This is important because Drupal module scripts may load in any order relative to the modeler bundle.
Modeler bundle loads --> window.WorkflowModeler created
Drupal.behaviors.workflowModelerReact registered
Drupal.attachBehaviors --> React app starts mounting
Plugin behaviors attach and listen for ready event
React app mounted --> WorkflowModeler.api becomes available
onReady() callbacks fire
workflow-modeler:ready DOM event dispatched
queued widgets and panels render
React app unmounts --> WorkflowModeler.api set to null
(HTMX navigation) registrations survive for re-mount
The workflow-modeler:ready DOM event¶
When the React app has fully mounted and the plugin API is available, the
modeler dispatches a workflow-modeler:ready custom event on document.
This is the recommended way for external Drupal behaviors to know when it is
safe to call WorkflowModeler.registerWidget() and friends.
The event is dispatched asynchronously (via setTimeout(…, 0)) to
guarantee it fires after Drupal.attachBehaviors() has completed, even when
the modeler mounts synchronously during HTMX navigation.
document.addEventListener('workflow-modeler:ready', () => {
// WorkflowModeler and WorkflowModeler.api are now available.
console.log('Modeler is ready!');
}, { once: true });
Toolbar widgets¶
Toolbar widgets are inline elements rendered inside the modeler toolbar. They are ideal for toggle buttons, status indicators, or action triggers.
Registering a widget¶
const modeler = window.WorkflowModeler;
modeler.registerWidget({
id: 'my_module_toggle',
label: 'My Feature',
position: 'right', // 'left' or 'right'
weight: 5, // lower = rendered first
render(container, api) {
const btn = document.createElement('button');
btn.className = 'toolbar-btn';
btn.title = 'Toggle My Feature';
btn.textContent = 'MF';
btn.addEventListener('click', () => {
btn.classList.toggle('active');
});
container.appendChild(btn);
},
destroy(container) {
container.innerHTML = '';
},
});
Widget descriptor reference¶
| Property | Type | Default | Description |
|---|---|---|---|
id |
string |
required | Unique identifier for the widget. |
label |
string |
required | Human-readable label (used for aria-label). |
position |
'left' \| 'right' |
'right' |
Where to render in the toolbar. left places it near the event button and search bar; right places it near the docs link, save button, and kebab menu. |
weight |
number |
0 |
Ordering weight within the position. Lower values appear first. |
render |
(container, api) => void |
required | Called once when the widget mounts. Receives a DOM element to render into and the public API. |
destroy |
(container) => void |
-- | Optional cleanup callback called before the widget is removed. |
Styling¶
For visual consistency, render a <button> with the toolbar-btn CSS class.
Add the active class to indicate a toggled-on state:
Removing a widget¶
Panels¶
Panels are larger content areas rendered alongside the canvas. They can be positioned on the left, right, or bottom of the modeler and support resize and collapse.
Registering a panel¶
const modeler = window.WorkflowModeler;
modeler.registerPanel({
id: 'my_module_info',
label: 'Model Info',
position: 'right',
weight: 10,
width: 350,
render(container, api) {
const heading = document.createElement('h3');
heading.textContent = 'Model Information';
container.appendChild(heading);
const info = document.createElement('div');
const data = api.getModelData();
info.textContent = data?.metadata?.label || 'Untitled';
container.appendChild(info);
// Subscribe to live updates
api.onSelectionChange((node, edge) => {
if (node) {
info.textContent = 'Selected: ' + (node.data.label || node.id);
} else if (edge) {
info.textContent = 'Edge: ' + edge.source + ' -> ' + edge.target;
} else {
info.textContent = data?.metadata?.label || 'Nothing selected';
}
});
},
destroy(container) {
container.innerHTML = '';
},
});
Panel descriptor reference¶
| Property | Type | Default | Description |
|---|---|---|---|
id |
string |
required | Unique identifier for the panel. |
label |
string |
required | Human-readable label shown in the collapsed tab. |
position |
'left' \| 'right' \| 'bottom' |
'right' |
Where to render the panel. right places it after the property panel; left places it before the canvas; bottom places it below the canvas. |
weight |
number |
0 |
Ordering weight within the position. Lower values appear first. |
width |
number |
320 |
Initial width in pixels (min: 200, max: 600). |
render |
(container, api) => void |
required | Called once when the panel mounts. Receives a DOM element to render into and the public API. |
destroy |
(container) => void |
-- | Optional cleanup callback called before the panel is removed. |
onResize |
(width, height) => void |
-- | Optional callback fired after the user finishes resizing the panel. |
Panel features¶
Each registered panel automatically receives:
- Collapse/expand toggle -- a button in the panel header that collapses the panel to a narrow vertical tab with a rotated label.
- Resize handle -- drag the panel edge to adjust its width.
- Error boundary -- if the panel's render function throws, only that panel shows an error; the rest of the modeler continues working.
Removing a panel¶
Public API reference¶
The public API is available as window.WorkflowModeler.api after the modeler
mounts. It is also passed as the second argument to every render() callback.
Data safety
All data returned by API getters is a deep-cloned snapshot. Mutating the returned objects will not affect the modeler's internal state.
Mutation methods and read-only mode
All mutation methods (addNode, updateNode, removeNode, addEdge,
updateEdge, removeEdge, setCondition, removeCondition,
autoLayout) are no-ops in read-only mode. They return false or
null to indicate the operation was rejected. Always check the return value
if your plugin needs to know whether the operation succeeded.
Mutations automatically integrate with the modeler's undo/redo history and unsaved-changes tracking -- there is no need to manage this manually.
Getters¶
Graph data¶
| Method | Returns | Description |
|---|---|---|
getNodes() |
PluginNode[] |
All current nodes (id, type, data, position). |
getEdges() |
PluginEdge[] |
All current edges (id, source, target, type, data). |
getNodeById(nodeId) |
PluginNode \| null |
A single node by ID, or null if not found. |
getEdgeById(edgeId) |
PluginEdge \| null |
A single edge by ID, or null if not found. |
getSelectedNode() |
PluginNode \| null |
The currently selected node, or null. |
getSelectedEdge() |
PluginEdge \| null |
The currently selected edge, or null. |
getModelData() |
PluginModelData \| null |
Model metadata (id, version, label, description, tags). |
State information¶
| Method | Returns | Description |
|---|---|---|
isReadOnly() |
boolean |
Whether the modeler is in read-only mode. |
isDarkMode() |
boolean |
Whether dark mode is active. |
getHistoryState() |
PluginHistoryState |
Current undo/redo availability ({ canUndo, canRedo }). |
getErrors() |
PluginError[] |
Current error log entries ({ id, message, dismissed }). |
Components and contexts¶
| Method | Returns | Description |
|---|---|---|
getComponents() |
PluginComponent[] |
All available component definitions (events, actions, conditions, gateways, subprocesses). Each entry contains plugin, label, type, provider, description, documentationUrl, and componentType. |
getComponentLabels() |
PluginComponentLabels |
Model-owner-provided terminology (e.g. { start: 'Event', element: 'Action', link: 'Condition' }). |
getContexts() |
PluginContext[] |
Available contexts ({ id, topic, model_owner }). |
getSelectedContextId() |
string \| null |
The currently selected context ID, or null when no filter is active. |
getFilteredNodeIds() |
string[] \| null |
IDs of start nodes whose flows are currently visible, or null when all flows are shown. |
Event subscriptions¶
All subscription methods return an unsubscribe function. Call it to stop receiving updates.
Graph events¶
| Method | Callback signature | Description |
|---|---|---|
onSelectionChange(cb) |
(node, edge) => void |
Fires when the selected node or edge changes. |
onNodesChange(cb) |
(nodes) => void |
Fires when nodes are added, removed, or reordered. |
onEdgesChange(cb) |
(edges) => void |
Fires when edges are added, removed, or reordered. |
onModelDataChange(cb) |
(data) => void |
Fires when model metadata changes. |
State events¶
| Method | Callback signature | Description |
|---|---|---|
onDarkModeChange(cb) |
(isDarkMode) => void |
Fires when dark mode is toggled. |
onContextChange(cb) |
(contextId) => void |
Fires when the active context filter changes. contextId is null when no filter is active. |
onComponentsChange(cb) |
(components) => void |
Fires when the list of available components changes. |
onReadOnlyChange(cb) |
(isReadOnly) => void |
Fires when the read-only state changes. |
const unsubscribe = api.onSelectionChange((node, edge) => {
console.log('Selection changed:', node, edge);
});
// Later, stop listening:
unsubscribe();
Selection and viewport actions¶
| Method | Description |
|---|---|
selectNode(nodeId) |
Select a node by ID. Pass null to clear. No-op in read-only mode. |
selectEdge(edgeId) |
Select an edge by ID. Pass null to clear. No-op in read-only mode. |
clearSelection() |
Clear the current selection (node, edge, and multi-selection). |
focusNode(nodeId) |
Animate the viewport to center on a node. |
fitView() |
Fit all nodes into the viewport with animation. |
Node mutations¶
| Method | Returns | Description |
|---|---|---|
addNode(descriptor) |
string \| null |
Add a new node. Returns the generated node ID, or null in read-only mode. |
updateNode(nodeId, updates) |
boolean |
Update a node's data. Returns true on success. |
removeNode(nodeId) |
boolean |
Remove a node and all its connected edges. Returns true on success. |
AddNodeDescriptor¶
| Property | Type | Required | Description |
|---|---|---|---|
plugin |
string |
yes | Plugin identifier (e.g. 'eca_content_entity:create'). |
componentType |
number |
yes | Integer component-type constant: 1 = start, 2 = subprocess, 4 = element, 6 = gateway. |
label |
string |
no | Human-readable label. Defaults to the plugin ID suffix. |
position |
{ x, y } |
no | Canvas position. If omitted, placed automatically to the right of existing nodes. |
configuration |
object |
no | Initial configuration key/value pairs. |
description |
string |
no | Short description. |
documentationUrl |
string |
no | URL to plugin documentation. |
UpdateNodeDescriptor¶
| Property | Type | Description |
|---|---|---|
label |
string |
New human-readable label. |
position |
{ x, y } |
New canvas position. |
configuration |
object |
Configuration values (shallow-merged with existing). |
locked |
boolean |
Whether the node is locked. |
annotation |
string |
Annotation text. |
// Add a new action node
const nodeId = api.addNode({
plugin: 'eca_content_entity:create',
componentType: 4, // element
label: 'Create Article',
});
// Update its configuration
if (nodeId) {
api.updateNode(nodeId, {
configuration: { type: 'article', status: '1' },
});
}
// Remove it
api.removeNode(nodeId);
Edge mutations¶
| Method | Returns | Description |
|---|---|---|
addEdge(sourceNodeId, targetNodeId) |
string \| null |
Connect two nodes. Returns the generated edge ID, or null in read-only mode or if either node does not exist. |
updateEdge(edgeId, updates) |
boolean |
Update an edge's data. Returns true on success. |
removeEdge(edgeId) |
boolean |
Remove an edge. Returns true on success. |
setCondition(edgeId, condition) |
boolean |
Attach a condition to an edge (converts it to a condition edge). Returns true on success. |
removeCondition(edgeId) |
boolean |
Remove the condition from an edge (converts it back to a default edge). Returns true on success. |
UpdateEdgeDescriptor¶
| Property | Type | Description |
|---|---|---|
locked |
boolean |
Whether the edge is locked. |
annotation |
string |
Annotation text (only meaningful for condition edges). |
SetConditionDescriptor¶
| Property | Type | Required | Description |
|---|---|---|---|
plugin |
string |
yes | Condition plugin identifier. |
label |
string |
no | Human-readable condition label. Defaults to the plugin ID suffix. |
configuration |
object |
no | Initial condition configuration values. |
// Connect two nodes
const edgeId = api.addEdge('event_1', 'action_1');
// Attach a condition
if (edgeId) {
api.setCondition(edgeId, {
plugin: 'eca_base:eca_scalar_comparison',
label: 'Status is Published',
configuration: { left: '[node:status]', right: '1' },
});
}
// Remove the condition (edge remains)
api.removeCondition(edgeId);
// Remove the edge entirely
api.removeEdge(edgeId);
Canvas actions¶
| Method | Description |
|---|---|
autoLayout() |
Trigger automatic layout of all unlocked nodes. No-op in read-only mode. |
setDarkMode(enabled) |
Toggle dark mode on or off. |
setFlowFilter(startNodeIds) |
Set the flow filter to show only the specified start node flows. Pass null to show all flows. |
Ready callback (in-bundle)¶
Use onReady() to defer logic until the API is available. This is intended for
code that runs inside the modeler bundle or scripts that are guaranteed to
load before the modeler mounts:
window.WorkflowModeler.onReady(() => {
const api = window.WorkflowModeler.api;
console.log('Model has', api.getNodes().length, 'nodes');
});
If the modeler is already mounted when onReady() is called, the callback
fires synchronously.
Ready event (Drupal behaviors)¶
For external Drupal modules that use Drupal.behaviors, listen for the
workflow-modeler:ready DOM event instead. This avoids timing issues where
onReady() might fire before the behavior has attached its listener. See the
Drupal integration section below for the full pattern.
Complete example: toolbar button that toggles a panel¶
This example shows the recommended pattern for a Drupal module that adds a
toolbar button which opens and closes a custom panel. It uses a proper Drupal
behavior that listens for the workflow-modeler:ready event, with public
methods exposed on a Drupal.* namespace.
/**
* @file
* My Module - Modeler plugin.
*/
(function (Drupal, once) {
'use strict';
Drupal.myModuleModelerPlugin = Drupal.myModuleModelerPlugin || {};
const PANEL_ID = 'my_module_panel';
const WIDGET_ID = 'my_module_toggle';
let panelActive = false;
/**
* Drupal behavior for the modeler plugin.
*
* Listens for the `workflow-modeler:ready` event and registers the
* widget once the modeler is available.
*
* @type {Drupal~behavior}
*/
Drupal.behaviors.myModuleModelerPlugin = {
attach: function (context) {
once('my-module-modeler-plugin', 'html', context).forEach(function () {
document.addEventListener(
'workflow-modeler:ready',
Drupal.myModuleModelerPlugin.registerToggleWidget,
{ once: true }
);
});
},
};
/**
* Registers the toolbar widget with the WorkflowModeler.
*/
Drupal.myModuleModelerPlugin.registerToggleWidget = function () {
const modeler = window.WorkflowModeler;
if (!modeler) {
return;
}
try {
modeler.registerWidget({
id: WIDGET_ID,
label: 'My Feature',
position: 'right',
weight: 5,
render: function (container) {
const btn = document.createElement('button');
btn.className = 'toolbar-btn';
btn.title = 'Toggle My Feature';
btn.textContent = 'MF';
btn.addEventListener('click', function () {
if (panelActive) {
modeler.unregisterPanel(PANEL_ID);
btn.classList.remove('active');
panelActive = false;
}
else {
modeler.registerPanel({
id: PANEL_ID,
label: 'My Feature',
position: 'right',
width: 400,
render: function (panelContainer, panelApi) {
renderPanel(panelContainer, panelApi);
},
destroy: function (panelContainer) {
panelContainer.innerHTML = '';
},
});
btn.classList.add('active');
panelActive = true;
}
});
container.appendChild(btn);
},
destroy: function (container) {
if (panelActive) {
modeler.unregisterPanel(PANEL_ID);
panelActive = false;
}
container.innerHTML = '';
},
});
}
catch (err) {
Drupal.throwError(err);
}
};
/**
* Renders the panel contents.
*
* @param {HTMLElement} container
* The panel container element.
* @param {object} api
* The WorkflowModeler plugin API.
*/
function renderPanel(container, api) {
container.innerHTML = '<h3>My Feature</h3>';
const status = document.createElement('p');
status.textContent = 'Select a node to analyze.';
container.appendChild(status);
api.onSelectionChange(function (node, edge) {
if (node) {
status.textContent = 'Selected: ' + (node.data.label || node.id);
}
else {
status.textContent = 'Select a node to analyze.';
}
});
const stats = document.createElement('p');
stats.textContent = 'Nodes: ' + api.getNodes().length;
container.appendChild(stats);
api.onNodesChange(function (nodes) {
stats.textContent = 'Nodes: ' + nodes.length;
});
}
})(Drupal, once);
Drupal integration¶
Library definition¶
Define a library in your module's *.libraries.yml with core/drupal and
core/once as dependencies. The library does not need to depend on
modeler/react-ui directly -- instead, use hook_library_info_alter() to
inject it as a dependency of the modeler library (see below).
# my_module.libraries.yml
modeler_plugin:
js:
js/modeler-plugin.js: {}
dependencies:
- core/drupal
- core/once
Loading the plugin with the modeler¶
The recommended way to ensure your plugin loads together with the modeler
is to use hook_library_info_alter() to add your library as a dependency of
modeler/react-ui. This way your script is automatically included whenever the
modeler is loaded -- no need for hook_page_attachments() or manual render
array attachments.
<?php
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
class LibraryHooks {
#[Hook('library_info_alter')]
public function libraryInfoAlter(array &$libraries, string $extension): void {
if ($extension === 'modeler' && isset($libraries['react-ui'])) {
$libraries['react-ui']['dependencies'][] = 'my_module/modeler_plugin';
}
}
}
This approach has several advantages:
- The plugin script only loads on pages where the modeler is present.
- Load order is guaranteed: the modeler bundle loads first (since your library
is a dependency of it, not the other way round), so
window.WorkflowModeleris available by the time your script executes. - Multiple modules can each add their own plugin library the same way without conflicting.
Namespacing conventions¶
Public methods that need to be accessible outside the closure (e.g. as event
listener callbacks) should be placed on a Drupal.* namespace object. The
behavior key should match the namespace:
// Namespace for public methods
Drupal.myModuleModelerPlugin = Drupal.myModuleModelerPlugin || {};
// Behavior uses the same key
Drupal.behaviors.myModuleModelerPlugin = { ... };
// Registration function on the namespace
Drupal.myModuleModelerPlugin.registerToggleWidget = function () { ... };
This makes the registration function callable from outside the closure (e.g. for debugging or testing) and ensures the event listener reference is stable.
Why window.WorkflowModeler instead of the bare global?
Always access the modeler via window.WorkflowModeler (typically captured
into a local variable). The bare identifier WorkflowModeler will not
resolve inside a 'use strict' closure unless it is explicitly passed in.
Complete example: node statistics panel¶
This example registers a panel (without a toolbar button) that displays live statistics about the current model.
(function (Drupal, once) {
'use strict';
Drupal.myModuleStatsPlugin = Drupal.myModuleStatsPlugin || {};
Drupal.behaviors.myModuleStatsPlugin = {
attach: function (context) {
once('my-module-stats-plugin', 'html', context).forEach(function () {
document.addEventListener(
'workflow-modeler:ready',
Drupal.myModuleStatsPlugin.registerPanel,
{ once: true }
);
});
},
};
Drupal.myModuleStatsPlugin.registerPanel = function () {
const modeler = window.WorkflowModeler;
if (!modeler) {
return;
}
try {
modeler.registerPanel({
id: 'my_module_stats',
label: 'Statistics',
position: 'right',
weight: 20,
width: 280,
render: function (container, api) {
container.innerHTML = '';
const table = document.createElement('table');
table.style.width = '100%';
table.style.borderCollapse = 'collapse';
function updateStats() {
const nodes = api.getNodes();
const edges = api.getEdges();
const types = {};
nodes.forEach(function (n) {
const t = n.type || 'unknown';
types[t] = (types[t] || 0) + 1;
});
table.innerHTML =
'<tr><th>Metric</th><th>Count</th></tr>' +
'<tr><td>Total Nodes</td><td>' + nodes.length + '</td></tr>' +
'<tr><td>Total Edges</td><td>' + edges.length + '</td></tr>' +
Object.entries(types).map(function (entry) {
return '<tr><td>' + entry[0] + '</td><td>' + entry[1] + '</td></tr>';
}).join('');
}
container.appendChild(table);
updateStats();
api.onNodesChange(updateStats);
api.onEdgesChange(updateStats);
},
});
}
catch (err) {
Drupal.throwError(err);
}
};
})(Drupal, once);
Complete example: panel with mutation capabilities¶
This example shows a plugin panel that can add pre-configured action nodes to the workflow -- demonstrating the mutation API. It lists available components and lets the user click to add them with a default configuration.
(function (Drupal, once) {
'use strict';
Drupal.myModuleQuickActions = Drupal.myModuleQuickActions || {};
Drupal.behaviors.myModuleQuickActions = {
attach: function (context) {
once('my-module-quick-actions', 'html', context).forEach(function () {
document.addEventListener(
'workflow-modeler:ready',
Drupal.myModuleQuickActions.registerPanel,
{ once: true }
);
});
},
};
Drupal.myModuleQuickActions.registerPanel = function () {
var modeler = window.WorkflowModeler;
if (!modeler) {
return;
}
try {
modeler.registerPanel({
id: 'my_module_quick_actions',
label: 'Quick Actions',
position: 'left',
weight: 5,
width: 300,
render: function (container, api) {
container.innerHTML = '';
var heading = document.createElement('h3');
heading.textContent = 'Quick Actions';
container.appendChild(heading);
// Show read-only warning when applicable
if (api.isReadOnly()) {
var warning = document.createElement('p');
warning.textContent = 'Read-only mode — actions disabled.';
warning.style.color = 'var(--modeler-color-text-secondary)';
container.appendChild(warning);
return;
}
// List available action components (componentType 4 = element)
var components = api.getComponents().filter(function (c) {
return c.componentType === 4;
});
var list = document.createElement('ul');
list.style.listStyle = 'none';
list.style.padding = '0';
components.slice(0, 10).forEach(function (comp) {
var item = document.createElement('li');
var btn = document.createElement('button');
btn.className = 'toolbar-btn';
btn.style.width = '100%';
btn.style.textAlign = 'left';
btn.style.marginBottom = '4px';
btn.textContent = comp.label;
btn.title = comp.description || comp.plugin;
btn.addEventListener('click', function () {
var nodeId = api.addNode({
plugin: comp.plugin,
componentType: comp.componentType,
label: comp.label,
description: comp.description,
documentationUrl: comp.documentationUrl,
});
if (nodeId) {
// Select and focus the new node
api.selectNode(nodeId);
api.focusNode(nodeId);
}
});
item.appendChild(btn);
list.appendChild(item);
});
container.appendChild(list);
// Add a "Connect selected" button
var connectBtn = document.createElement('button');
connectBtn.className = 'toolbar-btn';
connectBtn.style.width = '100%';
connectBtn.style.marginTop = '12px';
connectBtn.textContent = 'Connect selected to last added';
connectBtn.disabled = true;
var lastAddedId = null;
api.onSelectionChange(function (node) {
connectBtn.disabled = !node || !lastAddedId
|| node.id === lastAddedId;
});
connectBtn.addEventListener('click', function () {
var selected = api.getSelectedNode();
if (selected && lastAddedId) {
api.addEdge(selected.id, lastAddedId);
}
});
container.appendChild(connectBtn);
// Track the last node we added
api.onNodesChange(function (nodes) {
if (nodes.length > 0) {
lastAddedId = nodes[nodes.length - 1].id;
}
});
// React to read-only changes
api.onReadOnlyChange(function (readOnly) {
list.querySelectorAll('button').forEach(function (b) {
b.disabled = readOnly;
});
connectBtn.disabled = readOnly;
});
},
destroy: function (container) {
container.innerHTML = '';
},
});
}
catch (err) {
Drupal.throwError(err);
}
};
})(Drupal, once);
Error handling¶
Plugin code runs inside error boundaries. If a panel's render() function
throws, only that panel displays an error message -- the rest of the modeler
continues working. The error boundary offers automatic retry with exponential
backoff, plus a manual "Try Again" button.
Widget errors are caught and logged to the console but do not crash the toolbar.
API stability¶
The Plugin API is designed to be stable across minor versions of the modeler. The following guarantees apply:
- Additive changes only: New methods and properties may be added to the API in minor releases, but existing ones will not be removed or changed in breaking ways.
- Deep-cloned data: Plugins always receive copies, never internal references. Changes to internal data structures do not affect the plugin-facing shape.
- Position values: The set of allowed positions (
left,right,bottomfor panels;left,rightfor widgets) may be extended but not reduced.