Skip to content

Plugin Guide

HoloMUSH supports two plugin types for extending game functionality:

Type Language Use Case Performance
Lua Lua 5.1 Simple scripts, rapid iteration Fast
Binary Go Complex logic, external APIs Fastest

Both plugin types use the same event-driven model: plugins receive events and can emit new events in response.

Lua Plugins

Lua plugins are ideal for simple game logic. They run in a sandboxed Lua VM and require no compilation.

Project Structure

plugins/my-plugin/
├── plugin.yaml    # Plugin manifest
└── main.lua       # Entry point

Manifest (plugin.yaml)

name: my-plugin
version: 1.0.0
type: lua
events:
  - say
policies:
  - name: "emit-events"
    dsl: |
      permit(principal is plugin, action in ["emit"], resource is stream) when {
        principal.plugin.name == "my-plugin"
      };
lua-plugin:
  entry: main.lua
Field Required Description
name Yes Unique identifier (lowercase, a-z0-9, hyphens)
version Yes Semantic version (e.g., 1.0.0)
type Yes Must be lua for Lua plugins
events No Event types to receive
policies No ABAC policies (name + DSL pairs)
lua-plugin Yes Lua-specific configuration

Event Handler

Lua plugins implement a single on_event function:

-- SPDX-License-Identifier: Apache-2.0
-- Copyright 2026 HoloMUSH Contributors

function on_event(event)
    -- Only respond to say events
    if event.type ~= "say" then
        return nil
    end

    -- Don't echo plugin messages (prevents loops)
    if event.actor_kind == "plugin" then
        return nil
    end

    -- Parse message from payload
    local msg = event.payload:match('"message":"([^"]*)"')
    if not msg then
        return nil
    end

    -- Return events to emit
    return {
        {
            stream = event.stream,
            type = "say",
            payload = '{"message":"Echo: ' .. msg .. '"}'
        }
    }
end

Event Structure

The event table passed to on_event contains:

Field Type Description
id string Unique event ID (ULID)
stream string Event stream (e.g., location:room1)
type string Event type (say, pose, arrive, etc.)
timestamp number Unix milliseconds
actor_kind string "character", "system", or "plugin"
actor_id string Actor identifier
payload string JSON-encoded event data

Host Functions

Lua plugins can call host functions via the holomush global:

-- Logging (no capability required)
holomush.log("info", "Plugin loaded")
holomush.log("debug", "Processing event")
holomush.log("warn", "Something unexpected")
holomush.log("error", "Failed to process")

-- Request ID generation (no capability required)
local id = holomush.new_request_id()

-- Key-value storage (enforced via ABAC)
local value, err = holomush.kv_get("my-key")
local _, err = holomush.kv_set("my-key", "my-value")
local _, err = holomush.kv_delete("my-key")

World Query Functions

Lua plugins can query the game world using these host functions. Access is enforced via ABAC policies declared in plugin.yaml.

-- Query room/location information (enforced via ABAC)
local room, err = holomush.query_room(room_id)
-- Returns: table with id, name, description, type on success
-- Returns: nil, error_message on failure

-- Query character information (enforced via ABAC)
local char, err = holomush.query_character(character_id)
-- Returns: table with id, name, player_id, location_id on success
-- Returns: nil, error_message on failure

-- Query characters in a room (enforced via ABAC)
local chars, err = holomush.query_room_characters(room_id)
-- Returns: array of character tables on success
-- Returns: nil, error_message on failure

-- Query object information (enforced via ABAC)
local obj, err = holomush.query_object(object_id)
-- Returns: table with id, name, description, is_container, owner_id, containment_type on success
-- Returns: nil, error_message on failure

Example manifest with world query policies:

name: world-aware-plugin
version: 1.0.0
type: lua
events:
  - say
policies:
  - name: "read-world"
    dsl: |
      permit(principal is plugin, action in ["read"], resource is world_object) when {
        principal.plugin.name == "world-aware-plugin" &&
        (resource like "location:*" || resource like "character:*")
      };
