> For the complete documentation index, see [llms.txt](https://adatrack.gitbook.io/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://adatrack.gitbook.io/docs/documentation/features/plugin-development.md).

# Plugin Development Guide

Extend AdaTrack with custom functionality by writing **scripted plugins** in JavaScript. Scripted plugins run in a sandboxed runtime, are stored in the database, and can be created, updated, and managed entirely from the admin UI — no server recompilation required.

This guide covers everything you need to build, test, and deploy custom scripted plugins.

***

### Overview

* **Language:** JavaScript (ES6+). Modern syntax including `let`, `const`, arrow functions, template literals, destructuring, and `class` is supported via automatic transpilation.
* **Runtime:** Sandboxed VM with no filesystem or network access.
* **Storage:** Plugin code and configuration are stored in the database.
* **Management:** Install, enable, disable, configure, and uninstall from the **Admin > Plugins** panel or `adatrack-ctl plugin` commands.
* **Platform access:** The `adatrack.*` host API provides scoped access to devices, telemetry, events, configuration, key-value storage, and logging.

### What You Can Build

Scripted plugins can extend the platform through these extension points:

| Extension Point    | Use Case                                                                        |
| ------------------ | ------------------------------------------------------------------------------- |
| **Workflow Nodes** | Add custom logic steps to the visual workflow editor                            |
| **Event Hooks**    | React to platform events (telemetry received, alert fired, geofence transition) |
| **Report Blocks**  | Add custom content sections to generated PDF reports                            |
| **Cron Jobs**      | Run scheduled tasks (data sync, cleanup, aggregation)                           |
| **HTTP Routes**    | Mount authenticated or public API endpoints under your plugin's path            |

***

### Creating a Plugin

#### Step 1: Define the Plugin

Navigate to **Admin > Plugins > Create Scripted Plugin** and fill in the manifest:

| Field           | Required | Description                                                               |
| --------------- | -------- | ------------------------------------------------------------------------- |
| **Plugin ID**   | Yes      | Unique identifier (lowercase, hyphens allowed, e.g., `fleet-health`)      |
| **Name**        | Yes      | Display name (e.g., "Fleet Health Monitor")                               |
| **Version**     | Yes      | Semantic version (e.g., `1.0.0`)                                          |
| **Author**      | No       | Your name or organization                                                 |
| **Description** | No       | Short description of what the plugin does                                 |
| **Category**    | No       | One of: `monitoring`, `integration`, `automation`, `reporting`, `utility` |

#### Step 2: Declare Extension Points

Select which extension points your plugin uses. Each extension point has a specific entry function that the platform calls:

| Extension Point | Entry Function                | Called When                                |
| --------------- | ----------------------------- | ------------------------------------------ |
| Workflow Node   | `execute(input, settings)`    | Workflow reaches your node                 |
| Event Hook      | `onEvent(eventType, payload)` | A subscribed event fires                   |
| Report Block    | `render(config)`              | A report including your block is generated |
| Cron Job        | `run()`                       | The cron schedule triggers                 |

#### Step 3: Write the Script

Write your plugin logic in JavaScript (ES5.1). Use the `adatrack.*` host API to interact with the platform.

**Example — Device Status Summary (Workflow Node):**

```javascript
// Counts online vs offline devices and returns a summary
function execute(input, settings) {
    var devices = adatrack.devices.list();
    var online = 0;
    var offline = 0;

    for (var i = 0; i < devices.length; i++) {
        if (devices[i].active) {
            online++;
        } else {
            offline++;
        }
    }

    return {
        total: devices.length,
        online: online,
        offline: offline,
        health_pct: devices.length > 0
            ? Math.round((online / devices.length) * 100)
            : 0
    };
}
```

#### Step 4: Configure and Enable

1. If your plugin has configuration fields, define a **config schema** (see [Configuration Schema](#configuration-schema) below).
2. Set any required configuration values.
3. Click **Enable** to activate the plugin.

***

### Host API Reference

All scripted plugins have access to the `adatrack` global object. This is the only way to interact with the platform — there is no `require`, `import`, `fetch`, or filesystem access.

#### adatrack.devices

Query devices visible to the current context.

| Method | Signature                        | Returns            | Description                 |
| ------ | -------------------------------- | ------------------ | --------------------------- |
| `list` | `adatrack.devices.list()`        | `Array<Device>`    | List all devices            |
| `get`  | `adatrack.devices.get(deviceId)` | `Device` or `null` | Get a single device by UUID |

**Device object:**

```javascript
{
    id: "uuid-string",        // Device UUID
    name: "Truck-42",         // Display name
    profile_id: "uuid-string", // Device profile UUID
    active: true              // Whether the device is currently online
}
```

#### adatrack.telemetry

Fetch recent telemetry readings.

| Method   | Signature                                    | Returns          | Description                                               |
| -------- | -------------------------------------------- | ---------------- | --------------------------------------------------------- |
| `latest` | `adatrack.telemetry.latest(deviceId, limit)` | `Array<Reading>` | Get recent readings for a device. `limit` defaults to 10. |

**Reading object:**

```javascript
{
    time: "2026-04-13T10:30:00Z",  // ISO 8601 timestamp
    payload: {                       // Decoded telemetry (varies by device profile)
        temperature: 23.5,
        humidity: 65,
        battery: 87
    }
}
```

#### adatrack.events

Emit custom events to the platform event bus. Other plugins and event hooks can subscribe to your custom event types.

| Method    | Signature                                | Description                                                                                                   |
| --------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `publish` | `adatrack.events.publish(type, payload)` | Emit an event. `type` is a string (e.g., `"custom.fleet_health"`). `payload` is any JSON-serializable object. |

**Example:**

```javascript
adatrack.events.publish("custom.threshold_exceeded", {
    device_id: "abc-123",
    metric: "temperature",
    value: 95.2,
    threshold: 90
});
```

#### adatrack.config

Read the plugin's own configuration values (set via the admin UI or CLI).

| Method | Signature                  | Returns            | Description                       |
| ------ | -------------------------- | ------------------ | --------------------------------- |
| `get`  | `adatrack.config.get(key)` | `string` or `null` | Read a configuration value by key |

**Example:**

```javascript
var threshold = parseInt(adatrack.config.get("alert_threshold")) || 90;
var webhookUrl = adatrack.config.get("webhook_url");
```

#### adatrack.store

Persistent key-value store scoped to your plugin. Use this to maintain state between executions (e.g., counters, timestamps, cached results).

| Method | Signature                        | Description                                             |
| ------ | -------------------------------- | ------------------------------------------------------- |
| `get`  | `adatrack.store.get(key)`        | Read a value. Returns `null` if the key does not exist. |
| `set`  | `adatrack.store.set(key, value)` | Write a value. Both key and value must be strings.      |

**Example:**

```javascript
var lastRun = adatrack.store.get("last_run_time");
// ... do work ...
adatrack.store.set("last_run_time", new Date().toISOString());
adatrack.store.set("processed_count", String(count));
```

#### adatrack.log

Write messages to the server log. Useful for debugging and monitoring.

| Method  | Signature                     | Description                  |
| ------- | ----------------------------- | ---------------------------- |
| `info`  | `adatrack.log.info(message)`  | Log an informational message |
| `error` | `adatrack.log.error(message)` | Log an error                 |

**Example:**

```javascript
adatrack.log.info("Processing " + devices.length + " devices");
adatrack.log.error("Failed to parse config: " + err);
```

Log messages appear in the server log prefixed with the plugin ID, e.g., `[plugin:fleet-health] Processing 42 devices`.

***

### Extension Point Examples

#### Workflow Node

Workflow nodes receive `input` (data from the previous node) and `settings` (configured in the workflow editor). Return an object that downstream nodes can use.

```javascript
function execute(input, settings) {
    var deviceId = input.device_id;
    var readings = adatrack.telemetry.latest(deviceId, 5);

    if (readings.length === 0) {
        adatrack.log.error("No readings for device " + deviceId);
        return { status: "no_data" };
    }

    // Calculate average temperature
    var sum = 0;
    for (var i = 0; i < readings.length; i++) {
        sum += readings[i].payload.temperature || 0;
    }
    var avg = sum / readings.length;

    var threshold = parseInt(settings.threshold) || 80;

    return {
        device_id: deviceId,
        avg_temperature: Math.round(avg * 10) / 10,
        above_threshold: avg > threshold,
        reading_count: readings.length
    };
}
```

#### Event Hook

Event hooks fire when platform events occur. Subscribe to events by declaring them in your plugin's extension point configuration.

```javascript
function onEvent(eventType, payload) {
    if (eventType === "alert.fired") {
        adatrack.log.info("Alert: " + payload.rule_name +
            " on device " + payload.device_id);

        // Track alert count in KV store
        var count = parseInt(adatrack.store.get("alert_count")) || 0;
        adatrack.store.set("alert_count", String(count + 1));

        // Emit a custom event for other plugins
        adatrack.events.publish("custom.alert_tracked", {
            rule: payload.rule_name,
            total_alerts: count + 1
        });
    }

    if (eventType === "geofence.transition") {
        adatrack.log.info("Geofence " + payload.transition +
            ": device " + payload.device_id +
            " / fence " + payload.geofence_id);
    }
}
```

**Available event types:**

| Event                 | Payload Fields                                               |
| --------------------- | ------------------------------------------------------------ |
| `telemetry.received`  | `device_id`, `profile_id`, `payload`                         |
| `alert.fired`         | `rule_id`, `rule_name`, `device_id`, `severity`, `value`     |
| `geofence.transition` | `device_id`, `geofence_id`, `transition` (`enter` or `exit`) |
| `workflow.executed`   | `workflow_id`, `status` (`success` or `failure`)             |
| `report.generated`    | `report_id`, `format`                                        |

#### Report Block

Report blocks return HTML content that is embedded in generated PDF reports.

```javascript
function render(config) {
    var devices = adatrack.devices.list();
    var online = 0;
    for (var i = 0; i < devices.length; i++) {
        if (devices[i].active) online++;
    }

    var html = "<div style='padding: 16px;'>";
    html += "<h3>Fleet Summary</h3>";
    html += "<table border='1' cellpadding='8' cellspacing='0'>";
    html += "<tr><td><strong>Total Devices</strong></td><td>" + devices.length + "</td></tr>";
    html += "<tr><td><strong>Online</strong></td><td>" + online + "</td></tr>";
    html += "<tr><td><strong>Offline</strong></td><td>" + (devices.length - online) + "</td></tr>";
    html += "<tr><td><strong>Health</strong></td><td>" +
        (devices.length > 0 ? Math.round((online / devices.length) * 100) : 0) + "%</td></tr>";
    html += "</table></div>";

    return html;
}
```

#### Cron Job

Cron jobs run on a schedule defined in the plugin configuration. The `run()` function is called each time the schedule triggers.

```javascript
function run() {
    var devices = adatrack.devices.list();
    var staleCount = 0;

    for (var i = 0; i < devices.length; i++) {
        if (!devices[i].active) {
            staleCount++;
        }
    }

    adatrack.store.set("last_check", new Date().toISOString());
    adatrack.store.set("stale_devices", String(staleCount));

    if (staleCount > 0) {
        adatrack.log.error(staleCount + " devices are offline");
        adatrack.events.publish("custom.stale_device_check", {
            stale_count: staleCount,
            total: devices.length
        });
    }

    adatrack.log.info("Health check complete: " +
        staleCount + "/" + devices.length + " offline");
}
```

***

### Configuration Schema

Plugins can declare a JSON Schema for their configuration. The admin UI auto-generates a form from this schema.

#### Supported Types

| Type                            | UI Element      | Notes                                           |
| ------------------------------- | --------------- | ----------------------------------------------- |
| `string`                        | Text input      | —                                               |
| `number` / `integer`            | Number input    | —                                               |
| `boolean`                       | Toggle switch   | —                                               |
| `string` with `enum`            | Dropdown select | Provide an array of allowed values              |
| `string` with `writeOnly: true` | Password input  | Value is encrypted at rest and masked in the UI |

#### Example Schema

```json
{
    "type": "object",
    "properties": {
        "check_interval_minutes": {
            "type": "integer",
            "title": "Check Interval (minutes)",
            "description": "How often to check device status",
            "default": 15
        },
        "alert_threshold": {
            "type": "integer",
            "title": "Alert Threshold",
            "description": "Percentage of offline devices that triggers an alert",
            "default": 20
        },
        "severity": {
            "type": "string",
            "title": "Alert Severity",
            "description": "Severity level for generated alerts",
            "enum": ["info", "warning", "critical"],
            "default": "warning"
        },
        "webhook_url": {
            "type": "string",
            "title": "External Webhook URL",
            "description": "Optional URL to POST results to",
            "writeOnly": true
        },
        "enable_digest": {
            "type": "boolean",
            "title": "Enable Daily Digest",
            "description": "Send a daily summary event",
            "default": false
        }
    }
}
```

Configuration values are read in your script via `adatrack.config.get("check_interval_minutes")`. All values are returned as strings — parse numbers with `parseInt()` or `parseFloat()`.

***

### Security & Limits

#### Sandboxed Execution

Scripted plugins run in an isolated JavaScript VM. The following restrictions apply:

* **No filesystem access** — `require`, `import`, `fs`, `path`, and all Node.js modules are unavailable.
* **No network access** — `fetch`, `XMLHttpRequest`, `WebSocket`, and all HTTP/TCP APIs are unavailable.
* **No shell execution** — `exec`, `spawn`, and `child_process` are unavailable.
* **No global mutation** — the global scope is frozen. Plugins cannot modify `Object.prototype` or other built-ins.

The **only** way to interact with the outside world is through the `adatrack.*` host API.

#### Timeout

Each script execution has a configurable timeout (default: **1 second**). If the script exceeds this limit, the VM is interrupted and the execution returns an error. Configure the timeout per-plugin via the `script_timeout_ms` setting in the plugin's configuration.

For long-running work, break it into smaller cron job intervals rather than extending the timeout.

#### Key-Value Store Isolation

The `adatrack.store` is scoped to your plugin. You cannot read or write keys belonging to other plugins or the platform.

#### Database Table Isolation

If your plugin uses database storage (via the platform's plugin database API), all tables are automatically prefixed with your plugin ID. Plugins cannot access other plugins' tables or platform tables.

***

### Best Practices

1. **Use modern JavaScript freely.** ES6+ syntax including `const`, `let`, arrow functions, template literals, destructuring, and `class` is supported. Avoid `async`/`await` — the scripted runtime is synchronous.

   ```javascript
   // All of these work in scripted plugins
   const threshold = parseInt(adatrack.config.get("threshold")) || 80;
   const fn = (x) => x + 1;
   const { device_id, payload } = input;
   ```
2. **Check for null returns.** Host API calls may return `null` if an entity is not found.

   ```javascript
   var device = adatrack.devices.get(deviceId);
   if (device === null) {
       adatrack.log.error("Device not found: " + deviceId);
       return { error: "not_found" };
   }
   ```
3. **Use `adatrack.log` for debugging.** Log messages appear in the server log with your plugin ID prefix, making them easy to filter.
4. **Keep scripts focused.** Each plugin should do one thing well. If you need multiple capabilities, create separate plugins.
5. **Use the KV store for state.** Don't rely on global variables — the VM may be recycled between executions. Persist anything important with `adatrack.store.set()`.
6. **Parse config values.** All configuration values are returned as strings by `adatrack.config.get()`. Always parse numbers:

   ```javascript
   var limit = parseInt(adatrack.config.get("max_results")) || 10;
   ```
7. **Handle errors gracefully.** Uncaught exceptions terminate the script execution. Wrap risky operations in try/catch:

   ```javascript
   try {
       var readings = adatrack.telemetry.latest(deviceId, 5);
       // process readings
   } catch (e) {
       adatrack.log.error("Failed: " + e.message);
       return { error: e.message };
   }
   ```
8. **Test with the Preview endpoint.** The Chat Bot plugin page includes a command preview feature. For workflow nodes, test your script in the workflow editor's test mode before deploying to production workflows.

***

### HTTP Routes

Scripted plugins can declare HTTP route handlers that are mounted under the plugin's path. Two visibility levels are supported:

**Authenticated routes** — require a valid JWT. Mounted at `/api/v1/plugins/{plugin_id}/...`. Use these for plugin API endpoints accessed by the AdaTrack frontend or authenticated API clients.

**Public routes** — no authentication required. Mounted at `/api/v1/public/plugins/{plugin_id}/...`. Use these for receiving webhooks from external services that cannot provide a JWT (e.g., payment providers, messaging platforms, CI systems).

Declare the visibility of each route in your plugin manifest using the `Public: true` field on the route declaration. Public routes are isolated from authenticated routes — they share no session or user context.

### RBAC Permissions

If your plugin declares RBAC permissions in its manifest, they are automatically created in the permissions table when the plugin is **enabled** and removed when it is **uninstalled**. No manual database setup or migration is needed when deploying a plugin with a custom permission set.

Users can be granted plugin permissions via the standard RBAC management interface (**Admin > Roles**) or via `adatrack-ctl user role set`.

***

### CLI Management

You can manage scripted plugins from the command line using `adatrack-ctl`:

```bash
# List all plugins
adatrack-ctl plugin list

# View plugin details
adatrack-ctl plugin status my-plugin

# Enable/disable
adatrack-ctl plugin enable my-plugin
adatrack-ctl plugin disable my-plugin

# Update configuration
adatrack-ctl plugin config my-plugin --set alert_threshold=25
adatrack-ctl plugin config my-plugin --set severity=critical

# Run health check
adatrack-ctl plugin health my-plugin
```

See the [adatrack-ctl reference](/docs/documentation/getting-started/adatrack-ctl.md) for the full plugin command documentation.
