Build a plugin
The full SDK reference for plugin developers — sandboxed Python, shipped as a zip, distributed through the marketplace.
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:
| Event | Payload highlights |
|---|---|
| message_create | message_id, channel_id, author_id, author_bot, content, timestamp |
| message_edit | message_id, channel_id, content |
| message_delete | message_id, channel_id |
| member_join | user_id, username, guild_id |
| member_leave | user_id, username, guild_id |
| member_update | user_id, roles |
| reaction_add | message_id, channel_id, user_id, emoji |
| reaction_remove | message_id, channel_id, user_id, emoji |
| voice_state_update | user_id, channel_id, self_mute, self_deaf |
| interaction_create | interaction_id, interaction_type, command_name, custom_id, modal_values |
| channel_create | id, name, type |
| channel_delete | id, name |
| channel_update | id, name |
| role_create | id, name |
| role_delete | id, name |
| role_update | id, 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:
| Type | Meaning |
|---|---|
| 3 | String |
| 4 | Integer |
| 5 | Boolean |
| 6 | User (resolves to a user ID) |
| 7 | Channel |
| 8 | Role |
| 10 | Number (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
ValueErrorat 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