Skip to content

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

  1. Plugin Tiers
  2. Plugin SDK Overview
  3. Writing a Backend Plugin
  4. HTTP Routes
  5. Database Migrations
  6. Message Bus
  7. Prometheus Metrics
  8. KV Store
  9. Plugin Configuration
  10. RBAC Permissions
  11. Registering Your Plugin
  12. Writing an Agent Plugin
  13. Testing

Plugin Tiers

There are two backend plugin tiers:

TierInterfaceCapabilitiesReference
SDK pluginpluginsdk.BackendPluginHTTP routes, Prometheus metrics, DB migrations, Bus, KV store, per-route RBACinternal/backend/plugins/terminal/
Classic pluginbackendplugin.ServerPluginDB migrations, Bus onlyinternal/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:

InterfaceFilePurpose
BackendPluginplugin.goLifecycle hooks: Init, Start, Stop, Health
PluginManifestmanifest.goDeclarative capabilities, permissions, config schema
HostAPIhost.goAccess to HTTP router, Bus, DB, KV store, Logger
HTTPRouterhttp.goRegister HTTP routes with RBAC
Bushost.goPublish and subscribe to bus messages
DBhost.goRun DB migrations and execute tenant-scoped queries
MetricsRegistrymetrics.goRegister Prometheus counters, gauges, and histograms
KVStorehost.goNamespaced key-value store for plugin state

Writing a Backend Plugin

Implementing BackendPlugin

Create a package under internal/backend/plugins/{your-plugin}/.

go
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.

go
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().

go
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(), not Start(). Routes must be registered before ListenAndServe is called.


HTTP Routes

Use host.HTTP() to register routes. All routes are automatically mounted under /api/v1/plugins/{plugin-id}/.

go
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:

go
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 handler

Database Migrations

Declare migrations as a slice of pluginsdk.Migration. The platform tracks applied migrations in plugin_migrations_{plugin_id} and applies them idempotently.

go
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:

go
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:

go
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() without WithTenant() on RLS tables. Omitting it bypasses tenant isolation.


Message Bus

go
// 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

go
// 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.

go
// 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.

go
// 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:

typescript
can(perms, 'myplugin/items', 'read')   // show items list
can(perms, 'myplugin/items', 'write')  // show create/edit buttons

Default 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:

go
import myplugin "github.com/autofacts/umoo/internal/backend/plugins/myplugin"

// In main():
sdkRegistry.RegisterBuiltin(myplugin.New())

That's it. The SDKPluginManager calls InitStart 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:

go
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
  • AgentHostAPI provides Bus(), Store(), and Logger() only
  • Subscribe to cmd.{plugin}.* topics, publish evt.{plugin}.* topics
  • Listen for cfg.plugins.v1.set to 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:

go
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.

go
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:

bash
make docker-up
make test-e2e

See Also

Umoo — IoT Device Management Platform