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

Key-value store

Per-server JSON store. Keys are namespaced to your plugin and the active server — you cannot read another plugin's keys, and your keys for server A are isolated from server B. Capability: storage:kv.

Get / set / delete

ctx.kv.set("welcome_channel", "1234567890")
ctx.kv.set("session", {"user": "alice"}, ttl_seconds=3600)
val = ctx.kv.get("welcome_channel")
ctx.kv.delete("session")
exists = ctx.kv.exists("welcome_channel")

Counters

ctx.kv.increment("messages_today")          # +1
ctx.kv.increment("xp:user:42", amount=10)
ctx.kv.decrement("lives_remaining")

Bulk & listing

keys   = ctx.kv.list(prefix="user:", limit=100)
values = ctx.kv.list_values(prefix="user:", limit=100)   # {key: value}
batch  = ctx.kv.get_many(["a", "b", "c"])
ctx.kv.set_many({"a": 1, "b": 2})
total  = ctx.kv.count(prefix="xp:")

Quotas. 10,000 keys per (server, plugin), 64 KB max value. Going over raises KvQuotaError.

Sandboxed SQL capability

For relational data heavier than KV. Each plugin gets its own Postgres schema with row-level scoping to ctx.server_id. Capability: storage:sql (asks the user for explicit consent at install time).

ctx.sql.execute(
    "CREATE TABLE IF NOT EXISTS scores ("
    "  user_id TEXT PRIMARY KEY,"
    "  points  INT NOT NULL DEFAULT 0)"
)

ctx.sql.execute(
    "INSERT INTO scores (user_id, points) VALUES ($1, $2) "
    "ON CONFLICT (user_id) DO UPDATE SET points = scores.points + $2",
    ["user_42", 5],
)

rows  = ctx.sql.query("SELECT * FROM scores ORDER BY points DESC LIMIT 10")
top   = ctx.sql.query_one("SELECT * FROM scores WHERE user_id = $1", ["user_42"])
total = ctx.sql.scalar("SELECT COUNT(*) FROM scores")

Use $1, $2, … placeholders — never f-strings (would be auto-rejected). Queries are capped at 1000 rows by default; override with ctx.sql.query(..., limit=N).

Ephemeral state v0.5.0

Backed by Redis — fast counters, dedup, cooldowns, and short-lived flags. Don't use this for anything you need durably (eviction policy is LRU under memory pressure). No capability required.

Counters

n = ctx.ephemeral.counter("ratelimit:user:42", window_seconds=60)
if n > 5:
    ctx.interaction.respond(content="Slow down!", ephemeral=True)
    return

Cooldowns

state = ctx.ephemeral.cooldown_check("daily:user:42")
if state["active"]:
    ctx.interaction.respond(
        content=f"Try again in {int(state['remaining_seconds'])}s",
        ephemeral=True,
    )
    return
ctx.ephemeral.cooldown_set("daily:user:42", ttl_seconds=86400)

Dedup & flags

# dedup: returns True the FIRST time a key is seen, False afterwards
if not ctx.ephemeral.dedup(f"welcome:{event['user_id']}", ttl_seconds=3600):
    return  # already welcomed this user in the last hour

ctx.ephemeral.flag_set("event:black_friday", ttl_seconds=86400)
if ctx.ephemeral.flag_check("event:black_friday"):
    ...

Outbound HTTP

Plugins have --network none in the sandbox — every outbound request goes through the platform proxy. Capability: proxy:http.

resp = ctx.http.get("https://api.example.com/v1/status")
print(resp["status"], resp["body_bytes"])

resp = ctx.http.post(
    "https://api.example.com/webhook",
    body='{"hello": "world"}',
    headers={"Content-Type": "application/json", "Authorization": "Bearer …"},
)

# Custom method
resp = ctx.http.request("PATCH", "https://api.example.com/x/1", body='{"a":1}')

Response shape: {"status": int, "body_bytes": str, "headers": dict, "truncated": bool}. Body is capped at 1 MB; truncated=True if cut. The proxy enforces 30 requests/minute per (server, plugin).

Metrics v0.5.0

Time-series counters with optional tags. Show up on your plugin's dashboard via ctx.metrics.query.

ctx.metrics.record("commands_used")                            # +1
ctx.metrics.record("xp_awarded", value=25)
ctx.metrics.record("commands_used", tags={"command": "ban"})

# Read back
trend = ctx.metrics.query("commands_used", period="7d", group_by="command")
total = ctx.metrics.total("xp_awarded", period="30d")

period accepts 1h, 24h, 7d, 30d, 90d. Aggregates: sum (default), avg, max, min.

Logging

Plugin logs land in the dev portal logs viewer with full timestamp, level, and tags.

ctx.log("Plugin booted")
ctx.log("User joined", level="info", tags=["onboarding"])
ctx.log("Discord error: " + str(exc), level="error", tags=["discord"])
ctx.log("Custom payload", request_id="abc-123", user_id=event["user_id"])

Levels: debug, info (default), warning, error. Any extra **kwargs are stored as JSON detail.

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