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¶
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¶
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...)":
- The reference ID is a ULID that links to the server's error log
- Plugins should surface this ID to users so they can report it to operators
- 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:
Return Early¶
If an event doesn't match your criteria, return immediately:
Handle Missing Data¶
Check for missing or invalid data in payloads:
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:
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¶
- Review the echo-bot example
- Learn about the Event System
- Explore Host Functions for advanced capabilities