Skip to content

Binary Plugin Author Guide

Binary plugins are standalone Go programs that communicate with HoloMUSH over gRPC using HashiCorp's go-plugin system. They run as separate processes with full access to Go's ecosystem.

When to use binary plugins

Consideration Lua Binary
Complexity Simple event reactions Complex domain logic, state machines
Dependencies None (sandboxed VM) Any Go library
Storage KV store only KV or dedicated PostgreSQL schema
Service exposure Not supported Provide gRPC services to the host
Iteration speed Edit and reload Compile, restart
Examples echo-bot, core-communication core-scenes

Use binary plugins when you need persistent storage, complex data models, or want to expose a gRPC service that other parts of the system can call.

Plugin manifest

Every plugin needs a plugin.yaml in its directory. Here is the core-scenes manifest as a reference:

name: core-scenes
version: 1.0.0
type: binary

requires:
  - holomush.world.v1.WorldService

provides:
  - holomush.scene.v1.SceneService

storage: postgres

binary-plugin:
  executable: core-scenes

commands:
  - name: scene
    capabilities:
      - action: write
        resource: scene
        scope: local
    help: "Manage RP scenes"
    usage: "scene <subcommand> [args]"

  - name: scenes
    help: "Browse open scenes"
    usage: "scenes [--tags tag1,tag2]"

Field reference

Field Required Description
name Yes Unique identifier (lowercase, a-z0-9, hyphens)
version Yes Semantic version
type Yes Must be binary
requires No Proto service names this plugin depends on
provides No Proto service names this plugin exposes
storage No postgres for a dedicated schema, omit for KV-only
binary-plugin Yes Binary-specific config
commands No Commands this plugin handles
policies No ABAC policies (Cedar-style DSL)

Commands

Each command entry declares a name, help text, usage string, and optional capabilities. Capabilities use the two-layer authorization model:

commands:
  - name: scene
    capabilities:
      - action: write
        resource: scene
        scope: local    # local | self | global
    help: "Manage RP scenes"
    usage: "scene <subcommand> [args]"

Getting started

Minimal plugin

Create a directory under plugins/ and add a plugin.yaml and main.go:

plugins/my-plugin/
  plugin.yaml
  main.go

plugin.yaml:

name: my-plugin
version: 1.0.0
type: binary
binary-plugin:
  executable: my-plugin

main.go:

package main

import (
    "context"

    pluginsdk "github.com/holomush/holomush/pkg/plugin"
)

type myPlugin struct{}

func (p *myPlugin) HandleEvent(_ context.Context, event pluginsdk.Event) ([]pluginsdk.EmitEvent, error) {
    // Process events here
    return nil, nil
}

func main() {
    pluginsdk.Serve(&pluginsdk.ServeConfig{
        Handler: &myPlugin{},
    })
}

Adding command handling

Implement CommandHandler to handle commands declared in your manifest:

func (p *myPlugin) HandleCommand(_ context.Context, req pluginsdk.CommandRequest) (*pluginsdk.CommandResponse, error) {
    switch req.Command {
    case "greet":
        return pluginsdk.OK("Hello, " + req.CharacterName + "!"), nil
    default:
        return pluginsdk.Errorf("unknown subcommand: %s", req.Args), nil
    }
}

The CommandRequest provides full context about the invoking player:

Field Description
Command Parsed command name
Args Everything after the command name
CharacterID Invoking character ULID
CharacterName Character display name
LocationID Character's current location ULID
SessionID Active session ULID
PlayerID Player account ULID
InvokedAs What the player actually typed

Response helpers: pluginsdk.OK(output), pluginsdk.Errorf(fmt, args...), pluginsdk.Failuref(fmt, args...).

Service contracts

requires

The requires list declares proto services your plugin depends on. The host resolves these and provides connection details during Init. For Lua plugins, requires entries inject capability host functions (e.g., session.*, alias.*). For binary plugins, they provide gRPC client connections via the broker.

provides

The provides list declares proto services your plugin exposes. The host can proxy RPCs from other subsystems to your plugin's gRPC server. Services are registered on the same go-plugin transport via RegisterServices.

