Developing Plugins
Umoo's plugin system lets you add new capabilities to the platform — new HTTP endpoints, bus consumers, Prometheus metrics, and database schemas — without modifying core server code. This guide walks through writing, testing, and registering a backend SDK plugin.
Table of Contents
- Plugin Tiers
- Plugin SDK Overview
- Writing a Backend Plugin
- HTTP Routes
- Database Migrations
- Message Bus
- Prometheus Metrics
- KV Store
- Plugin Configuration
- RBAC Permissions
- Registering Your Plugin
- Writing an Agent Plugin
- Testing
Plugin Tiers
There are two backend plugin tiers:
| Tier | Interface | Capabilities | Reference |
|---|---|---|---|
| SDK plugin | pluginsdk.BackendPlugin | HTTP routes, Prometheus metrics, DB migrations, Bus, KV store, per-route RBAC | internal/backend/plugins/terminal/ |
| Classic plugin | backendplugin.ServerPlugin | DB migrations, Bus only | internal/backend/plugins/wireguard/ |
Use SDK plugins for new development. Classic plugins exist for legacy reasons and offer a smaller capability surface.
Plugin SDK Overview
The SDK lives in internal/pluginsdk/. It has zero platform dependencies — plugin authors import only this package.
Key interfaces:
| Interface | File | Purpose |
|---|---|---|
BackendPlugin | plugin.go | Lifecycle hooks: Init, Start, Stop, Health |
PluginManifest | manifest.go | Declarative capabilities, permissions, config schema |
HostAPI | host.go | Access to HTTP router, Bus, DB, KV store, Logger |
HTTPRouter | http.go | Register HTTP routes with RBAC |
Bus | host.go | Publish and subscribe to bus messages |
DB | host.go | Run DB migrations and execute tenant-scoped queries |
MetricsRegistry | metrics.go | Register Prometheus counters, gauges, and histograms |
KVStore | host.go | Namespaced key-value store for plugin state |
Writing a Backend Plugin
Implementing BackendPlugin
Create a package under internal/backend/plugins/{your-plugin}/.
package myplugin
import (
"context"
"github.com/autofacts/umoo/internal/pluginsdk"
)
type Plugin struct {
host pluginsdk.HostAPI
}
func New() *Plugin {
return &Plugin{}
}
func (p *Plugin) Manifest() pluginsdk.PluginManifest {
return pluginsdk.PluginManifest{
ID: "myplugin",
Version: "1.0.0",
}
}
func (p *Plugin) Init(ctx context.Context, host pluginsdk.HostAPI) error {
p.host = host
return nil
}
func (p *Plugin) Start(ctx context.Context) error {
return nil
}
func (p *Plugin) Stop(ctx context.Context) error {
return nil
}
func (p *Plugin) Health() pluginsdk.HealthReport {
return pluginsdk.HealthReport{Healthy: true}
}Declaring the Manifest
The Manifest() method tells the platform which capabilities your plugin needs. The platform only provides non-nil HostAPI sub-interfaces for capabilities that are declared here.
func (p *Plugin) Manifest() pluginsdk.PluginManifest {
return pluginsdk.PluginManifest{
ID: "myplugin",
Version: "1.0.0",
// Declare each capability you need:
HTTP: &pluginsdk.HTTPCapability{
Routes: []pluginsdk.RouteDecl{
{Method: "GET", Path: "/items/{id}", SubResource: "items", Action: "read"},
},
},
Bus: &pluginsdk.BusCapability{
Subscribes: []string{"evt.myplugin.v1.*"},
Publishes: []string{"cmd.myplugin.v1.notify"},
},
DB: &pluginsdk.DBCapability{},
Metrics: &pluginsdk.MetricsCapability{},
// Config params shown in the platform UI:
ConfigParams: []pluginsdk.ConfigParam{
{
Key: "timeout_seconds",
Type: pluginsdk.ConfigParamTypeInt,
Default: "30",
Description: "Request timeout in seconds",
},
},
// RBAC permissions seeded at startup:
Permissions: []pluginsdk.PluginPermissionDecl{
{SubResource: "items", Action: "read", Description: "Read items"},
{SubResource: "items", Action: "write", Description: "Create or update items"},
},
}
}Using HostAPI
In Init(ctx, host), call host.*() to get sub-interfaces. Each is non-nil only if you declared the corresponding capability in Manifest().
func (p *Plugin) Init(ctx context.Context, host pluginsdk.HostAPI) error {
p.host = host
// Register HTTP routes
if err := p.registerRoutes(); err != nil {
return err
}
// Run DB migrations
if err := host.DB().Migrate(ctx, migrations); err != nil {
return fmt.Errorf("migration failed: %w", err)
}
// Subscribe to bus topics
host.Bus().Subscribe("evt.myplugin.v1.*", p.handleEvent)
// Register Prometheus metrics
p.itemsTotal = host.Metrics().NewCounter("myplugin_items_total", "Total items processed")
return nil
}Register routes in
Init(), notStart(). Routes must be registered beforeListenAndServeis called.
HTTP Routes
Use host.HTTP() to register routes. All routes are automatically mounted under /api/v1/plugins/{plugin-id}/.
func (p *Plugin) registerRoutes() error {
router := p.host.HTTP()
// Protected route — caller needs permission "myplugin/items:read"
router.Handle("GET /items/{id}", "items", "read", p.handleGetItem)
// Protected route — caller needs permission "myplugin/items:write"
router.Handle("POST /items", "items", "write", p.handleCreateItem)
// Public route — no auth check (use for webhooks or health probes only)
router.HandlePublic("GET /health", p.handleHealth)
return nil
}Handler signature:
func (p *Plugin) handleGetItem(w http.ResponseWriter, r *http.Request) {
// Tenant ID is available from context — injected by TenantAuthMiddleware
tenantID := r.Context().Value(tenantCtxKey).(uuid.UUID)
id := r.PathValue("id")
// ...
}The middleware stack applied to all protected routes:
Rate limit → JWT auth → Tenant auth (X-Tenant-ID header) → RBAC check → your handlerDatabase Migrations
Declare migrations as a slice of pluginsdk.Migration. The platform tracks applied migrations in plugin_migrations_{plugin_id} and applies them idempotently.
var migrations = []pluginsdk.Migration{
{
Version: 1,
SQL: `
CREATE TABLE IF NOT EXISTS myplugin_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE myplugin_items ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON myplugin_items
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
`,
},
}Apply them in Init:
if err := host.DB().Migrate(ctx, migrations); err != nil {
return fmt.Errorf("migrate: %w", err)
}Executing Queries
Always use WithTenant(tenantID) for RLS-protected tables:
func (p *Plugin) getItem(ctx context.Context, tenantID, itemID uuid.UUID) error {
return p.host.DB().WithTenant(tenantID).QueryRow(ctx,
`SELECT id, name FROM myplugin_items WHERE id = $1`,
itemID,
).Scan(&item.ID, &item.Name)
}Never call
host.DB()withoutWithTenant()on RLS tables. Omitting it bypasses tenant isolation.
Message Bus
// Subscribe in Init:
host.Bus().Subscribe("evt.myplugin.v1.*", p.handleEvent)
// Publish from anywhere:
func (p *Plugin) notify(ctx context.Context, deviceID uuid.UUID) error {
return p.host.Bus().Publish(ctx,
fmt.Sprintf("device/%s/cmd.myplugin.v1.notify", deviceID),
map[string]any{"timestamp": time.Now().UTC()},
)
}
// Handler signature:
func (p *Plugin) handleEvent(ctx context.Context, topic string, payload []byte) {
// parse payload, update state, etc.
}Topic naming conventions:
evt.{plugin}.v{N}.{event}— events (device → server or server-internal)cmd.{plugin}.v{N}.{command}— commands (server → device)device/{id}/...— per-device topics
Prometheus Metrics
// Declare in Init:
p.requestsTotal = host.Metrics().NewCounter(
"myplugin_requests_total",
"Total HTTP requests handled by the myplugin plugin",
)
p.activeItems = host.Metrics().NewGauge(
"myplugin_active_items",
"Number of currently active items",
)
p.requestDuration = host.Metrics().NewHistogram(
"myplugin_request_duration_seconds",
"HTTP request duration in seconds",
)
// Use:
p.requestsTotal.Inc()
p.activeItems.Set(float64(count))
p.requestDuration.Observe(elapsed.Seconds())The platform automatically adds a plugin_id label to all metrics registered via MetricsRegistry.
KV Store
The host.Store() KV store is always available (no manifest declaration required). It provides a simple namespaced key-value store backed by the platform's storage layer.
// Store:
if err := p.host.Store().Set(ctx, "last_sync", []byte(time.Now().Format(time.RFC3339))); err != nil {
return fmt.Errorf("store set: %w", err)
}
// Retrieve:
val, err := p.host.Store().Get(ctx, "last_sync")
if err != nil {
return fmt.Errorf("store get: %w", err)
}The store is namespaced per-plugin — "last_sync" in myplugin is isolated from the same key in another plugin.
Plugin Configuration
Read per-tenant plugin configuration via host.Store() or by calling GetPluginConfig through your service layer. Configuration is pushed to device agents via cfg.plugins.v1.set bus messages.
// Read config snapshot (set via SetPluginConfig RPC):
snapshot, err := p.host.Store().Get(ctx, "config")
if err != nil {
return nil
}
var cfg MyConfig
json.Unmarshal(snapshot, &cfg)RBAC Permissions
Permissions declared in Manifest().Permissions are seeded into the permissions table at startup by SDKPluginManager.Init(). Each permission follows the naming convention "{plugin-id}/{subresource}:{action}".
In your frontend code, gate components with:
can(perms, 'myplugin/items', 'read') // show items list
can(perms, 'myplugin/items', 'write') // show create/edit buttonsDefault role assignments: declare them in PluginPermissionDecl.DefaultRoles. If unset, permissions are not granted to any role by default and must be assigned manually by a tenant_admin.
Registering Your Plugin
In cmd/umoo/main.go, register your plugin with the SDK registry:
import myplugin "github.com/autofacts/umoo/internal/backend/plugins/myplugin"
// In main():
sdkRegistry.RegisterBuiltin(myplugin.New())That's it. The SDKPluginManager calls Init → Start automatically during server startup. If your plugin's Init returns an error, startup logs the error but continues — other plugins are not affected.
Writing an Agent Plugin
Agent plugins run on the device. They implement pluginsdk.AgentPlugin:
type AgentPlugin interface {
ID() string
Init(ctx context.Context, host AgentHostAPI) error
Start(ctx context.Context) error
Stop(ctx context.Context) error
Health() HealthReport
}Key differences from backend plugins:
- No HTTP routes or Prometheus metrics
AgentHostAPIprovidesBus(),Store(), andLogger()only- Subscribe to
cmd.{plugin}.*topics, publishevt.{plugin}.*topics - Listen for
cfg.plugins.v1.setto hot-reload configuration
Source: internal/agent/plugins/. Use internal/agent/plugins/telemetry/ as a reference for a simple agent plugin.
Register in cmd/umoo-agent/main.go.
Testing
Unit Testing
Write unit tests in the same package (package myplugin). Use in-file mocks for dependencies:
type mockDB struct {
mu sync.RWMutex
items map[uuid.UUID]*Item
}
func (m *mockDB) WithTenant(id uuid.UUID) pluginsdk.DB { return m }
func (m *mockDB) Migrate(ctx context.Context, migrations []pluginsdk.Migration) error { return nil }
// ...Integration Testing
Integration tests live in tests/e2e/. Use testutil.SeedTenant() to create test tenants and setupTestEnv(t) to connect to the running server.
func TestMyPluginGetItem(t *testing.T) {
requireDockerInfra(t)
env := setupTestEnv(t)
tenant := testutil.SeedTenant(t, env.DB)
// make requests against the running server...
}Run integration tests with:
make docker-up
make test-e2eSee Also
- Terminal Plugin — reference SDK plugin implementation
- Telemetry Plugin — simple agent plugin example
- Auth & RBAC — full permission model