Plugin Guide
What you’ll build: a working echo plugin in both Lua and Go. By the end you
will have seen the full plugin lifecycle — manifest, event handler, ABAC policy,
and build — for each plugin type. The examples here use the same event-driven
model as the in-tree echo-bot and core-scenes plugins.
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. This guide walks through building one of each. For the API surface you’ll look up along the way, see the Plugin API reference; for error handling, see Handle plugin errors.
Lua Plugins
Section titled “Lua Plugins”Lua plugins are ideal for simple game logic. They run in a sandboxed Lua VM and require no compilation.
Project Structure
Section titled “Project Structure”plugins/my-plugin/├── plugin.yaml # Plugin manifest└── main.lua # Entry pointManifest (plugin.yaml)
Section titled “Manifest (plugin.yaml)”name: my-pluginversion: 1.0.0type: luaevents: - saypolicies: - 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
Section titled “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 .. '"}' } }endThe event table’s fields, the holomush.* host functions, and the world-query
functions you can call from a handler are catalogued in the
Plugin API reference.
Binary Plugins
Section titled “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
Section titled “Project Structure”plugins/my-binary-plugin/├── plugin.yaml # Plugin manifest├── main.go # Plugin source└── my-plugin # Compiled executableManifest (plugin.yaml)
Section titled “Manifest (plugin.yaml)”name: my-binary-pluginversion: 1.0.0type: binaryevents: - saypolicies: - 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-pluginImplementation
Section titled “Implementation”Binary plugins import github.com/holomush/holomush/pkg/plugin and implement
the Handler interface:
// 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
Section titled “Building”cd plugins/my-binary-plugingo build -o my-plugin .The SDK types (Event, EmitEvent, EventType, ActorKind) used above are
catalogued in the Plugin API reference.
Event Types
Section titled “Event Types”See Event Reference for event types, payload schemas, and stream patterns.
The optional crypto block declares event-type sensitivity contracts
and decryption opt-ins. See Declaring event sensitivity.
ABAC Policies
Section titled “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, which are installed when the plugin loads and removed when it unloads.
For task-focused recipes (granting emission, world reads, KV storage, command registration), see Plugin Access Control; for the common action/resource patterns at a glance, see Common policy patterns.
Best Practices
Section titled “Best Practices”Avoid Echo Loops
Section titled “Avoid Echo Loops”Always check actor_kind to avoid responding to your own events:
if event.actor_kind == "plugin" then return nilendif event.ActorKind == plugin.ActorPlugin { return nil, nil}Return Early
Section titled “Return Early”If an event doesn’t match your criteria, return immediately:
if event.type ~= "say" then return nilendif event.Type != plugin.EventTypeSay { return nil, nil}Handle Missing Data
Section titled “Handle Missing Data”Check for missing or invalid data in payloads:
local msg = event.payload:match('"message":"([^"]*)"')if not msg or msg == "" then return nilendvar 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
Section titled “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.
Example: Dice Roller
Section titled “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 .. '"}' } }endpackage main
import ( "context" cryptorand "crypto/rand" "encoding/json" "fmt" "math/big" "regexp" "strconv" "strings"
"github.com/holomush/holomush/pkg/plugin")
var dicePattern = regexp.MustCompile(`roll\s+(\d+)d(\d+)`)
// secureRoll returns a uniformly random die result in [1, sides] using// crypto/rand. HoloMUSH plugins must use crypto-safe RNG — never math/rand.func secureRoll(sides int) (int, error) { n, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(sides))) if err != nil { return 0, err } return int(n.Int64()) + 1, nil}
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, err := secureRoll(sides) if err != nil { return nil, err } 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{}, })}Next Steps
Section titled “Next Steps”- Review the echo-bot example
- Look up the Plugin API reference — event structure, host functions, SDK types, policy patterns, error types
- Learn to handle plugin errors with correlation IDs
- See the Event Reference for all event types and stream patterns
- Read the API Guide to understand the gRPC protocol layer