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

Cookbook

Copy-pasteable starter snippets for the most common plugin patterns. Each recipe is a minimal working example — drop it into your __main__.py and adapt. All capabilities used are auto-detected on upload so you don't need to manually edit manifest.json.

1) Welcome new members

Greet every new member in a channel an admin configures via /welcome-channel.

from mmo_maid_sdk import Plugin, Context
from mmo_maid_sdk.events import MemberJoin

plugin = Plugin()

@plugin.on_event("member_join")
def greet(ctx: Context, event: MemberJoin):
    channel_id = ctx.kv.get("welcome_channel_id")
    if not channel_id:
        return
    name = event.get("display_name") or event.get("username") or "friend"
    ctx.discord.send_message(channel_id=str(channel_id),
        content=f"Welcome, **{name}**! :wave:")

@plugin.on_slash_command("welcome-channel")
def set_channel(ctx: Context, event: dict):
    ctx.kv.set("welcome_channel_id", event["channel_id"])
    ctx.interaction.respond(content="This is now the welcome channel.", ephemeral=True)

2) Reaction roles (click ✓ to get a role)

Admin posts a message, configures the role+emoji pair, and any user who reacts gets the role.

from mmo_maid_sdk import Plugin, Context
from mmo_maid_sdk.events import ReactionAdd

plugin = Plugin()

@plugin.on_event("reaction_add")
def grant_role(ctx: Context, event: ReactionAdd):
    if event.get("user_bot"):
        return  # ignore bot reactions, including our own
    cfg = ctx.kv.get(f"react_role:{event['message_id']}")
    if not cfg:
        return
    if event["emoji"] == cfg["emoji"]:
        ctx.discord.add_role(user_id=event["user_id"], role_id=cfg["role_id"])

@plugin.on_slash_command("react-role-bind")
def bind(ctx: Context, event: dict):
    # event["modal_values"] would carry message_id / emoji / role_id from a modal
    mv = event.get("modal_values") or {}
    ctx.kv.set(f"react_role:{mv['message_id']}", {"emoji": mv["emoji"], "role_id": mv["role_id"]})
    ctx.interaction.respond(content="Reaction role bound.", ephemeral=True)

3) Daily scheduled announcement

Post a server-stat recap at 09:00 UTC every day.

from mmo_maid_sdk import Plugin, Context

plugin = Plugin()

@plugin.cron("0 9 * * *")  # every day at 09:00 UTC
def morning_recap(ctx: Context):
    channel_id = ctx.kv.get("recap_channel_id")
    if not channel_id:
        return
    count = ctx.kv.increment("messages_today_total", reset_ttl_sec=86400)
    ctx.discord.send_message(channel_id=str(channel_id),
        content=f"Good morning! {count} messages were posted yesterday.")

4) /leaderboard top 10 users by messages

Track per-user message count; `/leaderboard` posts the top 10.

from mmo_maid_sdk import Plugin, Context
from mmo_maid_sdk.events import MessageCreate

plugin = Plugin()

@plugin.on_event("message_create")
def count(ctx: Context, event: MessageCreate):
    if event.get("author_bot"):
        return
    ctx.kv.increment(f"msgs:{event['author_id']}")

@plugin.on_slash_command("leaderboard")
def show(ctx: Context, event: dict):
    rows = ctx.kv.list(prefix="msgs:") or []
    top = sorted(rows, key=lambda r: int(r["value"] or 0), reverse=True)[:10]
    lines = [f"{i+1}. <@{r['key'].split(':')[1]}> — {r['value']}" for i, r in enumerate(top)]
    ctx.interaction.respond(content="**Top 10 talkers**\n" + "\n".join(lines))

5) AFK status

/afk <reason> sets the user's status; the bot replies when they're mentioned.

from mmo_maid_sdk import Plugin, Context
from mmo_maid_sdk.events import MessageCreate

plugin = Plugin()