lua-plugin:
  entry: main.lua

Binary Plugins

Binary plugins are Go programs that communicate with HoloMUSH over gRPC using HashiCorp's go-plugin system. They offer maximum performance and access to Go's ecosystem.

Project Structure

plugins/my-binary-plugin/
├── plugin.yaml    # Plugin manifest
├── main.go        # Plugin source
└── my-plugin      # Compiled executable

Manifest (plugin.yaml)

name: my-binary-plugin
version: 1.0.0
type: binary
events:
  - say
policies:
  - name: "emit-events"
    dsl: |
      permit(principal is plugin, action in ["emit"], resource is stream) when {
        principal.plugin.name == "my-binary-plugin"
      };
binary-plugin:
  executable: my-plugin

Implementation

Binary plugins import github.com/holomush/holomush/pkg/plugin and implement the Handler interface:

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 HoloMUSH Contributors

package main

import (
    "context"
    "encoding/json"

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

type EchoPlugin struct{}

func (p *EchoPlugin) HandleEvent(ctx context.Context, event plugin.Event) ([]plugin.EmitEvent, error) {
    // Only respond to say events
    if event.Type != plugin.EventTypeSay {
        return nil, nil
    }

    // Don't echo plugin messages
    if event.ActorKind == plugin.ActorPlugin {
        return nil, nil
    }

    // Parse payload
    var payload struct {
        Message string `json:"message"`
    }
    if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
        return nil, nil
    }

    // Emit echo response
    responsePayload, _ := json.Marshal(map[string]string{
        "message": "Echo: " + payload.Message,
    })

    return []plugin.EmitEvent{
        {
            Stream:  event.Stream,
            Type:    plugin.EventTypeSay,
            Payload: string(responsePayload),
        },
    }, nil
}

func main() {
    plugin.Serve(&plugin.ServeConfig{
        Handler: &EchoPlugin{},
    })
}

Building

cd plugins/my-binary-plugin
go build -o my-plugin .

SDK Types

The plugin SDK provides these core types:

// Event received by plugins
type Event struct {
    ID        string
    Stream    string
    Type      EventType
    Timestamp int64     // Unix milliseconds
    ActorKind ActorKind
    ActorID   string
    Payload   string    // JSON string
}

// Event to emit in response
type EmitEvent struct {
    Stream  string
    Type    EventType
    Payload string // JSON string
}

// Event types
const (
    EventTypeSay    EventType = "say"
    EventTypePose   EventType = "pose"
    EventTypeArrive EventType = "arrive"
    EventTypeLeave  EventType = "leave"
    EventTypeSystem EventType = "system"
)

// Actor kinds
const (
    ActorCharacter ActorKind = iota
    ActorSystem
    ActorPlugin
)

Event Types

Both plugin types handle the same event types:

Communication Events

Type Description Payload
say Character speech {"message": "text"}
pose Character action/emote {"message": "text"}
system System-generated message {"message": "text"}

World Events

Type Description Payload Fields
move Character or object moved entity_type, entity_id, from_type, from_id, to_type, to_id, exit_id?, exit_name?
object_create Object created object_id, object_name, location_id
object_destroy Object destroyed object_id, object_name
object_use Object used object_id, object_name, character_id
object_examine Object examined object_id, object_name, character_id
object_give Object transferred between chars object_id, object_name, from_character_id, to_character_id

See the World Model Design for complete payload specifications.

ABAC Policies

Plugins declare access policies in their manifest using Cedar-style DSL. The ABAC engine is default-deny — plugins can only perform actions explicitly permitted by their policies.

Policies are installed to the PolicyStore when a plugin is loaded and removed when the plugin is unloaded.

Policy Structure

Each policy has a name (for identification) and a dsl block containing one or more Cedar-style permit statements:

policies:
  - name: "emit-events"
    dsl: |
      permit(principal is plugin, action in ["emit"], resource is stream) when {
        principal.plugin.name == "my-plugin"
      };
  - name: "kv-access"
    dsl: |
      permit(principal is plugin, action in ["read", "write"], resource is kv) when {
        principal.plugin.name == "my-plugin"
      };