Dependency resolution

The plugin manager validates that all requires entries can be satisfied before loading a plugin. If a required service is unavailable, the plugin fails to load with a clear error message.

Service injection

Binary plugins that declare requires receive service connections through the Init RPC. The host sends a ServiceConfig containing:

  • connection_string -- PostgreSQL DSN (when storage: postgres is declared)
  • required_services -- map of service name to broker address (broker:<id>)

Receiving required services

Use ParseBrokerServices to extract broker IDs, then dial each service:

func (p *myPlugin) Init(ctx context.Context, config *pluginv1.ServiceConfig) error {
    // Parse broker addresses from required_services map
    services, err := pluginsdk.ParseBrokerServices(config.GetRequiredServices())
    if err != nil {
        return fmt.Errorf("parse broker services: %w", err)
    }

    // Dial required services via the GRPCBroker
    for name, brokerID := range services {
        conn, err := p.broker.Dial(brokerID)
        if err != nil {
            return fmt.Errorf("dial %s: %w", name, err)
        }
        switch name {
        case "holomush.world.v1.WorldService":
            p.worldClient = worldv1.NewWorldServiceClient(conn)
        }
    }
    return nil
}

Each broker ID maps to a service the host is serving on behalf of your plugin. The returned grpc.ClientConn gives you a typed gRPC client for that service.

Storage

Plugins can use two storage tiers:

KV store

All plugins (including Lua) can use the namespaced key-value store via the PluginHostService callbacks. KV operations are scoped to the plugin name and enforced by ABAC policies declared in the manifest.

PostgreSQL (dedicated schema)

Binary plugins that declare storage: postgres get an isolated PostgreSQL schema provisioned automatically by the SchemaProvisioner:

  1. The host creates a schema named plugin_<name> (e.g., plugin_core_scenes)
  2. A dedicated database role is created with access restricted to that schema
  3. The connection string (with search_path set) is passed via Init

Using the storage SDK

The pkg/plugin/storage package provides Connect and RunMigrationsFS:

import (
    "embed"
    "io/fs"

    "github.com/holomush/holomush/pkg/plugin/storage"
)

//go:embed migrations/*.up.sql
var migrationsFS embed.FS

func NewStore(ctx context.Context, connString string) (*Store, error) {
    pool, err := storage.Connect(ctx, connString)
    if err != nil {
        return nil, err
    }

    // Extract the sub-filesystem for migrations
    sub, err := fs.Sub(migrationsFS, "migrations")
    if err != nil {
        pool.Close()
        return nil, err
    }

    if err := storage.RunMigrationsFS(ctx, pool, sub); err != nil {
        pool.Close()
        return nil, err
    }

    return &Store{pool: pool}, nil
}

Writing migrations

Place SQL files in a migrations/ directory with sequential numbering:

plugins/my-plugin/
  migrations/
    000001_initial.up.sql
    000001_initial.down.sql

The storage SDK tracks applied migrations in a plugin_migrations table within your schema. Only .up.sql files are executed; .down.sql files are for manual rollback.

Migration rules:

  • Use IF NOT EXISTS / IF EXISTS for idempotency
  • No triggers or functions -- all logic lives in Go
  • One logical change per migration file

Providing services

Plugins that declare provides use ServeWithServices instead of Serve. Your plugin struct implements ServiceProvider:

type ServiceProvider interface {
    // RegisterServices registers gRPC services on the go-plugin transport.
    RegisterServices(registrar grpc.ServiceRegistrar)

    // Init is called by the host with DB connection string and service config.
    Init(ctx context.Context, config *pluginv1.ServiceConfig) error
}

Full example (from core-scenes)

type scenePlugin struct {
    store   *SceneStore
    service *SceneServiceImpl
}

// HandleEvent processes events (no-op for scenes currently).
func (p *scenePlugin) HandleEvent(_ context.Context, _ pluginsdk.Event) ([]pluginsdk.EmitEvent, error) {
    return nil, nil
}

// HandleCommand handles scene commands.
func (p *scenePlugin) HandleCommand(_ context.Context, req pluginsdk.CommandRequest) (*pluginsdk.CommandResponse, error) {
    return pluginsdk.OK("Use scene service RPCs."), nil
}

