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

Plugin dashboards

Every plugin can expose a settings/data page on the user-facing dashboard. Two modes — pick one:

  • Manifest mode — declare widgets in JSON, write Python data handlers. Themed for free, no HTML to write.
  • Iframe mode — full HTML/CSS/JS control. Use when you need a custom UI (drag-and-drop, charts the manifest doesn't cover, custom forms).

Manifest mode

Drop a dashboard_manifest.json next to your manifest.json:

{
  "pages": [
    {
      "id": "overview",
      "title": "Overview",
      "widgets": [
        {
          "id": "active_users",
          "type": "stat_card",
          "title": "Active users",
          "rpc_method": "dashboard.get_active_users"
        },
        {
          "id": "messages_chart",
          "type": "chart",
          "chart_type": "line",
          "title": "Messages per day",
          "rpc_method": "dashboard.get_messages_chart"
        },
        {
          "id": "settings_form",
          "type": "form",
          "title": "Settings",
          "rpc_method": "dashboard.get_settings",
          "save_method": "dashboard.save_settings",
          "fields": [
            {"name": "channel_id", "label": "Welcome channel", "type": "channel"},
            {"name": "message",    "label": "Welcome text",    "type": "text"}
          ]
        }
      ]
    }
  ]
}

Supported widget types

TypeHandler returns
stat_card{"value": 1234, "change": "+12%"}
chart{"labels": [...], "series": [{"name": "...", "data": [...]}]}
table{"rows": [{...}, ...], "total": 100}
list{"items": [{"label": "...", "value": "..."}, ...]}
progress_bar{"value": 75, "max": 100, "label": "…"}
text{"text": "Plain text content"}
markdown{"markdown": "# Heading\n- bullet"}
alert{"level": "info", "message": "…"} — level: info / warn / error
formget → {"values": {...}}; save → {"ok": true}

Python handlers

@plugin.on_dashboard("get_active_users")
def active_users(ctx: Context, params: dict):
    n = ctx.kv.count(prefix="active_user:")
    return {"value": n, "change": "+12%"}

@plugin.on_dashboard("get_messages_chart")
def messages_chart(ctx: Context, params: dict):
    data = ctx.metrics.query("messages", period="7d")
    return {
        "labels": data["labels"],
        "series": [{"name": "Messages", "data": data["series"][0]["data"]}],
    }

@plugin.on_dashboard("get_settings")
def get_settings(ctx: Context, params: dict):
    return {"values": {
        "channel_id": ctx.kv.get("welcome_channel") or "",
        "message":    ctx.kv.get("welcome_message") or "Welcome!",
    }}

@plugin.on_dashboard("save_settings")
def save_settings(ctx: Context, params: dict):
    ctx.kv.set("welcome_channel", params["values"]["channel_id"])
    ctx.kv.set("welcome_message", params["values"]["message"])
    return {"ok": True}

Dashboard handlers must return within 10 seconds. params always contains discord_srv_id plus any widget-specific arguments (form values for save methods, table pagination, etc.).

Iframe mode

Drop a dashboard/ directory with at least index.html. The platform serves it inside a sandboxed iframe and gives you a JS bridge (MaidSDK) for backend RPC, theming, and lifecycle.

my_plugin/
├── manifest.json
├── __main__.py
└── dashboard/
    ├── index.html       # entry point
    ├── style.css
    └── app.js

Minimal HTML

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="/static/dashboard.css">
</head>
<body>
  <h1>My plugin</h1>
  <div id="stat">Loading…</div>

  <script src="/static/mmo-maid-sdk.js"></script>
  <script>
    MaidSDK.ready(async function () {
      const ctx = MaidSDK.getContext();
      // ctx = { discord_srv_id, plugin_id, user_id, theme: "dark" | "light" }

      const total = await MaidSDK.metrics.total("messages_sent", "30d");
      document.getElementById("stat").textContent = total + " messages";

      MaidSDK.resize();   // ask the iframe host to resize to fit content
    });
  </script>
</body>
</html>

MaidSDK JS API

Every method returns a Promise. The bridge is sandboxed — you can only call allow-listed RPC namespaces (kv, metrics, sql, dashboard).

CallPurpose
MaidSDK.ready(fn)Run fn after the bridge handshake completes.
MaidSDK.getContext()Sync — returns server / plugin / user / theme.
MaidSDK.getTheme()Sync — "dark" or "light".
MaidSDK.on(event, fn)Listen for "theme", "resize", etc.
MaidSDK.off(event, fn)Remove listener.
MaidSDK.resize(height?)Tell the host iframe to resize. Auto-detects content if no height passed.
MaidSDK.navigateTo(pageId)Switch to another page in your dashboard manifest.
MaidSDK.rpc(method, params)Call a Python @plugin.on_dashboard handler.
MaidSDK.kv.get / set / delete / list / getMany / setManyDirect KV access from the iframe.
MaidSDK.metrics.query / totalPull metrics for charts.
MaidSDK.sql.query / executeSQL access (requires storage:sql capability).

Theme variables

The iframe inherits these CSS custom properties from the dashboard so your widget blends in without you reverse-engineering colors:

:root {
  --gold;        --gold-light;   --gold-dim;     --gold-bg;
  --text;        --muted;        --border;
  --surface;     --depth;        --green;        --red;
}

Drop-in style pack new

For plugin-rendered HTML (both manifest widgets and iframe dashboards) the platform ships a small CSS + JS pack so your UI matches the host without you copying styles.

For non-iframe (manifest) dashboards — reference directly:

<link rel="stylesheet" href="/static/sdk/ui.css?v=1">
<script src="/static/sdk/ui.js?v=1" defer></script>

For iframe dashboards — the iframe CSP blocks cross-origin assets, so bundle the file into your zip under dashboard/sdk/ and reference via the sandboxed origin:

<link rel="stylesheet" href="dashboard-assets/sdk/ui.css">
<script src="dashboard-assets/sdk/ui.js" defer></script>

What's in the pack:

  • .maid-btn / .maid-btn-primary / .maid-btn-danger — buttons matching the platform palette
  • .maid-input / .maid-textarea / .maid-select — form fields with theme-aware focus rings
  • .maid-card — styled container with header/help slots
  • .maid-badge / .maid-pill — status indicators with semantic colors (ok / warn / error / info / muted)
  • .maid-table — theme-aware data table
  • .maid-stack / .maid-grid / .maid-row — layout helpers
  • .maid-empty — empty-state container

JS helpers (namespaced under window.MaidUI):

MaidUI.toast("Saved!", "ok");
MaidUI.confirm("Delete this record?").then((yes) => { if (yes) /* ... */ });
MaidUI.fetchJSON("/p/{plugin_id}/dashboard/rpc/save_settings", {
  method: "POST",
  body: JSON.stringify(MaidUI.serializeForm(myFormElement)),
});
MaidUI.copy("text to clipboard");

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