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

Error handling documented

The SDK raises typed exceptions instead of returning error dicts. All inherit from SdkError so you can catch broadly or narrowly.

from mmo_maid_sdk import (
    SdkError, CapabilityError, RateLimitError, DiscordApiError,
    SdkPermissionError, ValidationError, KvQuotaError, RpcTimeoutError,
)

@plugin.on_event("message_create")
def on_message(ctx: Context, event: dict):
    try:
        ctx.discord.send_message(channel_id=event["channel_id"], content="Pong")
    except RateLimitError as exc:
        ctx.log(f"rate limited; retry in {exc.retry_after}s", level="warning")
    except SdkPermissionError as exc:
        ctx.log(f"missing Discord permission: {exc.permission}", level="error")
    except DiscordApiError as exc:
        if exc.status_code == 404:
            ctx.log("channel was deleted", level="info")
        else:
            ctx.log(f"discord {exc.status_code}: {exc}", level="error")
    except SdkError as exc:
        # Catch-all — logs and keeps the plugin running.
        ctx.log(f"unexpected: {exc}", level="error")

Reference

ExceptionRaised when
SdkErrorBase class — catch this to handle anything from the SDK.
CapabilityErrorYou called an API your plugin didn't request via capabilities_required.
RateLimitErrorQuota exceeded. Has .retry_after (seconds).
DiscordApiErrorDiscord's REST returned non-2xx. Has .status_code.
SdkPermissionErrorBot is missing a Discord guild permission. Has .permission (e.g. "manage_channels").
ValidationErrorYou passed invalid args (empty channel_id, bad emoji, key with null bytes…).
KvQuotaErrorHit the 10k-key or 64 KB-value KV limit.
RpcTimeoutErrorThe runner didn't respond inside the per-call timeout.
PermissionError aliasBackwards-compatible alias for SdkPermissionError.
TimeoutError aliasBackwards-compatible alias for RpcTimeoutError.

Testing documented

Plugins test like any other Python code — no Docker, no platform connection, no Discord mocks. Import from mmo_maid_sdk.testing:

from mmo_maid_sdk.testing import MockContext, make_event

def test_ping_replies_pong():
    ctx = MockContext()
    event = make_event("message_create", content="!ping", channel_id="42")
    on_message(ctx, event)            # the handler from your __main__.py

    assert len(ctx.messages_sent) == 1
    sent = ctx.messages_sent[0]
    assert sent["channel_id"] == "42"
    assert sent["content"] == "Pong!"

def test_kv_counter_increments():
    ctx = MockContext()
    ctx.kv.increment("hits")
    ctx.kv.increment("hits")
    assert ctx.kv.get("hits") == 2

def test_capability_gate():
    ctx = MockContext(capabilities=["discord:send_message"])
    assert ctx.has_capability("discord:send_message")
    assert not ctx.has_capability("storage:sql")

What MockContext records

Every Discord side-effect is captured for assertion. Read these as lists of dicts in the order they were called:

ctx.messages_sent     ctx.messages_edited    ctx.messages_deleted
ctx.messages_pinned   ctx.messages_unpinned  ctx.reactions_added
ctx.roles_added       ctx.roles_removed
ctx.members_banned    ctx.members_kicked     ctx.members_timed_out
ctx.modals_sent       ctx.interaction.responses
ctx.metrics.recorded  ctx.sql.executed       ctx.log_entries

Mock outbound HTTP

def test_calls_external_api():
    ctx = MockContext()
    ctx.http.mock_response(
        "api.example.com/status",
        status=200,
        body='{"online": true}',
    )
    my_status_handler(ctx, make_event("message_create", content="!status"))
    assert ctx.http.requests[0]["url"].startswith("https://api.example.com")

make_event ships sensible defaults for all 16 event types — pass overrides as kwargs.

Capabilities

Every API your plugin uses corresponds to a capability. Declare them in capabilities_required; servers approve them at install time. The upload pipeline also auto-adds two caps when it sees the corresponding manifest field: slash_commands implies interaction:respond, and a non-empty proxy_domains_requested implies proxy:http. Three tiers:

CapabilityTierWhat it unlocks
storage:kvSafePer-server KV store
discord:send_messageSafesend_message, execute_webhook
discord:edit_messageSafeedit_message, pin_message, unpin_message
discord:add_reactionSafeadd_reaction
discord:readSafeRead message history, members, channels, roles
interaction:respondSafeSlash command + component handling
proxy:httpSafeOutbound ctx.http.*
discord:delete_messageRiskydelete_message, bulk_delete_messages
discord:manage_channelsRiskyCreate/edit/delete channels & threads, set permissions
discord:manage_webhooksRiskyCreate/delete webhooks
storage:sql opt-inRiskySandboxed SQL schema
discord:moderate_membersDangeroustimeout_member, set_nickname
discord:kick_membersDangerouskick_member, kick_bulk
discord:ban_membersDangerousban_member, unban_member
discord:manage_rolesDangerousadd_role, remove_role, bulk variants

Calling an API your plugin didn't request raises CapabilityError at runtime — the runner blocks the call, you don't get partial damage. The user sees a "needs permission" prompt and can grant it without uninstalling.

Sandbox — what it guarantees

Marketplace plugins run in isolated Docker containers. The sandbox isn't a limitation placed on you — it's a set of guarantees made on your behalf.

What the sandbox guarantees you

  • Your plugin can't leak user tokens, customer DMs, or platform secrets. The environment is empty — no env vars, no DB credentials, no Discord bot tokens. If your code is ever compromised, the blast radius is exactly what the user consented to install. You're not one bad dependency away from a security incident.
  • Your plugin can't crash the platform for other users. If your plugin spirals, only your plugin dies; the rest of the server keeps running.
  • One plugin's bugs can't become another plugin's bugs. Each plugin gets its own container, its own KV namespace, its own SQL schema. You can't accidentally read another plugin's data, and another plugin can't corrupt yours.
  • Customers don't have to audit your code to trust your plugin. The capability picker at install time shows them exactly what your plugin can touch — no mystery imports, no surprise side effects. That's what makes the marketplace work.

How those guarantees are enforced

  • Network: --network none. Only the JSON-RPC pipe to the runner is reachable. All HTTP goes through ctx.http via the platform proxy — you declare the domains you need, customers see them at install.
  • Filesystem: read-only root, ephemeral /tmp, no persistence between restarts. State lives in ctx.kv / ctx.sql where backups and audits can see it.
  • Memory: 64 MB hard limit per worker. One plugin's leak doesn't take down others.
  • CPU: 0.25 vCPU. Background work won't starve other plugins.
  • Processes: 32 max (PIDs). Caps fork-bomb damage.
  • User: non-root (nobody, uid 65534). No privilege escalation paths.
  • Secrets: env is empty. There's nothing to leak even if you wanted to.
  • Rate limits per (server, plugin): 50 events/sec, 60 outbound Discord actions/min, 30 proxy HTTP requests/min. One server can't DoS your plugin by flooding it.

If your plugin trips a limit it gets RateLimitError with a retry_after hint — back off, don't busy-loop.

Publish & review

Upload your zip on the Dev Portal. The reviewer checks:

  • Manifest validates: required fields present, capabilities legal, slash commands well-formed.
  • Code does not import any disallowed module (the sandbox blocks them anyway, but pre-flight catches it before users see errors).
  • No unparameterised SQL, no f-string interpolation in ctx.sql.execute.
  • No surprises — tier shifts (e.g. a previously-Safe plugin newly requesting discord:ban_members) are flagged for human review.

Turnaround is 1–3 business days. If denied you'll see specific actionable feedback in the dev portal.

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