Skip to content

Binary Plugin Author Guide

This guide walks you through building a binary plugin from scratch. By the end you will understand how to structure a Go plugin, wire up storage and service dependencies, expose gRPC services to the host, and test your plugin in isolation. The core-scenes plugin (in plugins/core-scenes/) is the reference implementation — examples throughout pull from it.

Prerequisites: a working HoloMUSH development environment and familiarity with Go. If you are new to plugins entirely, start with Getting Started with Plugins and the Plugin Guide first.

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.

ConsiderationLuaBinary
ComplexitySimple event reactionsComplex domain logic, state machines
DependenciesNone (sandboxed VM)Any Go library
StorageKV store onlyKV or dedicated PostgreSQL schema
Service exposureNot supportedProvide gRPC services to the host
Iteration speedEdit and reloadCompile, restart
Examplesecho-bot, core-communicationcore-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.

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]"
FieldRequiredDescription
nameYesUnique identifier (lowercase, a-z0-9, hyphens)
versionYesSemantic version
typeYesMust be binary
requiresNoProto service names this plugin depends on
providesNoProto service names this plugin exposes
storageNopostgres for a dedicated schema, omit for KV-only
binary-pluginYesBinary-specific config
commandsNoCommands this plugin handles
policiesNoABAC policies (Cedar-style DSL)

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]"

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{},
})
}

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:

FieldDescription
CommandParsed command name
ArgsEverything after the command name
CharacterIDInvoking character ULID
CharacterNameCharacter display name
LocationIDCharacter’s current location ULID
SessionIDActive session ULID
PlayerIDPlayer account ULID
InvokedAsWhat the player actually typed

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

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.

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.

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.

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>)

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.

Plugins can use two storage tiers:

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.

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

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
}

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

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

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())
}

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)
}
Terminal window
task plugin:build -- my-plugin
Terminal window
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.

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.

TypePurpose
HandlerEvent handler interface (required)
CommandHandlerCommand handler interface (optional)
ServiceProviderService registration and init (optional)
ServeConfigConfiguration for Serve
EventIncoming event from the host
EmitEventOutgoing event to emit
CommandRequestCommand invocation context
CommandResponseCommand result with status and output
CommandStatusOutcome category (OK, Error, Failure, Fatal)
FunctionWhen to use
ServeEvent-only plugins, no service injection
ServeWithServicesPlugins that provide or require services
FunctionPurpose
ConnectOpen a connection pool to the plugin’s schema
RunMigrationsRun embedded SQL migrations (embed.FS)
RunMigrationsFSRun migrations from any fs.FS
ParseSchemaFromConnStringExtract schema name from connection string
FunctionPurpose
ParseBrokerServicesParse required_services map into broker IDs

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.

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"}`,
})
}

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.

MethodPurpose
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.
SetConnectionFocus(ctx, connectionID, focusKey, isSceneGrid)Set per-connection focus (Phase 5). The substrate enforces INV-P5-1: focusKey MUST refer to a scene the character is already a member of; the call is rejected otherwise. Pass isSceneGrid=true to clear focus without removing membership.
AutoFocusOnJoin(ctx, characterID, sceneID)Fan-out focus to all terminal and telnet connections of characterID when the character joins sceneID (Phase 5). Returns a summary {focused, skipped, failed, total_connection_count}. Comms-hub connections are never auto-focused (INV-P5-4).
IsAnyConnFocused(ctx, characterID, sceneID)Returns true if at least one of characterID’s connections currently has FocusKey pointing at sceneID (Phase 5). Read-only.

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.

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

CodeMeaning
SESSION_NOT_FOUNDThe session does not exist.
SESSION_EXPIREDThe session is past its TTL.
FOCUS_ALREADY_MEMBERMembership already exists — treat as idempotent success.
FOCUS_KIND_UNREGISTEREDThe host has no policy registered for this FocusKind.
FOCUS_POLICY_FAILEDThe kind policy rejected the join.
FOCUS_NOT_MEMBER(PresentFocus only) Target is not in the session’s memberships.
  • 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.

Plugin authors don’t write crypto code. The host owns encryption, decryption, and authorization. After Phase 7, plugin-owned audit tables hold ciphertext byte-equal to the bus envelope for sensitive events; the plugin’s PluginAuditService.QueryHistory returns those ciphertext bytes verbatim to the host, which validates and decrypts before delivering to clients.

The pluginsdk package provides two helpers in pkg/plugin/audit.go:

pluginsdk.StoreFromMessage(msg jetstream.Msg) (AuditRow, error)

Section titled “pluginsdk.StoreFromMessage(msg jetstream.Msg) (AuditRow, error)”

Call at PluginAuditService.AuditEvent RPC handler. Extracts an AuditRow from the JetStream message — projection fields (id, subject, type, timestamp, actor) plus crypto envelope (codec, payload, dek_ref, dek_version) plus schema_ver. Plugin authors persist the row fields verbatim into their own audit table.

pluginsdk.LoadForQuery(row AuditRow) (*pluginauditpb.AuditRow, error)

Section titled “pluginsdk.LoadForQuery(row AuditRow) (*pluginauditpb.AuditRow, error)”

Call at PluginAuditService.QueryHistory RPC handler. Converts a stored AuditRow back to the proto frame returned on the stream. Round-trip stable with StoreFromMessage.

Phase 7 requires plugin audit tables to mirror events_audit for crypto-bearing columns:

ColumnTypeNotes
idBYTEA PRIMARY KEY16-byte ULID; matches AuditRow.id
subjectTEXT NOT NULLbus subject
typeTEXT NOT NULLqualified <plugin>:<event_type>
timestampTIMESTAMPTZ NOT NULL
actor_kind, actor_idTEXT, BYTEAfrom AuditRow.actor
payloadBYTEA NOT NULLciphertext when codec != identity
schema_verSMALLINT NOT NULLfrom AuditRow.schema_ver
codecTEXT NOT NULLidentity or xchacha20poly1305-v1
dek_refBIGINT NULLNULL for identity codec
dek_versionINTEGER NULLNULL for identity codec

See plugins/core-scenes/audit.go for a reference implementation.