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.
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
| Exception | Raised when |
|---|---|
| SdkError | Base class — catch this to handle anything from the SDK. |
| CapabilityError | You called an API your plugin didn't request via capabilities_required. |
| RateLimitError | Quota exceeded. Has .retry_after (seconds). |
| DiscordApiError | Discord's REST returned non-2xx. Has .status_code. |
| SdkPermissionError | Bot is missing a Discord guild permission. Has .permission (e.g. "manage_channels"). |
| ValidationError | You passed invalid args (empty channel_id, bad emoji, key with null bytes…). |
| KvQuotaError | Hit the 10k-key or 64 KB-value KV limit. |
| RpcTimeoutError | The runner didn't respond inside the per-call timeout. |
| PermissionError alias | Backwards-compatible alias for SdkPermissionError. |
| TimeoutError alias | Backwards-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:
| Capability | Tier | What it unlocks |
|---|---|---|
| storage:kv | Safe | Per-server KV store |
| discord:send_message | Safe | send_message, execute_webhook |
| discord:edit_message | Safe | edit_message, pin_message, unpin_message |
| discord:add_reaction | Safe | add_reaction |
| discord:read | Safe | Read message history, members, channels, roles |
| interaction:respond | Safe | Slash command + component handling |
| proxy:http | Safe | Outbound ctx.http.* |
| discord:delete_message | Risky | delete_message, bulk_delete_messages |
| discord:manage_channels | Risky | Create/edit/delete channels & threads, set permissions |
| discord:manage_webhooks | Risky | Create/delete webhooks |
| storage:sql opt-in | Risky | Sandboxed SQL schema |
| discord:moderate_members | Dangerous | timeout_member, set_nickname |
| discord:kick_members | Dangerous | kick_member, kick_bulk |
| discord:ban_members | Dangerous | ban_member, unban_member |
| discord:manage_roles | Dangerous | add_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 throughctx.httpvia 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 inctx.kv/ctx.sqlwhere 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