// RegisterServices exposes SceneService on the go-plugin transport.
func (p *scenePlugin) RegisterServices(registrar grpc.ServiceRegistrar) {
    scenev1.RegisterSceneServiceServer(registrar, p.service)
}

// Init wires up storage and services.
func (p *scenePlugin) Init(ctx context.Context, config *pluginv1.ServiceConfig) error {
    connStr := config.GetConnectionString()
    if connStr == "" {
        return oops.Code("SCENE_INIT_FAILED").Errorf("connection_string is required")
    }

    store, err := NewSceneStore(ctx, connStr)
    if err != nil {
        return err
    }

    p.store = store
    p.service.store = store
    return nil
}

func main() {
    plugin := &scenePlugin{
        service: &SceneServiceImpl{},
    }

    pluginsdk.ServeWithServices(
        &pluginsdk.ServeConfig{Handler: plugin},
        plugin,
    )
}

Key pattern: pre-allocate the service struct in main() so that RegisterServices (called during gRPC server setup, before Init) has a valid receiver. Init wires the store into both the plugin and the service.

Testing

Unit tests with mocks

Define a narrow interface for your store and mock it:

type sceneStorer interface {
    CreateScene(ctx context.Context, row *SceneRow) error
    GetScene(ctx context.Context, id string) (*SceneRow, error)
    // ...
}

// In tests, use a mock implementation
type mockStore struct {
    scenes map[string]*SceneRow
}

func (m *mockStore) CreateScene(_ context.Context, row *SceneRow) error {
    m.scenes[row.ID] = row
    return nil
}

Use testify for assertions:

func TestCreateSceneRejectsEmptyTitle(t *testing.T) {
    svc := NewSceneServiceImpl(&mockStore{scenes: make(map[string]*SceneRow)})

    _, err := svc.CreateScene(context.Background(), &scenev1.CreateSceneRequest{
        CharacterId: "01ABC",
        Title:       "",
    })

    require.Error(t, err)
    st, ok := status.FromError(err)
    require.True(t, ok)
    assert.Equal(t, codes.InvalidArgument, st.Code())
}

Integration tests with testcontainers

For database tests, use testcontainers to spin up a PostgreSQL instance:

//go:build integration

func TestSceneStoreCreatesAndRetrievesScene(t *testing.T) {
    ctx := context.Background()
    connStr := setupTestDB(t) // testcontainers helper

    store, err := NewSceneStore(ctx, connStr)
    require.NoError(t, err)
    defer store.Close()

    row := &SceneRow{
        ID:      "01TEST",
        Title:   "Test Scene",
        OwnerID: "01OWNER",
        State:   "active",
        // ...
    }

    require.NoError(t, store.CreateScene(ctx, row))

    got, err := store.GetScene(ctx, "01TEST")
    require.NoError(t, err)
    assert.Equal(t, "Test Scene", got.Title)
}

Building

Single plugin

task plugin:build -- my-plugin

All plugins

task plugin:build-all

This discovers all binary plugins (directories with type: binary in plugin.yaml) and compiles them for the host platform plus linux/amd64 and linux/arm64.

Plugin discovery

The host discovers plugins at startup by scanning the plugins/ directory for plugin.yaml files. Binary plugins must have a compiled executable matching the binary-plugin.executable field in the manifest.

SDK reference

Core types

Type Purpose
Handler Event handler interface (required)
CommandHandler Command handler interface (optional)
ServiceProvider Service registration and init (optional)
ServeConfig Configuration for Serve
Event Incoming event from the host
EmitEvent Outgoing event to emit
CommandRequest Command invocation context
CommandResponse Command result with status and output
CommandStatus Outcome category (OK, Error, Failure, Fatal)

Entry points

Function When to use
Serve Event-only plugins, no service injection
ServeWithServices Plugins that provide or require services

Storage SDK (pkg/plugin/storage)

Function Purpose
Connect Open a connection pool to the plugin's schema
RunMigrations Run embedded SQL migrations (embed.FS)
RunMigrationsFS Run migrations from any fs.FS
ParseSchemaFromConnString Extract schema name from connection string

