← 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 YourBot account: sign in to access the Dev Portal.
SDK v0.8.4

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

ExceptionRaised when
SdkErrorBase class — catch this to handle anything from the SDK. SDK_ERROR
CapabilityErrorYou called an API your plugin didn't request via capabilities_required. CAPABILITY_DENIED
RateLimitErrorQuota exceeded. Has .retry_after (seconds). RATE_LIMITED / QUOTA_EXCEEDED
DiscordApiErrorDiscord's REST returned non-2xx. Has .status_code. DISCORD_API_ERROR
SdkPermissionErrorBot is missing a Discord guild permission. Has .permission (e.g. "manage_channels"). BOT_MISSING_PERMISSION
ValidationErrorYou passed invalid args (empty channel_id, bad emoji, key with null bytes…). VALIDATION_ERROR
KvQuotaErrorHit 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
RpcTimeoutErrorThe runner didn't respond inside the per-call timeout. RPC_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 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 through ctx.http via the platform proxy — you declare the domains you need, customers see them at install.
  • Filesystem: read-only root plus a 16 MB /tmp tmpfs mounted noexec; nothing persists between restarts. State lives in ctx.kv / ctx.sql where 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_ready fires per server, not per boot. It runs once per (worker, server) right before that server's first event reaches you, with a tenant-scoped ctx — the designed place for per-server init like KV defaults or SQL schema bootstrap.
  • Schedules run server-side, from your manifest. Declare each @plugin.cron task 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.schedule tasks do not run pooled — use cron or the event-driven pattern below.
  • Module globals are unreliable. The Context is rebuilt per event, your plugin runs on more than one worker, and workers restart freely. Anything that must survive belongs in ctx.kv or ctx.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 call eval / 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 validate checks 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_update on, 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 grossYou keep
Up to $1,00070%
$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