Skip to content

Plugin Development

Mumimo plugins are self-contained packages consisting of a Python module, a plugin.toml manifest, and tests. Plugins can be core (built-in, always available) or extensions (optional, loaded on demand).

Every plugin lives in its own directory with a fixed layout:

mumimo/plugins/extensions/<name>/
├── __init__.py # Empty or package init
├── <name>.py # class Plugin(PluginBase)
├── plugin.toml # Metadata, commands, roles, aliases
└── tests/
└── test_<name>.py # pytest tests

Core plugins follow the same structure under mumimo/plugins/core/<name>/.

Every plugin must include a plugin.toml that declares its metadata and commands:

[plugin]
name = "my_plugin"
version = "1.0.0"
type = "extension"
audio = false
threading = "single"
wait_for = []
[commands.greet]
description = "Say hello"
help = "!greet — Says hello to the caller."
roles = ["user"]
aliases = ["hello"]
flags = []
Field Type Description
name string Plugin identifier (must match directory name)
version string Semantic version string
type string "core" or "extension"
audio boolean Whether the plugin uses the audio engine
threading string "single" or "worker" — see Threading Modes
wait_for list Command names that must complete before other commands are processed

Each [commands.<name>] table declares a command the plugin handles:

Field Type Description
description string Short description of the command
help string Help text shown to users
roles list Roles required to use the command (e.g. ["user"], ["admin"])
aliases list Alternative command names
flags list Supported flags (e.g. ["--quiet", "-q"])

Every plugin module must define a class Plugin that inherits from PluginBase:

from mumimo.plugins.plugin_base import PluginBase, command, CommandData, PluginContext
class Plugin(PluginBase):
def __init__(self, ctx: PluginContext) -> None:
super().__init__(ctx)
def start(self) -> None:
"""Called when the plugin is loaded. Acquire resources here."""
super().start()
def stop(self) -> None:
"""Called when the plugin is stopped. Release resources here."""
super().stop()
def quit(self) -> None:
"""Called when the bot shuts down. Final cleanup."""
super().quit()
@command("greet")
def cmd_greet(self, data: CommandData) -> None:
self.ctx.gui.gui_info(f"Hello, {data.actor}!")
Method When Called Purpose
start() Plugin loaded Acquire resources, initialise state
stop() Plugin stopped Release resources (can be restarted)
quit() Bot shutting down Final cleanup (terminal)

All lifecycle methods are idempotent — calling them when already in the target state is a no-op. Always call super() first when overriding.

Commands are implemented as methods named cmd_<name> and decorated with @command("<name>"):

@command("roll")
def cmd_roll(self, data: CommandData) -> None:
"""Handle !roll <sides>."""
if not data.args:
self.ctx.gui.gui_error("Usage: !roll <sides>")
return
sides = int(data.args[0])
result = random.randint(1, sides)
self.ctx.gui.gui_result(f"Rolled a {result}")

The data parameter provides everything about the incoming command:

Field Type Description
name str The command name that was invoked
args list[str] Positional arguments (flags stripped)
args_raw str Full raw string after the command name
raw str Complete original message
actor User The user who sent the command
channel Channel | None Channel where the command was sent
flags dict[str, bool] Parsed flags (--flagTrue/False)

Every plugin receives a PluginContext via self.ctx — the capability handle that provides access to all bot subsystems:

Capability Access Description
log self.ctx.log structlog BoundLogger
config self.ctx.config Bot configuration dataclass
paths self.ctx.paths Data directory, log directory, etc.
gui self.ctx.gui GuiService for sending messages
audio self.ctx.audio AudioEngine for playback
mumble self.ctx.mumble MumbleSession (channels, users)
db self.ctx.db SQLite database access
events self.ctx.events Synchronous event bus
scheduler self.ctx.scheduler Scheduled task runner
aliases self.ctx.aliases AliasService for command aliases
history self.ctx.history Per-user command history
plugins self.ctx.plugins PluginRegistry for plugin lookup
privileges self.ctx.privileges PrivilegeService (RBAC dispatch gate)
role_service self.ctx.role_service RoleService for runtime role management
theme_registry self.ctx.theme_registry Theme registry for dynamic themes
boot_time self.ctx.boot_time float timestamp of bot startup

Use the semantic helpers on self.ctx.gui instead of raw quick_gui where possible:

Helper Usage
gui_error(msg) Display an error message (red header)
gui_info(msg) Display an informational message
gui_result(content) Display a result
gui_list(title, items) Display a list of items
gui_reply(actor, prompt, reply) Reply to a specific user’s message

All helpers accept an optional channel keyword argument to target a specific channel.

self.ctx.gui.gui_error("Something went wrong!", channel=data.channel)
self.ctx.gui.gui_list("Available dice", ["d4", "d6", "d8", "d10", "d12", "d20"])

Declared in plugin.toml via the threading field:

  • "single" (default) — Commands execute in the main command dispatch thread. Simpler and sufficient for most plugins that return quickly.

  • "worker" — Commands execute on a worker thread via an executor. Use this for long-running operations (network requests, heavy computation) that would otherwise block other commands.

The wait_for field lists command names that must complete before other commands from the same user are processed. This is useful for serialising access to shared state.

Gate commands by declaring required roles in plugin.toml:

[commands.kick]
description = "Kick a user"
roles = ["admin"]
  • Commands with roles = ["user"] are available to everyone.
  • Commands with roles = ["admin"] require the admin role.
  • The admin role bypasses all gates.
  • Operators can override command roles at runtime via [command_overrides] in config.toml.
  1. Create the plugin directory

    Terminal window
    mkdir -p mumimo/plugins/extensions/my_plugin/tests
  2. Write the plugin module

    Create my_plugin.py with a class Plugin(PluginBase) that implements your commands.

  3. Write the manifest

    Create plugin.toml with [plugin] metadata and [commands.<name>] tables for each command. Make sure every command has a matching @command handler.

  4. Write tests

    Create tests/test_my_plugin.py. Standard practice is to parse plugin.toml and assert the command count matches the number of @command methods, and that the version string is correct.

  5. Test and iterate

    Run the test suite to verify:

    Terminal window
    pytest mumimo/plugins/extensions/my_plugin/tests/ -v
  • Simple: mumimo/plugins/extensions/randomizer/ — stateless dice rolls and coin flips. Good starting point for a new plugin.
  • Complex: mumimo/plugins/extensions/ai_assistant/ — OpenRouter-backed chat, TTS, emotion tracking, karma, soundboard, and tool integration. Demonstrates advanced patterns like worker threading, event subscriptions, and database access.