← Documentation  /  Developer Guide

Build a plugin

The full SDK reference for plugin developers — sandboxed Python, shipped as a zip, distributed through the marketplace.

These docs are publicly viewable. To publish a plugin you'll need a free MMO Maid account — sign in to access the Dev Portal.

SDK v0.5.1 · Plugins are sandboxed Python processes. Write a handler, declare your capabilities, ship a zip — we run it in a locked-down Docker container and stream Discord events to it over JSON-RPC.

Discord events

Use @plugin.on_event(name) for raw Discord events. Sixteen event types are dispatched to plugins:

EventPayload highlights
message_createmessage_id, channel_id, author_id, author_bot, content, timestamp
message_editmessage_id, channel_id, content
message_deletemessage_id, channel_id
member_joinuser_id, username, guild_id
member_leaveuser_id, username, guild_id
member_updateuser_id, roles
reaction_addmessage_id, channel_id, user_id, emoji
reaction_removemessage_id, channel_id, user_id, emoji
voice_state_updateuser_id, channel_id, self_mute, self_deaf
interaction_createinteraction_id, interaction_type, command_name, custom_id, modal_values
channel_createid, name, type
channel_deleteid, name
channel_updateid, name
role_createid, name
role_deleteid, name
role_updateid, name

Note: for slash commands, button clicks, and modal submits use the dedicated decorators below — they're easier than picking through interaction_create.

Slash commands

Two steps: declare the command in manifest.json, then handle it with @plugin.on_slash_command. The platform syncs the command registration with Discord automatically when your version is published.

Capability shortcut: declaring any slash_commands automatically adds interaction:respond to capabilities_required — you don't have to list it yourself. The explicit declaration in the example below is harmless but redundant.

Declaration in manifest.json

{
  "id": "my_plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "capabilities_required": ["interaction:respond"],
  "slash_commands": [
    {
      "name": "greet",
      "description": "Say hello to someone",
      "options": [
        {
          "name": "user",
          "description": "Who to greet",
          "type": 6,
          "required": true
        },
        {
          "name": "message",
          "description": "Custom greeting",
          "type": 3,
          "required": false
        }
      ]
    }
  ]
}

Discord option types you'll commonly use:

TypeMeaning
3String
4Integer
5Boolean
6User (resolves to a user ID)
7Channel
8Role
10Number (float)

Handler

@plugin.on_slash_command("greet")
def greet(ctx: Context, event: dict):
    options = {opt["name"]: opt["value"] for opt in event.get("options", [])}
    target_user_id = options.get("user")
    custom_msg = options.get("message", "Hey there!")

    ctx.interaction.respond(
        content=f"<@{target_user_id}> {custom_msg}",
    )

Inside a command handler always call one of ctx.interaction.respond, defer, or send_modal within 3 seconds. Discord rejects the interaction otherwise.

Buttons & menus

Component classes build Discord component JSON for you. They take string-style values, not enums.

Button

from mmo_maid_sdk import Button, ActionRow

Button(
    label="Click me",
    custom_id="btn_hi",
    style="primary",   # "primary" | "secondary" | "success" | "danger" | "link"
    emoji="🎉",
    disabled=False,
)

# Link buttons use url instead of custom_id
Button(label="Docs", url="https://mmomaid.cloud/dev/docs", style="link")

SelectMenu

from mmo_maid_sdk import SelectMenu, SelectOption, ActionRow

menu = SelectMenu(
    custom_id="pick_role",
    options=[
        SelectOption(label="Gamer", value="role_gamer", description="Get pinged for game nights"),
        SelectOption(label="Music",  value="role_music"),
        SelectOption(label="Art",    value="role_art", emoji="🎨"),
    ],
    placeholder="Pick your interests…",
    min_values=1,
    max_values=2,
)

ActionRow

Discord shows components in rows. Wrap one or more components in an ActionRow and pass it as components=[…]:

@plugin.on_slash_command("menu")
def menu_cmd(ctx: Context, event: dict):
    row = ActionRow(
        Button("Yes", custom_id="yes", style="success"),
        Button("No",  custom_id="no",  style="danger"),
    )
    ctx.interaction.respond(content="Pick one:", components=[row])

@plugin.on_component("yes")
def yes_clicked(ctx: Context, event: dict):
    ctx.interaction.respond(content="✅", ephemeral=True)

@plugin.on_component("no")
def no_clicked(ctx: Context, event: dict):
    ctx.interaction.respond(content="❌", ephemeral=True)

Modal dialogs

Modals collect text input from the user. Open one with ctx.interaction.send_modal and handle the submission with @plugin.on_modal_submit:

from mmo_maid_sdk import TextInput

@plugin.on_slash_command("feedback")
def feedback_cmd(ctx: Context, event: dict):
    ctx.interaction.send_modal(
        title="Send Feedback",
        custom_id="feedback_form",
        fields=[
            TextInput(
                label="Subject",
                custom_id="subject",
                style="short",          # "short" | "paragraph"
                placeholder="Quick summary",
                required=True,
                max_length=100,
            ),
            TextInput(
                label="Details",
                custom_id="details",
                style="paragraph",
                required=False,
                max_length=2000,
            ),
        ],
    )

@plugin.on_modal_submit("feedback_form")
def feedback_submitted(ctx: Context, event: dict):
    values = event["modal_values"]   # {"subject": "...", "details": "..."}
    ctx.kv.set(f"feedback:{event['interaction_id']}", values)
    ctx.interaction.respond(content="Thanks for the feedback!", ephemeral=True)

Background tasks

@plugin.schedule(seconds) runs a coroutine on a fixed interval, starting after the first interval has elapsed. Minimum interval is 30 seconds.

@plugin.schedule(300)   # every 5 minutes
def heartbeat(ctx: Context):
    count = ctx.ephemeral.counter("heartbeats")
    ctx.log(f"heartbeat #{count}")

The runner skips a tick if the previous one is still running, so a slow handler can't queue up.

Cron schedules

@plugin.cron(spec) runs a task on a 5-field cron schedule in UTC. Use this when you want a job to fire at a wall-clock time (e.g. Monday 09:00) instead of a fixed interval since boot.

Spec format: "minute hour day-of-month month day-of-week" — each field supports *, */N, N, N,M,…, N-M, N-M/S. Day-of-week is 0=Sunday through 6=Saturday.

@plugin.cron("0 9 * * 1")        # every Monday at 09:00 UTC
def weekly_report(ctx: Context):
    ctx.log("Sending weekly report")

@plugin.cron("*/15 * * * *")     # every 15 minutes
def heartbeat(ctx: Context):
    ctx.ephemeral.counter("heartbeats")

@plugin.cron("30 0 1 * *")       # 00:30 UTC on the 1st of each month
def monthly_rollup(ctx: Context):
    ...
  • Like @schedule, cron tasks run in a daemon thread inside the worker process. If the plugin restarts between firings, a missed tick is not replayed.
  • Pool mode: cron tasks do not run (pool workers don't carry per-install ctx). For pooled plugins, declare a server-side cron in your manifest.
  • Invalid specs raise ValueError at registration — you'll see the error during boot, not silently at the first miss.

SDK v0.5.1 · Last updated April 2026 · Back to Dev Portal