Common Policy Patterns

Access Needed Action Resource Pattern
Emit events to streams "emit" "stream:*"
Read locations "read" "location:*"
Read characters "read" "character:*"
Read objects "read" "object:*"
Key-value read "read" "kv:*"
Key-value write "write" "kv:*"
Key-value delete "delete" "kv:*"
Execute commands "execute" "command:*"

Error Handling

Host functions return errors as a second return value. Understanding error types helps plugins respond appropriately.

Error Types

Error Message Cause Recovery
"room not found" Room ID doesn't exist Check ID validity, handle missing
"character not found" Character ID doesn't exist Check ID validity, handle missing
"object not found" Object ID doesn't exist Check ID validity, handle missing
"access denied" Plugin lacks required ABAC policy Add policy to manifest
"query timed out" Query exceeded 5-second timeout Simplify query or retry later
"internal error (ref: XXXX...) Server error with correlation ID Log and surface to user

Correlation ID Pattern

When a host function returns an error like "internal error (ref: 01JCXYZ...)":

  1. The reference ID is a ULID that links to the server's error log
  2. Plugins should surface this ID to users so they can report it to operators
  3. Operators can search logs for the ID to find the full stack trace and context

This pattern enables efficient debugging of production issues without exposing internal details to end users.

Example Error Handling

local room, err = holomush.query_room(room_id)
if err then
    if err:match("not found") then
        -- Handle missing entity gracefully
        holomush.log("debug", "Room not found: " .. room_id)
        return nil
    elseif err:match("access denied") then
        -- Permission error - likely missing ABAC policy
        holomush.log("warn", "Permission denied for room query")
        return nil
    elseif err:match("internal error") then
        -- Surface correlation ID to user for debugging
        holomush.log("error", "Server error - " .. err)
        -- Consider notifying the user with the reference ID
        return {
            {
                stream = event.stream,
                type = "system",
                payload = '{"message":"An error occurred. Reference: ' ..
                    err:match("ref: ([^)]+)") .. '"}'
            }
        }
    end
    return nil
end
-- Use room data safely
holomush.log("debug", "Found room: " .. room.name)

For Operators

When users report correlation IDs, search server logs to find the full error:

# Plain text logs
grep "error_id=01JCXYZ" /var/log/holomush/server.log

# Structured JSON logs
jq 'select(.error_id == "01JCXYZ...")' /var/log/holomush/server.json

# With journald
journalctl -u holomush | grep "error_id=01JCXYZ"

The log entry will contain the full stack trace, original error message, and additional context like the plugin name and operation being performed.

Best Practices

Avoid Echo Loops

Always check actor_kind to avoid responding to your own events:

if event.actor_kind == "plugin" then
    return nil
end
if event.ActorKind == plugin.ActorPlugin {
    return nil, nil
}

Return Early

If an event doesn't match your criteria, return immediately:

if event.type ~= "say" then
    return nil
end
if event.Type != plugin.EventTypeSay {
    return nil, nil
}

Handle Missing Data

Check for missing or invalid data in payloads:

local msg = event.payload:match('"message":"([^"]*)"')
if not msg or msg == "" then
    return nil
end
var payload struct {
    Message string `json:"message"`
}
if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
    return nil, nil // Skip invalid payloads
}
if payload.Message == "" {
    return nil, nil
}

Keep Handlers Fast

Plugin handlers have a 5-second timeout. If exceeded:

  • The call fails with a timeout error
  • The event is skipped for that plugin
  • The plugin continues receiving future events

For slow operations, consider caching results or offloading work to external systems.

Stream Patterns

Events are organized into streams. Subscribe to streams using patterns:

Pattern Matches
location:* All location events
location:room1 Specific location only
global:* All global events
character:* All character-specific events
* Everything

Example: Dice Roller

A complete example showing both plugin types:

-- plugins/dice/main.lua
-- Responds to "roll XdY" commands