Broker helpers (pkg/plugin)

Function Purpose
ParseBrokerServices Parse required_services map into broker IDs

EventSink

The EventSink facade is the plugin-side entry point for emitting events on behalf of the plugin. Plugins that need to publish events to the host's event store (rather than returning them from HandleEvent) call EventSink.Emit directly. The SDK adapter detects EventSinkAware during Init and injects a broker-backed sink.

Declaring EventSinkAware

type myPlugin struct {
    sink pluginsdk.EventSink
}

func (p *myPlugin) SetEventSink(sink pluginsdk.EventSink) {
    p.sink = sink
}

func (p *myPlugin) someMethod(ctx context.Context, stream string) error {
    return p.sink.Emit(ctx, pluginsdk.EmitIntent{
        Stream:  stream,
        Type:    "my_event",
        Payload: `{"key":"value"}`,
    })
}

FocusClient

The FocusClient facade is the plugin-side entry point for driving server-owned session focus state. Plugins that need to declare a character's participation in a focused context — scenes today, mail/admin-views in the future — call FocusClient rather than mutating session state directly. All calls cross the plugin broker (mTLS) to the host's PluginHostService.

Methods

Method Purpose
JoinFocus(ctx, sessionID, target) Add a focus membership. The server determines streams, replay mode, and cursor baselines based on the target's FocusKind. Idempotent — treat FOCUS_ALREADY_MEMBER as success.
LeaveFocus(ctx, sessionID, target) Remove a focus membership. Idempotent on non-member. Clears PresentingFocus if it pointed at the removed target.
PresentFocus(ctx, sessionID, target) Update the session's presenting-focus pointer. The target MUST already be a member; non-members get FOCUS_NOT_MEMBER. No replay or subscription change — pure bookkeeping.
QueryStreamHistory(ctx, req) Read the tail of a stream for plugin-side display (e.g., last 20 messages on join). Read-only — does not mutate cursors. The host clamps Count at 500.

Declaring FocusClientAware

A plugin opts in by implementing FocusClientAware:

type scenePlugin struct {
    focusClient pluginsdk.FocusClient
}

func (p *scenePlugin) SetFocusClient(client pluginsdk.FocusClient) {
    p.focusClient = client
}

func (p *scenePlugin) handleJoin(ctx context.Context, req pluginsdk.CommandRequest, args string) (*pluginsdk.CommandResponse, error) {
    sceneID := strings.TrimSpace(args)
    // ... persist the DB row first ...

    err := p.focusClient.JoinFocus(ctx, req.SessionID, pluginsdk.FocusKey{
        Kind:     pluginsdk.FocusKindScene,
        TargetID: sceneID,
    })
    if err != nil {
        // Inspect oops error code; FOCUS_ALREADY_MEMBER is idempotent-success.
        return pluginsdk.Errorf("failed to join scene: %v", err), nil
    }
    return pluginsdk.OK(fmt.Sprintf("Joined scene %s.", sceneID)), nil
}

The SDK adapter detects FocusClientAware during Init and injects a broker-backed client. EventSink and FocusClient share a single *grpc.ClientConn to PluginHostService, so a plugin implementing both EventSinkAware and FocusClientAware opens ONE connection — not two.

Error codes

FocusClient methods return oops-coded errors; inspect via errors.As:

Code Meaning
SESSION_NOT_FOUND The session does not exist.
SESSION_EXPIRED The session is past its TTL.
FOCUS_ALREADY_MEMBER Membership already exists — treat as idempotent success.
FOCUS_KIND_UNREGISTERED The host has no policy registered for this FocusKind.
FOCUS_POLICY_FAILED The kind policy rejected the join.
FOCUS_NOT_MEMBER (PresentFocus only) Target is not in the session's memberships.

Invariants

  • Plugins MUST NOT mutate session.FocusMemberships directly. The facade is the only sanctioned path.
  • Plugins MUST NOT declare replay modes or stream names on focus RPCs — the server owns those decisions.
  • QueryStreamHistory is strictly read-only.

References