Skip to content

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:

TypeLanguageUse CasePerformance
LuaLua 5.1Simple scripts, rapid iterationFast
BinaryGoComplex logic, external APIsFastest

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 are ideal for simple game logic. They run in a sandboxed Lua VM and require no compilation.

plugins/my-plugin/
├── plugin.yaml # Plugin manifest
└── main.lua # Entry point
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
FieldRequiredDescription
nameYesUnique identifier (lowercase, a-z0-9, hyphens)
versionYesSemantic version (e.g., 1.0.0)
typeYesMust be lua for Lua plugins
eventsNoEvent types to receive
policiesNoABAC policies (name + DSL pairs)
lua-pluginYesLua-specific configuration

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

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

plugins/my-binary-plugin/
├── plugin.yaml # Plugin manifest
├── main.go # Plugin source
└── my-plugin # Compiled executable
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

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

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{},
})
}
Terminal window
cd plugins/my-binary-plugin
go build -o my-plugin .

The SDK types (Event, EmitEvent, EventType, ActorKind) used above are catalogued in the Plugin API reference.

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.

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.

Always check actor_kind to avoid responding to your own events:

if event.actor_kind == "plugin" then
return nil
end

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

if event.type ~= "say" then
return nil
end

Check for missing or invalid data in payloads:

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

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.

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