@plugin.on_slash_command("afk")
def set_afk(ctx: Context, event: dict):
    reason = (event.get("modal_values") or {}).get("reason") or "afk"
    ctx.kv.set(f"afk:{event['user_id']}", reason, ttl_sec=86400)
    ctx.interaction.respond(content=f"Got it — set you AFK ({reason}).", ephemeral=True)

@plugin.on_event("message_create")
def notify_mentions(ctx: Context, event: MessageCreate):
    if event.get("author_bot") or event.get("mention_count", 0) == 0:
        return
    # In production you'd parse event['content'] for <@user_id> mentions.
    # For brevity this recipe just shows the pattern.
    # Look up AFK status for any mentioned user and reply.
    pass

6) Custom commands stored in KV

Server admins add their own !shortcut commands without redeploying.

@plugin.on_event("message_create")
def custom(ctx: Context, event: MessageCreate):
    content = event.get("content", "").strip()
    if not content.startswith("!"):
        return
    name = content[1:].split(" ", 1)[0]
    response = ctx.kv.get(f"cmd:{name}")
    if response:
        ctx.discord.send_message(channel_id=event["channel_id"], content=str(response))

@plugin.on_slash_command("addcmd")
def add(ctx: Context, event: dict):
    mv = event.get("modal_values") or {}
    ctx.kv.set(f"cmd:{mv['name']}", mv["response"])
    ctx.interaction.respond(content=f"Saved !{mv['name']}.", ephemeral=True)

7) Reaction-based polls

/poll creates a message with ✓/✗ reactions; tallies via reaction count later.

@plugin.on_slash_command("poll")
def poll(ctx: Context, event: dict):
    mv = event.get("modal_values") or {}
    question = mv.get("question") or "Yes or no?"
    msg = ctx.discord.send_message(channel_id=event["channel_id"], content=f"**Poll:** {question}")
    ctx.discord.add_reaction(channel_id=event["channel_id"], message_id=msg["id"], emoji="✅")
    ctx.discord.add_reaction(channel_id=event["channel_id"], message_id=msg["id"], emoji="❌")
    ctx.interaction.respond(content="Poll posted!", ephemeral=True)

8) Message logger

Log every non-bot message to a configurable mod channel (useful for audit trails).

@plugin.on_event("message_create")
def log(ctx: Context, event: MessageCreate):
    if event.get("author_bot"):
        return
    log_channel = ctx.kv.get("mod_log_channel_id")
    if not log_channel:
        return
    ctx.discord.send_message(channel_id=str(log_channel),
        content=f"[#{event.get('channel_name')}] <@{event['author_id']}>: {event['content'][:200]}")

9) /stats — server stat card

Show member count, message count today, etc.

@plugin.on_slash_command("stats")
def stats(ctx: Context, event: dict):
    msgs_today = ctx.kv.get("messages_today_total") or 0
    new_today = ctx.kv.get("new_members_today") or 0
    ctx.interaction.respond(content=(
        f"**Server stats**\n"
        f"Messages today: {msgs_today}\n"
        f"New members today: {new_today}"
    ))

10) Role menu (dropdown)

Admin runs /role-menu, gets a dropdown listing self-assignable roles.

from mmo_maid_sdk import Plugin, Context, ActionRow, SelectMenu, SelectOption

plugin = Plugin()

@plugin.on_slash_command("role-menu")
def menu(ctx: Context, event: dict):
    roles = ctx.kv.get("self_roles") or []  # list of {"id": "...", "label": "..."}
    options = [SelectOption(label=r["label"], value=r["id"]) for r in roles]
    ctx.interaction.respond(content="Pick your role(s):",
        components=[ActionRow(SelectMenu("pick_role", options, min_values=1, max_values=len(options)))])

@plugin.on_component("pick_role")
def picked(ctx: Context, event: dict):
    selected_ids = event.get("values") or []
    for rid in selected_ids:
        ctx.discord.add_role(user_id=event["user_id"], role_id=rid)
    ctx.interaction.respond(content="Roles updated.", ephemeral=True)

11) Ticket triage: /ticket opens a thread

User runs /ticket <subject>; the bot creates a private thread tagging the support role.

