Wanaku Plugin Development Guide
Plugins extend the Wanaku admin UI by adding pages, navigation entries, and backend service integration without modifying the core application. This guide shows you how to build one.
This guide is for plugin developers and platform operators. It explains the runtime contract, manifest, host API, backend mappings, local tests, and cleanup rules.
Contents
- Overview
- Quick Start
- Manifest Reference
- Plugin Lifecycle
- PluginHost API Reference
- Backend Configuration
- Complete Example
- Using Carbon Design System
- Testing Locally
- Best Practices
Overview
Plugins are ES modules loaded at runtime. A plugin can:
- Add navigation entries to the sidebar
- Register new pages under custom routes
- Call backend services through an authenticated proxy
- Show notifications to users
The supported plugin contract is the host API. Plugins must keep DOM changes inside their page container. They must not read internal host state or call service URLs outside the configured proxy.
Plugins currently run as same-origin JavaScript. Wanaku does not sandbox them or enforce manifest permissions. Install only trusted plugins. The host API contract does not expose React internals or Carbon component instances.
Quick Start
Five steps to see a plugin running:
- Create a plugin directory under your plugins path (e.g.,
/data/plugins/my-plugin/) - Create
plugin.jsonwith id, name, version, and entrypoint - Create
plugin.jswithactivateanddeactivateexports - Start the server with
--plugins-path /data/plugins - Open the admin UI — your plugin page appears in the navigation
The following example is the smallest working plugin:
plugin.json:
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"entrypoint": "plugin.js"
}plugin.js:
export async function activate(host) {
host.navigation.add({
id: "my-page",
label: "My Page",
route: "/my-page"
});
host.pages.register({
route: "/my-page",
mount(container) {
container.innerHTML = "<h2>Hello from plugin</h2>";
}
});
}
export function deactivate() {}Restart the server. The "My Page" link appears in the sidebar.
Plugin Structure
A plugin lives in its own directory under the path specified by --plugins-path. The typical layout:
my-plugin/
├── plugin.json # Manifest (required)
├── plugin.js # Entry point (required)
├── plugin.css # Optional styles
└── assets/ # Optional images, fonts, etc.
└── logo.svgThe manifest (plugin.json) tells the host what files to load. The entry point exports activate() and deactivate() functions. Styles and assets are optional — the host loads stylesheets declared in the manifest and serves assets at /plugins/{pluginId}/{path}.
Manifest Reference (plugin.json)
The manifest is a JSON file with these fields:
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Unique plugin identifier (kebab-case, no spaces) |
name | string | yes | Human-readable name shown in error messages |
version | string | yes | Semver version (e.g., "1.2.3") |
entrypoint | string | yes | Path to the JavaScript module (relative to plugin dir) |
styles | string[] | no | CSS files to load (relative to plugin dir) |
requires.hostApi | string | no | Required host API version (semver range, e.g., ">=1.0 <2.0") |
requires.services | object[] | no | Backend services this plugin needs (see Backend Configuration) |
permissions | string[] | no | Declared capabilities (not enforced yet, reserved for future use) |
Example with all fields:
{
"id": "customer-management",
"name": "Customer Management",
"version": "1.4.0",
"entrypoint": "plugin.js",
"styles": ["plugin.css"],
"requires": {
"hostApi": ">=1.0 <2.0",
"services": [
{"id": "customer-api", "version": "1.0"}
]
},
"permissions": [
"navigation",
"pages",
"notifications"
]
}The manifest must be valid JSON. Missing id, name, version, or entrypoint will prevent the plugin from loading.
Plugin Lifecycle
Plugins have two lifecycle hooks:
activate(host)
Called when the plugin loads. The host parameter is the PluginHost object — your gateway to all platform capabilities. Use this function to register navigation entries, pages, and set up any runtime state.
Returns void or Promise<void>. Errors thrown here prevent the plugin from loading. The host logs the error but continues loading other plugins.
deactivate()
Called when the plugin is unloaded (currently only on page refresh). Clean up timers, event listeners, subscriptions, or other resources here.
Returns void or Promise<void>. If you omit this export, the host assumes that the plugin has no resources to release.
Critical: A plugin that does not release resources in deactivate() can leak memory or leave timers active. Dispose each resource that you register in activate().
PluginHost API Reference
The PluginHost object has five capabilities. All registration methods return a Disposable — an object with a dispose() method you can call to remove the registration early (before deactivate() is called).
host.version
A string identifying the host API version (currently "1.0"). Use this to log compatibility info or implement fallback behavior for different host versions.
console.log(`Running on host API ${host.version}`);host.navigation.add(entry)
Adds a navigation entry to the sidebar. The order value controls its position. Lower values appear first. If multiple plugins use the same value, the host uses registration order.
Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Unique identifier for this nav entry |
label | string | yes | Text shown in the sidebar |
route | string | yes | Route to navigate to (must start with /) |
icon | string | no | Icon name (reserved for future use) |
section | string | no | Grouping hint (reserved for future use) |
order | number | no | Sort order (default: 0) |
Returns: Disposable
Example:
const navDisposable = host.navigation.add({
id: "customers",
label: "Customers",
route: "/customers",
order: 100
});
// Later, remove the nav entry:
// navDisposable.dispose();host.pages.register(page)
Registers a page that renders when the route matches. The host calls your mount(container) function with an HTMLElement — you own everything inside that element. Render your UI however you like: vanilla DOM manipulation, a framework, whatever.
Parameters:
| Field | Type | Description |
|---|---|---|
route | string | Route pattern (must start with /) |
mount | function | (container: HTMLElement) => void | Disposable |
The mount function receives a container element. You can return:
undefined— the host assumes that the plugin releases resources indeactivate()- A
Disposableobject — the host callsdispose()when the route unmounts
Returns: Disposable
Example:
host.pages.register({
route: "/customers",
mount(container) {
container.innerHTML = `
<div class="customer-page">
<h2>Customers</h2>
<p>Customer list here...</p>
</div>
`;
return {
dispose() {
container.innerHTML = "";
}
};
}
});If you use a framework such as React, call its mount function inside mount(). Return a disposable that unmounts the page:
import { createRoot } from "react-dom/client";
import { CustomerPage } from "./CustomerPage.jsx";
host.pages.register({
route: "/customers",
mount(container) {
const root = createRoot(container);
root.render(<CustomerPage />);
return {
dispose() {
root.unmount();
}
};
}
});host.http.get / post / put / delete
Makes HTTP requests to backend services. These methods route through the Rust backend at /api/plugins/{pluginId}/{serviceId}/{path}. The backend resolves the logical service ID to a physical backend URL (configured in wanaku.yaml or the management API).
Why use this instead of fetch()?
- Authentication headers are injected automatically
- CORS is handled (single origin)
- The backend URL is configuration, not hardcoded in your plugin
- Errors trigger redirect to login when auth expires
Signatures:
host.http.get<T>(service: string, path: string): Promise<T>
host.http.post<T>(service: string, path: string, body?: unknown): Promise<T>
host.http.put<T>(service: string, path: string, body?: unknown): Promise<T>
host.http.delete<T>(service: string, path: string): Promise<T>Parameters:
service— logical service identifier (e.g.,"customer-api","chat")path— the path to append (must start with/)body— JSON-serializable object (for POST/PUT)
Returns: Promise<T> — the parsed JSON response
Example:
try {
const customers = await host.http.get("customer-api", "/customers");
console.log("Loaded", customers.length, "customers");
} catch (err) {
console.error("Failed to load customers:", err);
}
await host.http.post("customer-api", "/customers", {
name: "Acme Corp",
email: "contact@acme.example"
});The backend routes this as:
GET /api/plugins/my-plugin/customer-api/customers
→ resolves "customer-api" service
→ proxies to http://customer-service:8080/customersYou configure the mapping in the backend (see Backend Configuration below).
host.notifications.show(message)
Displays a toast notification at the top-right of the screen. Notifications auto-dismiss after a few seconds.
Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
title | string | no | Bold heading |
text | string | yes | Notification body |
kind | string | no | "info", "success", "warning", or "error" (default: "info") |
Example:
host.notifications.show({
title: "Customer Created",
text: "Acme Corp has been added to the system.",
kind: "success"
});
host.notifications.show({
text: "Failed to connect to backend.",
kind: "error"
});Backend Configuration
To call backend services via host.http, you need to map logical service IDs to physical URLs. Two options:
Option 1: Environment Variable + YAML Config
Start the server with --plugins-path and create a wanaku.yaml file with a plugins section:
plugins:
- id: my-plugin
services:
customer-api:
target: http://customer-service:8080
chat:
target: http://localhost:11434The backend reads this on startup and registers the mappings.
Option 2: Management API
You can dynamically register service mappings via the management API (not yet implemented — this is a placeholder for future capability).
Key point: The plugin does not know the backend URL. The platform resolves it. The same plugin can run in development, staging, and production without a code change.
Complete Example
The following example shows the hello-world plugin from the examples directory:
plugin.json:
{
"id": "hello-world",
"name": "Hello World",
"version": "1.0.0",
"entrypoint": "./plugin.js",
"requires": {
"hostApi": ">=1.0 <2.0"
},
"permissions": [
"navigation",
"pages"
]
}plugin.js:
// The activate function is called when the plugin loads.
// It receives the PluginHost object.
export async function activate(host) {
// Register a navigation entry in the sidebar.
// This appears as "Hello Plugin" in the nav.
host.navigation.add({
id: "hello",
label: "Hello Plugin",
route: "/hello",
order: 100,
});
// Register the page that renders at /hello.
// The mount function receives an HTMLElement container.
host.pages.register({
route: "/hello",
mount(container) {
// Render whatever you want inside the container.
// This example uses plain HTML.
container.innerHTML = `
<div style="padding: 2rem;">
<h2>Hello from Plugin!</h2>
<p>This page was contributed by the <strong>hello-world</strong> plugin.</p>
<p>Host API version: <code>${host.version}</code></p>
</div>
`;
// Return a disposable to clean up when the route unmounts.
return {
dispose() {
container.innerHTML = "";
},
};
},
});
}
// The deactivate function is called when the plugin unloads.
// Clean up timers, listeners, subscriptions, etc. here.
export function deactivate() {
// Nothing to clean up in this example.
}To run it:
cargo run -- --plugins-path /path/to/examplesOpen http://localhost:8080 (or your admin UI URL). Click "Hello Plugin" in the sidebar.
Using Carbon Design System
The admin UI uses IBM Carbon Design System. Use Carbon components in first-party plugins for visual consistency. The plugin API does not require or enforce Carbon.
Bundling Approach
Plugins are ES modules that load at runtime. Bundle npm dependencies with a tool such as esbuild, Rollup, or Vite.
Example with esbuild:
npm install @carbon/web-componentssrc/plugin.js:
import "@carbon/web-components/es/components/button/index.js";
export async function activate(host) {
host.pages.register({
route: "/demo",
mount(container) {
container.innerHTML = `
<cds-button>Click Me</cds-button>
`;
}
});
}
export function deactivate() {}Build:
esbuild src/plugin.js --bundle --format=esm --outfile=plugin.jsNow plugin.js includes the Carbon button component. The browser can load it as a single ES module.
Caveat: Carbon React components require React and ReactDOM. Include these dependencies in the plugin bundle. This increases the plugin size. For React-based plugins, use a build tool such as Vite to produce optimized ES modules with code splitting.
Testing Locally
Step-by-step process to test a plugin:
Create the plugin directory:
bashmkdir -p /tmp/plugins/my-pluginEnter the plugin directory:
bashcd /tmp/plugins/my-pluginWrite the manifest:
bashcat > plugin.json <<EOF { "id": "my-plugin", "name": "My Plugin", "version": "1.0.0", "entrypoint": "plugin.js" } EOFWrite the plugin code:
bashcat > plugin.js <<EOF export async function activate(host) { host.navigation.add({ id: "test", label: "Test Page", route: "/test" }); host.pages.register({ route: "/test", mount(container) { container.innerHTML = "<h2>It works!</h2>"; } }); } export function deactivate() {} EOFRun the server:
bashcargo run -- --plugins-path /tmp/pluginsOpen
http://localhost:8080.Select Test Page in the sidebar. The page shows It works!.
Hot reload: The server does not monitor plugin files for changes. Restart the server after you edit a plugin.
Best Practices
Clean Up Resources in deactivate()
If you create timers, event listeners, subscriptions, or other stateful resources in activate(), dispose them in deactivate(). The host cannot dispose resources that the plugin does not return.
Bad:
export async function activate(host) {
setInterval(() => console.log("ping"), 1000);
}
export function deactivate() {
// Timer keeps running — memory leak
}Good:
let timer;
export async function activate(host) {
timer = setInterval(() => console.log("ping"), 1000);
}
export function deactivate() {
clearInterval(timer);
}Use host.http Instead of Raw fetch
Always use host.http for backend calls. It handles authentication, CORS, and service resolution.
Bad:
await fetch("http://customer-service:8080/customers");
// Hard-coded URL, breaks in different environments
// No auth headers, fails if backend requires authentication
// CORS issues if backend is on a different originGood:
await host.http.get("customer-api", "/customers");
// Resolves to the right backend for this environment
// Automatically includes auth headers
// Routes through the host (same origin, no CORS issues)Prefix CSS Classes with Plugin ID
The host and other plugins share the same document. Avoid CSS class name collisions by prefixing your classes.
Bad:
.button { background: red; }Now every button on the page is red.
Good:
.my-plugin-button { background: red; }Only your plugin's buttons are red.
Or use CSS modules if your bundler supports them.
Keep DOM Access Inside the Container
The mount(container) function gives the plugin a container element. Change only elements inside this container.
Bad:
mount(container) {
document.body.appendChild(createModal());
// Now the modal outlives the route
// The host cannot clean it up
}Good:
mount(container) {
const modal = createModal();
container.appendChild(modal);
return {
dispose() {
modal.remove();
}
};
}Use the Disposable Pattern for All Registrations
Each host.navigation.add() and host.pages.register() call returns a Disposable. Store it. Call dispose() when the registration is no longer necessary.
Why? If you need to unregister something before the plugin unloads (e.g., a dynamic nav entry based on user permissions), you can:
const disposable = host.navigation.add({ id: "admin", label: "Admin", route: "/admin" });
// Later, when the user logs out:
disposable.dispose();The navigation entry disappears immediately. The plugin does not have to unload first.
That's the guide. You now know how to build, configure, and test a plugin for Wanaku. Start with the Quick Start example, experiment with the host APIs, and check the examples/hello-plugin/ directory for a working reference implementation.