This document explains how to build Overlord plugins using the **native plugin system**. Plugins run as native shared libraries (`.so` on Linux, `.dylib` on macOS, in-memory DLL on Windows), giving them full access to system APIs.
Plugins can be written in **any language** that can produce a C-ABI shared library: **Go**, **C**, **C++**, **Rust**, or anything else that compiles to native code.
> TL;DR: A plugin is a zip with platform-specific binaries (`.so`/`.dll`/`.dylib`) plus `<id>.html`, `<id>.css`, `<id>.js`. Upload it in the Plugins page or drop it in Overlord-Server/plugins. Optionally include a `server.js` for a per-plugin server runtime with a private SQLite DB, an RPC API, and live broadcasts to open UIs (see [section 11](#11-server-side-plugin-runtime)).
The server will **never** send an x64 binary to an ARM client (or vice versa). If no binary matches the client's platform, loading is skipped with an error.
The `navbar` field is only present when a `config.json` is included in the bundle.
### Global Nav Bar Plugins (`config.json`)
Include an optional `config.json` in the root of your plugin zip to register the plugin in the global navigation bar. This is suited for plugins that operate across all clients (e.g. a global clipboard manager) rather than requiring a specific `?clientId=` to be selected first.
```json
{
"name":"My Plugin",
"version":"1.0.0",
"description":"A global plugin",
"navbar":{
"label":"My Plugin",
"icon":"fa-cube"
}
}
```
**`navbar.icon`** accepts any [Font Awesome 6 solid icon class](https://fontawesome.com/icons?s=solid) without the `fa-solid` prefix, e.g. `fa-clipboard`, `fa-network-wired`, `fa-key`.
When the server extracts the bundle:
-`config.json` fields are merged into the auto-generated `manifest.json`
- If the plugin is also `enabled`, it will appear in the **"Plugin Apps"** group in the sidebar/topbar
- Clicking the nav entry opens the plugin at `/plugins/<id>` — **without** a `?clientId=` parameter, suitable for global operation
> **Important:** `config.json` must be in the **root** of the zip (not inside a subfolder). The build scripts automatically include it if it exists at the plugin source root. The manifest is re-generated on every extraction (triggered when the zip is newer than `manifest.json`), so keep `config.json` in the zip to avoid losing the navbar registration on re-upload.
Every plugin — regardless of language — must export **C-callable functions** with specific signatures. The ABI differs slightly between Windows and Unix.
**Linux / macOS** — The callback is a standard C function with an opaque context pointer:
```c
voidcallback(uintptr_tctx,
constchar*event,inteventLen,
constchar*payload,intpayloadLen);
```
The `ctx` value received in `PluginOnLoad` must be passed back as the first argument to every callback invocation.
### PluginGetRuntime (runtime detection)
If a plugin exports `PluginGetRuntime`, the host calls it after loading. It must return a pointer to a static, null-terminated string identifying the runtime:
| *(absent)* | Defaults to `"go"` — **not** freed |
This matters because **Go plugins cannot be fully unloaded** due to [golang/go#11100](https://github.com/golang/go/issues/11100) — Go's runtime spawns threads that cannot be stopped, so calling `FreeLibrary`/`dlclose` on a Go shared library causes a crash. The host uses this information to skip freeing Go plugins while properly reclaiming memory for C/C++/Rust plugins.
The `hostInfo` buffer passed to `PluginOnLoad` is JSON:
```json
{
"clientId":"abc123",
"os":"windows",
"arch":"amd64",
"version":"1.0.0"
}
```
### Loading mechanism
- **Windows** — DLLs are loaded **entirely in memory** via a custom PE loader. No files are written to disk. This is why Rust's `std::sync::Mutex` (backed by `SRWLOCK`) may not initialize correctly — see the Rust section below.
- **Linux** — Shared libraries are loaded in memory via `memfd_create` + `dlopen` on `/proc/self/fd/`. No files touch disk.
- **macOS** — Shared libraries are written to a temp file, `dlopen`'d, then the temp file is deleted.
> **Note:** Go plugins do **not** export `PluginGetRuntime` and default to the `"go"` runtime. They are intentionally never freed on unload to prevent crashes from orphaned Go runtime threads.
- Uses `extern "stdcall"` for the callback type on Windows, `extern "C"` on Unix
- Exports `PluginGetRuntime` → returns `"rust"`
**⚠️ Important: avoid `std::sync::Mutex` on Windows.** Because the DLL is loaded via an in-memory PE loader (not `LoadLibrary`), Rust's standard `Mutex` (backed by Windows `SRWLOCK`) may not initialize correctly, causing an access violation. Use C-style `static mut` globals instead — this is safe because the Go host serializes all calls through its own mutex.
Each language has `.bat` (Windows) and `.sh` (Unix) build scripts. All scripts support the `BUILD_TARGETS` environment variable to compile for multiple platforms in one invocation.
The server reads your `<pluginId>.html`, extracts the body content, and renders it inside the standard Overlord layout. Your CSS and JS are loaded from `/plugins/<pluginId>/assets/`.
For global plugins, `clientId` will be `null`. Use `/api/clients?status=online&pageSize=1000` to enumerate connected clients and broadcast events to all of them:
The agent calls your `OnEvent(event, payload)` function directly with JSON-encoded data. No stdin/stdout pipes, no msgpack — just a direct function call.
Plugin UI pages are embedded **directly** into the main Overlord page — no iframe, no sandbox, no bridge. The server reads your plugin's HTML, extracts the `<body>` content and any `<link>`/`<style>` tags from the `<head>`, and injects them into the standard page layout (with nav bar, Tailwind CSS, etc.).
Because the plugin UI is injected into a persistent single-page application, navigating away and back to a plugin page causes the plugin's JS file to be re-evaluated in the **same global scope**. Using top-level `const` / `let` declarations will throw `SyntaxError: Identifier '...' has already been declared` on the second load.
**Wrap all plugin JS in an IIFE** to scope everything locally:
```js
(()=>{
constMY_CONST="value";// safe — scoped to this closure
By default, plugins are only loaded when manually triggered via the API or UI. For plugins that need to run 24/7 on every connected client (e.g. clipboard monitoring, keylogging, persistence), you can configure **auto-load**.
When auto-load is enabled for a plugin, the server will automatically load it onto every client that connects. If the client already has the plugin loaded, it's skipped — no duplicate loads.
You can also configure **auto-start events** — a list of events that are queued and delivered to the plugin immediately after it loads. This lets you pre-configure the plugin without any manual interaction.
Global nav bar plugins (those with a `navbar` entry in `config.json`) can expose an **"Autostart on new clients"** toggle directly in their UI. This checkbox controls whether new clients that connect will automatically have the plugin loaded and the current configuration replayed.
When the switch is checked, the UI calls `POST /api/plugins/<id>/autoload` with `autoLoad: true` and the current `autoStartEvents` (rule set + start order). When unchecked, it sets `autoLoad: false` — new clients won't receive the plugin unless manually triggered.
The switch state is saved to the server and restored when the plugin page is reopened.
The events in `autoStartEvents` are queued in order and delivered to the plugin after it reports `loaded`. This works exactly like calling the event API multiple times, but happens automatically.
### Disable auto-load
```
POST /api/plugins/<pluginId>/autoload
Content-Type: application/json
{
"autoLoad": false
}
```
### How it works
1. Client connects and completes the enrollment handshake
2. Server sends `hello_ack` (as usual)
3. Server dispatches auto-scripts (as usual)
4. Server checks all plugins with `autoLoad: true` and `enabled: true`
5. For each, if the plugin is **not already loaded** on that client:
- Sends the plugin binary bundle (chunked)
- Queues any `autoStartEvents`
6. When the plugin reports `loaded`, queued events are flushed in order
### Checking auto-load status
`GET /api/plugins` returns `autoLoad` and `autoStartEvents` for each plugin:
Each plugin has a **persistent data directory** on the server:
```
Overlord-Server/plugins/<pluginId>/data/
```
This directory is **never deleted** when a plugin is removed or reinstalled. It survives the entire plugin lifecycle and gives server-side plugins a dedicated place to store files — SQLite databases, config files, cached data, executables, etc.
### Read/write files
From your plugin UI JS (or any authenticated API consumer):
A plugin can ship an optional **`server.js`** at the root of its zip. When present, the server boots a dedicated **Bun Worker** for that plugin, opens a private SQLite database for it (`plugins/<id>/data/plugin.db`), and gives the worker an RPC + broadcast API.
This is the right place for plugin logic that:
- Has to run **without an open UI** (e.g. persisting credentials the agent harvests in the background).
- Needs to **aggregate state across every connected client** (the per-client native binary only sees its own host).
- Wants to **stream live updates** to whatever plugin pages are open right now without polling.
The worker is process-isolated from the main server: a buggy plugin that throws inside `onEvent` or `rpc` does not take the rest of Overlord down with it.
### Lifecycle
| Event | Effect |
|-------|--------|
| Server starts | Every enabled plugin with a `server.js` is booted in its own worker. |
| Plugin enabled (`POST /api/plugins/<id>/enable {enabled:true}`) | Worker is started if `server.js` exists. |
| Plugin disabled | Worker is stopped (graceful shutdown → `terminate()` after 750 ms). |
| Plugin re-uploaded | Worker is restarted so the new `server.js` takes effect. |
| Plugin deleted | Worker is stopped; the `data/` directory (including `plugin.db`) is preserved across reinstalls. |
| Server SIGINT/SIGTERM | All workers are shut down before the process exits. |
### `server.js` contract
`server.js` is an ES module whose default export is a plain object with up to four optional handlers:
```js
// plugins/credharvest/server.js
exportdefault{
// Run once when the worker boots. Use this to migrate the schema or seed
// any state. Throwing here marks the plugin as failed (visible in
// /api/plugins → lastError) and the worker exits.
setup(ctx){
ctx.db.exec(`
CREATE TABLE IF NOT EXISTS creds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
service TEXT,
username TEXT,
password TEXT,
captured_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS creds_by_client ON creds(client_id);
`);
},
// Fired every time an agent's native plugin sends a callback event back
// through the WebSocket (PluginOnEvent → host callback → server). Also
// fires for the lifecycle events the host injects: "loaded", "unloaded",
// Called when the plugin is being shut down (disable, re-upload, server
// exit). Use it to flush state. The worker is forcibly terminated 750 ms
// after this returns, so don't rely on async cleanup taking longer.
teardown(ctx){
ctx.log.info("shutting down");
},
};
```
### The `ctx` object
| Field | Type | Description |
|-------|------|-------------|
| `ctx.pluginId` | `string` | The sanitized plugin ID. |
| `ctx.db` | `Database` (`bun:sqlite`) | A pre-opened SQLite connection backed by `plugins/<id>/data/plugin.db`. WAL mode is enabled. The plugin owns the schema entirely. |
| `ctx.dataDir` | `string` | Absolute path to the plugin's `data/` directory. Use this if you want to read/write files alongside the DB (the same directory the `/api/plugins/<id>/data/*` filesystem API serves). |
| `ctx.log` | `{debug, info, warn, error}` | Each call is forwarded to the main server logger as `[plugin:<id>] <message>`. |
| `ctx.broadcast(channel, data)` | function | Push an event to every UI currently subscribed to this plugin's SSE stream. `data` must be JSON-serializable. |
The SSE stream keeps itself alive with a comment heartbeat every 25 s, which works through the standard reverse-proxy idle timeouts. Close the stream with `stream.close()` when the plugin page unmounts.
### End-to-end flow for a credential harvester
1. Native binary on the agent captures a credential and calls the host callback with `event="credential"` and a JSON payload.
2. Agent forwards the event over its `/api/clients/<id>/stream/ws` connection.
3. Server's `handlePluginEvent` records it for short-lived UI polling (the existing per-client behavior) **and** dispatches it into the plugin's worker via `onEvent`.
4. Worker INSERTs the credential into `plugin.db` and calls `ctx.broadcast("cred_added", ...)`.
5. Every open `/plugins/credharvest` page receives a `cred_added` SSE event and updates its UI in real time.
6. A user landing on `/plugins/credharvest` later calls `POST /api/plugins/credharvest/rpc {method:"list_all"}` and gets the full history back from `plugin.db`, regardless of which clients are currently online.
### API surface (server runtime)
-`POST /api/plugins/<id>/rpc` → `{ method: string, params?: any }` returns `{ ok, result }` or `{ ok:false, error }`. Requires `clients:control`. The method must exist on the plugin's `rpc` map. RPCs time out after 30 s.
-`GET /api/plugins/<id>/stream` → `text/event-stream`. Requires `clients:control`. Each `ctx.broadcast(channel, data)` becomes an SSE event named `channel` whose `data` field is `JSON.stringify(data)`.
-`GET /api/plugins` now includes two extra fields per plugin:
-`hasServer: boolean` — the bundle ships a `server.js`.
-`serverRunning: boolean` — the worker is live right now.
### Caveats
- **One worker per plugin.** RPC calls into the same plugin serialise. Long-running RPCs block other RPCs to that plugin (not other plugins). Do heavy work in `onEvent` and let RPCs read pre-computed state.
- **`bun:sqlite` is single-writer per database file.** Since each plugin gets its own DB this is fine; just don't open the same `plugin.db` from another process.
- **The worker is not a security sandbox.** It has the same filesystem and network access as the main server. Treat `server.js` with the same trust as your other server code, and lean on plugin signing if you load third-party bundles.
- **Compiled binaries.** Workers spawned from the bundled `--compile` build need the worker host module embedded; if you ship that build, run `bun build` with the worker host included as an entry point or stick with `bun run` for now.
- **No automatic restart on worker crash.** A crashed worker is logged and `pluginState.lastError` is set, but it will not loop-restart on its own. Disable + re-enable the plugin to bring it back up.
Plugins can be cryptographically signed with Ed25519 keys. The server verifies signatures on upload and displays trust status in the dashboard. Loading unsigned or untrusted plugins requires explicit confirmation.
1. The canonical content digest is computed by hashing every file in the ZIP (excluding `signature.json`), sorting filenames alphabetically, and concatenating `filename:sha256hex\n` for each
2. The digest is signed with Ed25519 using the private key
3. The signature, public key, and algorithm are stored as `signature.json` inside the ZIP
4. On upload, the server extracts `signature.json`, recomputes the digest, and verifies the signature using `crypto.subtle.verify("Ed25519", ...)`
5. The signer's fingerprint (`hex(SHA-256(raw_public_key))`) is compared against `plugins.trustedKeys` in the config