@plugin.on_slash_command("ticket")
def open_ticket(ctx: Context, event: dict):
    mv = event.get("modal_values") or {}
    subject = mv.get("subject") or "Support request"
    msg = ctx.discord.send_message(channel_id=event["channel_id"],
        content=f"**Ticket from <@{event['user_id']}>**: {subject}")
    # Real plugins would call ctx.discord.create_thread(...) here.
    ctx.interaction.respond(content="Ticket opened — staff will reply shortly.", ephemeral=True)

12) Auto-respond to keywords

Trigger a reply when a message contains a configured keyword. Useful for FAQs.

@plugin.on_event("message_create")
def autoresponder(ctx: Context, event: MessageCreate):
    if event.get("author_bot"):
        return
    content = event.get("content", "").lower()
    triggers = ctx.kv.get("keyword_triggers") or {}  # {keyword: reply}
    for kw, reply in (triggers.items() if isinstance(triggers, dict) else []):
        if kw.lower() in content:
            ctx.discord.send_message(channel_id=event["channel_id"], content=str(reply))
            break  # one auto-reply per message

13) Birthday reminders

Users register their birthday via /birthday MM-DD; cron at midnight UTC announces the day's birthdays.

@plugin.on_slash_command("birthday")
def set_bday(ctx: Context, event: dict):
    mv = event.get("modal_values") or {}
    ctx.kv.set(f"bday:{event['user_id']}", mv.get("date"))
    ctx.interaction.respond(content=f"Saved {mv.get('date')}.", ephemeral=True)

@plugin.cron("0 0 * * *")  # midnight UTC
def announce(ctx: Context):
    from datetime import datetime
    today = datetime.utcnow().strftime("%m-%d")
    bday_users = [r for r in (ctx.kv.list(prefix="bday:") or []) if r.get("value") == today]
    channel_id = ctx.kv.get("birthday_channel_id")
    if not channel_id or not bday_users:
        return
    mentions = " ".join(f"<@{r['key'].split(':')[1]}>" for r in bday_users)
    ctx.discord.send_message(channel_id=str(channel_id),
        content=f"🎂 Happy birthday {mentions}!")

14) Member of the week

Sunday 09:00 UTC: announce the top message-sender of the past week.

@plugin.cron("0 9 * * 0")  # Sunday 09:00 UTC
def weekly_mvp(ctx: Context):
    rows = ctx.kv.list(prefix="msgs_week:") or []
    if not rows:
        return
    rows.sort(key=lambda r: int(r["value"] or 0), reverse=True)
    top = rows[0]
    uid = top["key"].split(":", 1)[1]
    channel_id = ctx.kv.get("announce_channel_id")
    if channel_id:
        ctx.discord.send_message(channel_id=str(channel_id),
            content=f"🏆 Member of the week: <@{uid}> with {top['value']} messages.")
    # Reset weekly counters
    for r in rows:
        ctx.kv.delete(r["key"])

15) Voice channel time tracker

Track how long each user spends in voice channels. Useful for activity-based rewards.

from mmo_maid_sdk.events import VoiceStateUpdate
import time

@plugin.on_event("voice_state_update")
def track(ctx: Context, event: VoiceStateUpdate):
    uid = event["user_id"]
    if event.get("after_channel_id") and not event.get("before_channel_id"):
        # joined voice
        ctx.kv.set(f"voice_in:{uid}", str(int(time.time())))
    elif event.get("before_channel_id") and not event.get("after_channel_id"):
        # left voice
        join_t = ctx.kv.get(f"voice_in:{uid}")
        if join_t:
            duration = int(time.time()) - int(join_t)
            ctx.kv.increment(f"voice_secs:{uid}", amount=duration)
            ctx.kv.delete(f"voice_in:{uid}")

Tip: Each snippet calls the SDK methods that auto-detect their required capabilities — you can drop any of these into a fresh mmo new my_plugin and the platform will figure out the manifest entries on upload. Use mmo validate before pushing to catch any gaps.

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