function on_event(event)
    if event.type ~= "say" then
        return nil
    end

    local msg = event.payload:match('"message":"([^"]*)"')
    if not msg then
        return nil
    end

    -- Match "roll XdY" pattern
    local count, sides = msg:match("roll%s+(%d+)d(%d+)")
    if not count then
        return nil
    end

    count = tonumber(count)
    sides = tonumber(sides)

    if count < 1 or count > 100 or sides < 2 or sides > 100 then
        return nil
    end

    -- Roll dice
    local total = 0
    local results = {}
    for i = 1, count do
        local roll = math.random(1, sides)
        total = total + roll
        table.insert(results, tostring(roll))
    end

    local result_str = table.concat(results, " + ")
    local response = string.format("Rolled %dd%d: %s = %d",
        count, sides, result_str, total)

    return {
        {
            stream = event.stream,
            type = "say",
            payload = '{"message":"' .. response .. '"}'
        }
    }
end
// plugins/dice/main.go
package main

import (
    "context"
    "encoding/json"
    "fmt"
    "math/rand"
    "regexp"
    "strconv"
    "strings"

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

var dicePattern = regexp.MustCompile(`roll\s+(\d+)d(\d+)`)

type DicePlugin struct{}

func (p *DicePlugin) HandleEvent(ctx context.Context, event plugin.Event) ([]plugin.EmitEvent, error) {
    if event.Type != plugin.EventTypeSay {
        return nil, nil
    }

    var payload struct {
        Message string `json:"message"`
    }
    if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
        return nil, nil
    }

    matches := dicePattern.FindStringSubmatch(payload.Message)
    if matches == nil {
        return nil, nil
    }

    count, _ := strconv.Atoi(matches[1])
    sides, _ := strconv.Atoi(matches[2])

    if count < 1 || count > 100 || sides < 2 || sides > 100 {
        return nil, nil
    }

    var total int
    var results []string
    for i := 0; i < count; i++ {
        roll := rand.Intn(sides) + 1
        total += roll
        results = append(results, strconv.Itoa(roll))
    }

    response := fmt.Sprintf("Rolled %dd%d: %s = %d",
        count, sides, strings.Join(results, " + "), total)

    responsePayload, _ := json.Marshal(map[string]string{
        "message": response,
    })

    return []plugin.EmitEvent{
        {
            Stream:  event.Stream,
            Type:    plugin.EventTypeSay,
            Payload: string(responsePayload),
        },
    }, nil
}

func main() {
    plugin.Serve(&plugin.ServeConfig{
        Handler: &DicePlugin{},
    })
}

Migration Guide

WithWorldQuerier to WithWorldService

Deprecation Notice

WithWorldQuerier is deprecated and will be removed in v1.0.0. Migrate to WithWorldService before upgrading.

Why Migrate?

WithWorldService provides per-plugin ABAC (Attribute-Based Access Control) authorization. Each plugin receives its own authorization subject (plugin:<name>), enabling:

  • Fine-grained access control: Operators can grant different plugins different world access permissions
  • Audit logging: Plugin queries are traceable to specific plugins
  • Security isolation: Plugins only access what they're authorized to access

WithWorldQuerier bypasses authorization entirely, which is a security risk in production environments.

Migration Steps

Replace WithWorldQuerier with WithWorldService:

// Legacy approach - no authorization
funcs := hostfunc.New(
    kvStore,
    hostfunc.WithWorldQuerier(querier),
)
// New approach - per-plugin ABAC authorization
funcs := hostfunc.New(
    kvStore,
    hostfunc.WithWorldService(worldService),
)

Interface Changes

The key difference is that WorldService methods include a subjectID parameter for authorization:

Interface Method Signature
WorldQuerier GetLocation(ctx, id)
WorldService GetLocation(ctx, subjectID, id)

The host function system automatically provides the subject ID based on the plugin name (plugin:<name> via access.PluginSubject()), so plugin code itself does not need changes.

Timeline

Version Status
Current WithWorldQuerier deprecated
v1.0.0 WithWorldQuerier removed

Next Steps