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