Build a plugin
The full SDK reference for plugin developers: sandboxed Python, shipped as a zip, distributed through the marketplace.
Plugins are sandboxed Python processes. Write a handler, declare your capabilities, ship a zip and we run it in a locked-down Docker container, streaming 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 yourbot_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")
Every exception also carries a stable machine-readable .code so you can branch on failures without string-matching: v0.6.1
except SdkError as e:
if e.code == "RATE_LIMITED":
time.sleep(getattr(e, "retry_after", 5))
elif e.code == "CAPABILITY_DENIED":
ctx.log(f"missing capability: {e}", level="error")
Reference
| Exception | Raised when |
|---|---|
| SdkError | Base class — catch this to handle anything from the SDK. SDK_ERROR |
| CapabilityError | You called an API your plugin didn't request via capabilities_required. CAPABILITY_DENIED |
| RateLimitError | Quota exceeded. Has .retry_after (seconds). RATE_LIMITED / QUOTA_EXCEEDED |
| DiscordApiError | Discord's REST returned non-2xx. Has .status_code. DISCORD_API_ERROR |
| SdkPermissionError | Bot is missing a Discord guild permission. Has .permission (e.g. "manage_channels"). BOT_MISSING_PERMISSION |
| ValidationError | You passed invalid args (empty channel_id, bad emoji, key with null bytes…). VALIDATION_ERROR |
| KvQuotaError | Hit a KV key-count quota (50k per server or 500k global). An oversized value or key currently surfaces as a generic RPC error instead. KV_QUOTA_EXCEEDED |
| RpcTimeoutError | The runner didn't respond inside the per-call timeout. RPC_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 yourbot_sdk.testing:
from yourbot_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")
MockContext enforces capabilities by default. Calling a gated method without the matching capability raises CapabilityError, exactly like production — so a passing test means a working manifest. With no capabilities= argument you get the full standard set except proxy:websocket; WebSocket tests need MockContext(capabilities=[..., "proxy:websocket"]). capabilities=[] means none. Pass MockContext(strict_capabilities=False) to opt out. v0.6.1
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.roles_added ctx.roles_removed
ctx.members_banned ctx.members_kicked ctx.modals_sent
ctx.kv_writes ctx.log_lines ctx.interaction.responses
ctx.metrics.recorded ctx.sql.executed
ctx.discord.messages_pinned ctx.discord.messages_unpinned
ctx.discord.reactions_added ctx.discord.members_timed_out ctx.ws.ensured
Everything else your handlers touch is recorded on ctx.discord (webhooks, channels, threads, nicknames, permissions) and ctx.ws (ensured, sent, closed, allowed_hosts).
Deterministic time and canned data: MockContext(clock=MockClock(start=1000.0)) lets you clock.advance(31) for cooldown tests (a bare MockClock() is wall-clock passthrough and cannot be advanced), and ctx.discord.set_messages([…]) feeds get_messages / iter_messages.
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; server admins choose which to grant at install time. The full table of capabilities, tiers and the calls each one unlocks lives in the capability catalog on the Reference tab.
The upload pipeline auto-adds any capability it detects from your code (with file and line attribution in the Dev Portal), plus two manifest implications: slash_commands implies interaction:respond, and a non-empty proxy_domains_requested implies proxy:http. So forgetting a capability does not make calls fail at upload; it silently appears on your consent screen, and if it is a reviewed tier it can send the version to staff review.
Tiers are what customers see at install: Safe, Standard and Dangerous badges on the consent screen. Dangerous-tier calls are also audit-logged at WARNING level.
Calling an API your plugin didn't request (or the admin didn't grant) 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 plus a 16 MB
/tmptmpfs mountednoexec; nothing persists between restarts. State lives inctx.kv/ctx.sqlwhere backups and audits can see it. - Memory: 64 MB hard limit per worker (24 MB per plugin in pool mode). One plugin's leak doesn't take down others.
- CPU: 0.25 vCPU (0.1 in pool mode). Background work won't starve other plugins.
- Processes: 64 max (PIDs). Caps fork-bomb damage.
- User: non-root (
nobody, uid 65534),--cap-drop ALL, no-new-privileges, seccomp-filtered syscalls. 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.
Crash recovery
- Quarantine: 5 consecutive crashes within 5 minutes stops event delivery. Restart the plugin from the dashboard to clear it.
Pool mode
Plugins share a container to cut overhead: the platform assigns whole plugins to pools (a deployment-level decision per plugin, not per install), and marketplace plugins run pooled today. Your code runs unchanged; what changes is the runtime shape:
@plugin.on_readyfires per server, not per boot. It runs once per (worker, server) right before that server's first event reaches you, with a tenant-scopedctx— the designed place for per-server init like KV defaults or SQL schema bootstrap.- Schedules run server-side, from your manifest. Declare each
@plugin.crontask in the manifest's"cron"array and the platform fires it per installed server (UTC, 5-minute floor, up to 5 entries; delivery is at-least-once and missed ticks are not replayed, so keep handlers idempotent). Interval-style@plugin.scheduletasks do not run pooled — use cron or the event-driven pattern below. - Module globals are unreliable. The
Contextis rebuilt per event, your plugin runs on more than one worker, and workers restart freely. Anything that must survive belongs inctx.kvorctx.ephemeral. - Tighter limits: 24 MB memory and 0.1 vCPU per plugin (vs 64 MB / 0.25 solo).
Periodic work without schedules
For work tighter than the 5-minute cron floor, or when you'd rather skip the manifest entry, ride on event traffic and throttle with a cooldown — on an active server this runs your job roughly once per interval:
@plugin.on_event("message_create")
def on_message(ctx: Context, event: dict):
# ... your normal handling ...
# Piggyback: roll the daily summary at most once per 24h.
if not ctx.ephemeral.cooldown_check("daily_summary")["active"]:
ctx.ephemeral.cooldown_set("daily_summary", ttl_seconds=86400)
post_daily_summary(ctx)
Quiet servers produce no events, so nothing fires — which is usually what you want (no one is reading the summary anyway). When your feature genuinely needs wall-clock delivery, declare it in the manifest "cron" array instead: those fire on schedule whether or not the server is chatting.
Publish & review
Upload your zip on the Dev Portal (or link a GitHub repo and pull versions from it). Run yourbot validate first — it applies the same checks locally. The reviewer checks:
- Manifest validates: required fields present, capabilities legal, slash commands well-formed.
- Code does not import any disallowed module (
subprocess,ctypes,socket,multiprocessing) or calleval/exec. 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 — every submission from a new developer gets human review, and a Dangerous-tier request (like
discord:ban_members) is exactly what reviewers weigh hardest. Established developers with public repos publish without the queue. - Slash command names are legal: no names reserved by built-in plugins (see the definitive list), no duplicates within your manifest.
yourbot validatechecks this locally.
Turnaround is typically 1–3 business days. If denied you'll see specific actionable feedback in the dev portal.
After you publish: updates and rollback
- Auto-updates: installs default to
auto_updateon, so servers pick up your new published version automatically. Include a changelog with every version; it shows on the marketplace page and in update notifications. - Pinning is rollback: server owners can pin any published version; pinning disables auto-update for that server. There is no separate revert button.
- Capability changes: adding any new capability or proxy domain, even a Safe-tier one, pauses auto-update on each install until the admin re-consents — a harmless-looking new capability fragments your install base across versions. Removing one is silent.
- Slash commands propagate per install: Discord sees the commands of each server's installed version, not your latest published one. A renamed option only reaches a server after it updates.
Selling your plugin
A price is optional: free plugins are welcome and many of the most-installed ones are free. When you do charge, the platform handles checkout, invoicing, refunds and payouts.
Before your first sale
- Accept the Developer Agreement (you'll be prompted in the Dev Portal).
- Complete Stripe Connect onboarding from Earnings — that's where your money lands. Checkout stays disabled for your paid plans until Stripe enables charges on your account, so buyers can never pay into a void.
Pricing and plans
Plans are set per plugin in the Dev Portal: monthly, yearly or one-time billing, with optional per-seat quantities. You can run limited-time sales, offer bundles of your plugins and buyers can gift purchases to a server.
What you keep
| Your trailing 30-day gross | You keep |
|---|---|
| Up to $1,000 | 70% |
| $1,000+ | 75% |
| $5,000+ | 80% |
| $10,000+ | 85% |
The tier is computed from your last 30 days of sales net of refunds, so a good month makes every plugin you sell cheaper to run. Your current tier and progress to the next one show on Earnings.
Payouts, refunds and disputes
- Payouts are automatic. Each sale's funds release about 30 days after payment, then pay out to your connected bank through Stripe. No invoices to send, no payout button to press.
- Refunds don't cost you the platform fee. When a sale is refunded the buyer is made whole, your share of that sale is reversed and the platform returns its fee share to you — a fully refunded sale nets you zero, not negative.
- Buyers can escalate refund requests. You get 7 days to respond before staff decide. Disputes (chargebacks) pause the buyer's access and claw back the sale while under review.
SDK v0.8.4 · Last updated July 2026 · Back to Dev Portal