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.
Cookbook
Twenty copy-pasteable starter snippets for the most common plugin patterns. Recipes 1 and 2 are complete files; later recipes show only the handlers, so paste them into the scaffold from yourbot new and keep plugin.run() at the bottom. Add the event-type imports each snippet names.
All recipes:
1 Welcome · 2 Reaction roles · 3 Daily announcement · 4 Leaderboard (KV) · 5 AFK · 6 Custom commands · 7 Polls · 8 Message logger · 9 Server stats · 10 Role menu · 11 Tickets · 12 Autoresponder · 13 Birthdays · 14 Member of the week · 15 Voice tracker · 16 External API · 17 XP levels (SQL) · 18 Keyword moderation · 19 Starboard · 20 Weekly digest (cron)
- Capabilities are auto-detected from the SDK calls in your code at upload. Run
yourbot validatebefore pushing to confirm nothing is missing. - Slash commands: command names are auto-registered from your decorators, but descriptions and options are not. Any command that takes arguments needs a
slash_commandsentry — the block in Manifest declarations covers every recipe here. - Rich payloads are privacy-gated: the default install mode strips events down to IDs and timestamps. Recipes that read
content(5, 6, 8, 12, 18) or reaction emoji (2, 19) need theevents:message_contentcapability. - Reply visibility: the platform acknowledges slash commands publicly before your handler runs, so
ephemeral=Trueon a slash reply is not honored (it works on component interactions, or with"defer_on_dispatch": false). - Mentions don't ping:
<@id>in a channel message renders as a mention but sends no notification (anti-mass-ping guard). Interaction replies can ping. - Scheduled work: production schedules come from the manifest
"cron"array (Build: Cron schedules, 5-minute floor) — recipe 20 shows that path end to end. Recipes 3, 13 and 14 use the event-driven daily-gate pattern instead, which needs no manifest entry and also works below the floor.
Manifest declarations for these recipes
One block covers every slash command below — copy the entries you use into your manifest.json:
"slash_commands": [
{"name": "welcome-channel", "description": "Make this channel the welcome channel"},
{"name": "react-role-bind", "description": "Bind an emoji on a message to a role", "options": [
{"name": "message_id", "description": "Message to watch", "type": 3, "required": true},
{"name": "emoji", "description": "Emoji to watch for", "type": 3, "required": true},
{"name": "role", "description": "Role to grant", "type": 8, "required": true}]},
{"name": "leaderboard", "description": "Top 10 talkers"},
{"name": "afk", "description": "Set your AFK status", "options": [
{"name": "reason", "description": "Why you are away", "type": 3, "required": false}]},
{"name": "addcmd", "description": "Add a custom text command", "options": [
{"name": "name", "description": "Shortcut name (no !)", "type": 3, "required": true},
{"name": "response", "description": "Reply text", "type": 3, "required": true}]},
{"name": "poll", "description": "Post a yes/no poll", "options": [
{"name": "question", "description": "Poll question", "type": 3, "required": true}]},
{"name": "stats", "description": "Server stat card"},
{"name": "role-menu", "description": "Show the self-assign role menu"},
{"name": "ticket", "description": "Open a support ticket", "options": [
{"name": "subject", "description": "What do you need help with?", "type": 3, "required": true}]},
{"name": "birthday", "description": "Save your birthday", "options": [
{"name": "date", "description": "MM-DD", "type": 3, "required": true}]},
{"name": "price", "description": "Look up a coin price", "options": [
{"name": "coin", "description": "CoinGecko id, e.g. bitcoin", "type": 3, "required": true}]},
{"name": "rank", "description": "XP leaderboard top 10"},
{"name": "banword", "description": "Add a word to the banned-word list", "options": [
{"name": "word", "description": "Word to ban", "type": 3, "required": true}]},
{"name": "starboard-here", "description": "Make this channel the starboard"},
{"name": "digest-here", "description": "Post the weekly digest in this channel"}
]
1) Welcome new members
Greet every new member in a channel an admin configures via /welcome-channel.
from yourbot_sdk import Plugin, Context
from yourbot_sdk.events import MemberJoin
plugin = Plugin()
@plugin.on_event("member_join")
def greet(ctx: Context, event: MemberJoin):
channel_id = ctx.kv.get("welcome_channel_id")
if not channel_id:
return
name = event.get("display_name") or event.get("username") or "friend"
ctx.discord.send_message(channel_id=str(channel_id),
content=f"Welcome, **{name}**! :wave:")
@plugin.on_slash_command("welcome-channel")
def set_channel(ctx: Context, event: dict):
ctx.kv.set("welcome_channel_id", event["channel_id"])
ctx.interaction.respond(content="This is now the welcome channel.")
plugin.run()
2) Reaction roles (click ✓ to get a role)
Admin posts a message, configures the role+emoji pair, and any user who reacts gets the role. Reaction events carry custom emoji as their bare name, so normalize the stored form before comparing. Needs events:message_content: without it the default privacy mode strips emoji and user_bot from reaction events.
from yourbot_sdk import Plugin, Context
from yourbot_sdk.events import ReactionAdd
plugin = Plugin()
@plugin.on_event("reaction_add")
def grant_role(ctx: Context, event: ReactionAdd):
if event.get("user_bot"):
return # ignore bot reactions, including our own
cfg = ctx.kv.get(f"react_role:{event['message_id']}")
if not cfg:
return
want = cfg["emoji"]
if want.startswith("<"): # "<:pepega:1234>" -> "pepega"
want = want.split(":")[1]
if event["emoji"] == want:
ctx.discord.add_role(user_id=event["user_id"], role_id=cfg["role_id"])
@plugin.on_slash_command("react-role-bind")
def bind(ctx: Context, event: dict):
opts = {o["name"]: o["value"] for o in event.get("options", [])}
ctx.kv.set(f"react_role:{opts['message_id']}",
{"emoji": opts["emoji"], "role_id": opts["role"]})
ctx.interaction.respond(content="Reaction role bound.")
plugin.run()
3) Daily announcement (the daily-gate pattern)
Two ways to run daily work. The wall-clock way: declare the task in your manifest's "cron" array and handle it with @plugin.cron (Build: Cron schedules). The zero-manifest way shown here: piggyback on message traffic and gate with a once-per-day flag — the recap posts with the first message after 09:00 UTC, so quiet servers skip it for free. Recipes 13 and 14 reuse this pattern.
from datetime import datetime, timezone
@plugin.on_event("message_create")
def count(ctx: Context, event: dict):
if event.get("author_bot"):
return
ctx.metrics.record("messages")
_maybe_recap(ctx, event["channel_id"])
def _maybe_recap(ctx: Context, fallback_channel: str):
now = datetime.now(timezone.utc)
if now.hour < 9:
return
if not ctx.ephemeral.dedup(f"recap:{now:%Y-%m-%d}", ttl_seconds=86400):
return # already posted today
channel_id = ctx.kv.get("recap_channel_id") or fallback_channel
total = int(ctx.metrics.total("messages", period="24h"))
ctx.discord.send_message(channel_id=str(channel_id),
content=f"Good morning! {total} messages in the last 24 hours.")
4) /leaderboard top 10 users by messages
Track per-user message count; `/leaderboard` posts the top 10.
from yourbot_sdk.events import MessageCreate
@plugin.on_event("message_create")
def count(ctx: Context, event: MessageCreate):
if event.get("author_bot"):
return
ctx.kv.increment(f"msgs:{event['author_id']}")
@plugin.on_slash_command("leaderboard")
def show(ctx: Context, event: dict):
counts = ctx.kv.list_values(prefix="msgs:", limit=100) or {} # {key: value}
top = sorted(counts.items(), key=lambda kv: int(kv[1] or 0), reverse=True)[:10]
lines = [f"{i+1}. <@{key.split(':')[1]}>: {n}" for i, (key, n) in enumerate(top)]
ctx.interaction.respond(content="**Top 10 talkers**\n" + "\n".join(lines))
list_values returns at most 100 pairs, so on a server with more than 100 tracked users the true top talker can fall outside the scan. For big servers keep the counts in ctx.sql instead.
5) AFK status
/afk <reason> sets the user's status; the bot replies when they're mentioned. Parsing mentions reads message text, so this recipe needs events:message_content.
import re
from yourbot_sdk.events import MessageCreate
@plugin.on_slash_command("afk")
def set_afk(ctx: Context, event: dict):
opts = {o["name"]: o["value"] for o in event.get("options", [])}
reason = opts.get("reason") or "afk"
ctx.kv.set(f"afk:{event['user_id']}", reason, ttl_seconds=86400)
ctx.interaction.respond(content=f"Got it, set you AFK ({reason}).")
@plugin.on_event("message_create")
def notify_mentions(ctx: Context, event: MessageCreate):
if event.get("author_bot"):
return
for uid in set(re.findall(r"<@!?(\d+)>", event.get("content", ""))):
reason = ctx.kv.get(f"afk:{uid}")
if reason:
ctx.discord.send_message(channel_id=event["channel_id"],
content=f"<@{uid}> is AFK: {reason}")
6) Custom commands stored in KV
Server admins add their own !shortcut commands without redeploying.
from yourbot_sdk.events import MessageCreate
@plugin.on_event("message_create")
def custom(ctx: Context, event: MessageCreate):
content = event.get("content", "").strip()
if not content.startswith("!"):
return
name = content[1:].split(" ", 1)[0]
response = ctx.kv.get(f"cmd:{name}")
if response:
ctx.discord.send_message(channel_id=event["channel_id"], content=str(response))
@plugin.on_slash_command("addcmd")
def add(ctx: Context, event: dict):
opts = {o["name"]: o["value"] for o in event.get("options", [])}
ctx.kv.set(f"cmd:{opts['name']}", opts["response"])
ctx.interaction.respond(content=f"Saved !{opts['name']}.")
7) Reaction-based polls
/poll creates a message with ✓/✗ reactions; tallies via reaction count later.
@plugin.on_slash_command("poll")
def poll(ctx: Context, event: dict):
opts = {o["name"]: o["value"] for o in event.get("options", [])}
question = opts.get("question") or "Yes or no?"
msg = ctx.discord.send_message(channel_id=event["channel_id"], content=f"**Poll:** {question}")
ctx.discord.add_reaction(channel_id=event["channel_id"], message_id=msg["message_id"], emoji="✅")
ctx.discord.add_reaction(channel_id=event["channel_id"], message_id=msg["message_id"], emoji="❌")
ctx.interaction.respond(content="Poll posted!")
8) Message logger
Log every non-bot message to a configurable mod channel (useful for audit trails). Reads message text, so it needs events:message_content.
from yourbot_sdk.events import MessageCreate
@plugin.on_event("message_create")
def log(ctx: Context, event: MessageCreate):
if event.get("author_bot"):
return
log_channel = ctx.kv.get("mod_log_channel_id")
if not log_channel:
return
text = event.get("content", "")[:200]
ctx.discord.send_message(channel_id=str(log_channel),
content=f"[#{event.get('channel_name', '?')}] <@{event['author_id']}>: {text}")
9) /stats: server stat card
Show message and join counts. The two counter handlers keep the numbers the command reads; reset them with the daily-gate pattern from recipe 3 if you want true per-day figures.
@plugin.on_event("message_create")
def count_messages(ctx: Context, event: dict):
if not event.get("author_bot"):
ctx.kv.increment("messages_today_total")
@plugin.on_event("member_join")
def count_joins(ctx: Context, event: dict):
ctx.kv.increment("new_members_today")
@plugin.on_slash_command("stats")
def stats(ctx: Context, event: dict):
msgs_today = ctx.kv.get("messages_today_total") or 0
new_today = ctx.kv.get("new_members_today") or 0
ctx.interaction.respond(content=(
f"**Server stats**\n"
f"Messages today: {msgs_today}\n"
f"New members today: {new_today}"
))
10) Role menu (dropdown)
Admin runs /role-menu, gets a dropdown listing self-assignable roles.
from yourbot_sdk import ActionRow, SelectMenu, SelectOption
@plugin.on_slash_command("role-menu")
def menu(ctx: Context, event: dict):
roles = ctx.kv.get("self_roles") or [] # list of {"id": "...", "label": "..."}
options = [SelectOption(label=r["label"], value=r["id"]) for r in roles]
ctx.interaction.respond(content="Pick your role(s):",
components=[ActionRow(SelectMenu("pick_role", options, min_values=1, max_values=len(options)))])
@plugin.on_component("pick_role")
def picked(ctx: Context, event: dict):
selected_ids = event.get("values") or []
for rid in selected_ids:
ctx.discord.add_role(user_id=event["user_id"], role_id=rid)
ctx.interaction.respond(content="Roles updated.", ephemeral=True)
11) Ticket triage: /ticket opens a thread
User runs /ticket <subject>; the bot creates a private thread and posts the details there. create_thread needs discord:manage_channels (auto-detected).
@plugin.on_slash_command("ticket")
def open_ticket(ctx: Context, event: dict):
opts = {o["name"]: o["value"] for o in event.get("options", [])}
subject = opts.get("subject") or "Support request"
thread = ctx.discord.create_thread(
channel_id=event["channel_id"],
name=f"ticket-{event['user_id']}"[:100],
thread_type=12, # 12 = private thread
)
ctx.discord.send_message(channel_id=thread["id"],
content=f"**Ticket from <@{event['user_id']}>**: {subject}")
ctx.interaction.respond(content="Ticket opened. Staff will reply shortly.")
12) Auto-respond to keywords
Trigger a reply when a message contains a configured keyword. Useful for FAQs.
from yourbot_sdk.events import MessageCreate
@plugin.on_event("message_create")
def autoresponder(ctx: Context, event: MessageCreate):
if event.get("author_bot"):
return
content = event.get("content", "").lower()
triggers = ctx.kv.get("keyword_triggers") or {} # {keyword: reply}
for kw, reply in (triggers.items() if isinstance(triggers, dict) else []):
if kw.lower() in content:
ctx.discord.send_message(channel_id=event["channel_id"], content=str(reply))
break # one auto-reply per message
13) Birthday reminders
Users register their birthday via /birthday MM-DD; the daily gate (recipe 3) announces the day's birthdays with the first message after midnight UTC. The mentions render but do not ping (anti-mass-ping guard).
from datetime import datetime, timezone
@plugin.on_slash_command("birthday")
def set_bday(ctx: Context, event: dict):
opts = {o["name"]: o["value"] for o in event.get("options", [])}
ctx.kv.set(f"bday:{event['user_id']}", opts.get("date"))
ctx.interaction.respond(content=f"Saved {opts.get('date')}.")
@plugin.on_event("message_create")
def announce(ctx: Context, event: dict):
today = datetime.now(timezone.utc).strftime("%m-%d")
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d")
if not ctx.ephemeral.dedup(f"bday_check:{stamp}", ttl_seconds=86400):
return # already checked today
channel_id = ctx.kv.get("birthday_channel_id")
if not channel_id:
return
bdays = ctx.kv.list_values(prefix="bday:") or {} # {key: "MM-DD"}
user_ids = [k.split(":")[1] for k, d in bdays.items() if d == today]
if not user_ids:
return
mentions = " ".join(f"<@{uid}>" for uid in user_ids)
ctx.discord.send_message(channel_id=str(channel_id),
content=f"🎂 Happy birthday {mentions}!")
14) Member of the week
Count messages per user, then announce the week's top sender on Sunday after 09:00 UTC using the daily-gate pattern. The winner mention renders without pinging.
from datetime import datetime, timezone
@plugin.on_event("message_create")
def count_week(ctx: Context, event: dict):
if event.get("author_bot"):
return
ctx.kv.increment(f"msgs_week:{event['author_id']}")
now = datetime.now(timezone.utc)
if now.weekday() != 6 or now.hour < 9: # Sunday, after 09:00 UTC
return
if not ctx.ephemeral.dedup(f"mvp:{now:%Y-%m-%d}", ttl_seconds=86400):
return
counts = ctx.kv.list_values(prefix="msgs_week:") or {} # {key: count}
if not counts:
return
top_key, top_count = max(counts.items(), key=lambda kv: int(kv[1] or 0))
uid = top_key.split(":", 1)[1]
channel_id = ctx.kv.get("announce_channel_id")
if channel_id:
ctx.discord.send_message(channel_id=str(channel_id),
content=f"🏆 Member of the week: <@{uid}> with {top_count} messages.")
for key in counts: # reset weekly counters
ctx.kv.delete(key)
15) Voice channel time tracker
Track how long each user spends in voice channels. Useful for activity-based rewards.
from yourbot_sdk.events import VoiceStateUpdate
import time
@plugin.on_event("voice_state_update")
def track(ctx: Context, event: VoiceStateUpdate):
uid = event["user_id"]
if event.get("after_channel_id") and not event.get("before_channel_id"):
# joined voice
ctx.kv.set(f"voice_in:{uid}", str(int(time.time())))
elif event.get("before_channel_id") and not event.get("after_channel_id"):
# left voice
join_t = ctx.kv.get(f"voice_in:{uid}")
if join_t:
duration = int(time.time()) - int(join_t)
ctx.kv.increment(f"voice_secs:{uid}", amount=duration)
ctx.kv.delete(f"voice_in:{uid}")
16) Call an external API: /price
Fetch live data from a third-party API through the platform proxy. Add "proxy_domains_requested": ["api.coingecko.com"] to your manifest (the upload scan usually detects it from the URL literal, but declaring it is your final say); proxy:http is auto-added. For APIs that need a key, store it with ctx.secrets and pass secret_auth= — see secret-backed auth.
import json
from yourbot_sdk import SdkError
@plugin.on_slash_command("price")
def price(ctx: Context, event: dict):
opts = {o["name"]: o["value"] for o in event.get("options", [])}
coin = (opts.get("coin") or "bitcoin").strip().lower()
try:
resp = ctx.http.get(
"https://api.coingecko.com/api/v3/simple/price",
params={"ids": coin, "vs_currencies": "usd"},
)
except SdkError as exc:
ctx.interaction.respond(content=f"Price lookup failed: {exc.code}")
return
if resp["status"] != 200:
ctx.interaction.respond(content="Price service is unavailable right now.")
return
data = json.loads(resp["body_bytes"]) or {}
usd = (data.get(coin) or {}).get("usd")
if usd is None:
ctx.interaction.respond(content=f"Unknown coin: {coin}")
return
ctx.interaction.respond(content=f"**{coin}**: ${usd:,}")
Responses are capped at 200 KB (check resp["truncated"] on big payloads) and the proxy allows 30 requests/minute per server — cache slow-moving data in ctx.kv with a ttl_seconds if you expect chatty usage.
17) XP levels that scale: /rank with SQL
Recipe 4's KV leaderboard scans at most 100 keys per call. When your plugin lands on big servers, keep counts in ctx.sql instead — a real table sorts any number of users. Needs the storage:sql capability (staff-reviewed); bootstrap the schema in on_ready, the designed place for per-server init.
@plugin.on_ready
def init_schema(ctx: Context):
ctx.sql.execute(
"CREATE TABLE IF NOT EXISTS xp ("
" user_id TEXT PRIMARY KEY,"
" points BIGINT NOT NULL DEFAULT 0)"
)
@plugin.on_event("message_create")
def award_xp(ctx: Context, event: dict):
if event.get("author_bot"):
return
ctx.sql.execute(
"INSERT INTO xp (user_id, points) VALUES (%s, 5) "
"ON CONFLICT (user_id) DO UPDATE SET points = xp.points + 5",
[event["author_id"]],
)
@plugin.on_slash_command("rank")
def rank(ctx: Context, event: dict):
rows = ctx.sql.query(
"SELECT user_id, points FROM xp ORDER BY points DESC LIMIT 10")
if not rows:
ctx.interaction.respond(content="No XP yet - say something!")
return
lines = [f"{i+1}. <@{r['user_id']}>: {r['points']} XP"
for i, r in enumerate(rows)]
ctx.interaction.respond(content="**XP leaderboard**\n" + "\n".join(lines))
18) Keyword moderation: auto-timeout + embed mod-log
Time out members who use a banned word and post an embed to a mod-log channel. Reads message text (events:message_content) and uses discord:moderate_members (Dangerous tier — expect reviewers to look at it closely). Note plugins can only delete their own messages, so a moderation plugin's tools are timeouts and logging, not message removal.
from yourbot_sdk import SdkPermissionError
@plugin.on_slash_command("banword")
def banword(ctx: Context, event: dict):
opts = {o["name"]: o["value"] for o in event.get("options", [])}
words = ctx.kv.get("banned_words") or []
words.append(str(opts["word"]).lower())
ctx.kv.set("banned_words", words)
ctx.interaction.respond(content=f"Added. {len(words)} words on the list.")
@plugin.on_event("message_create")
def watch(ctx: Context, event: dict):
if event.get("author_bot"):
return
content = (event.get("content") or "").lower()
hit = next((w for w in (ctx.kv.get("banned_words") or []) if w in content), None)
if not hit:
return
try:
ctx.discord.timeout_member(
user_id=event["author_id"],
duration_seconds=600,
reason=f"Banned word: {hit}",
)
except SdkPermissionError as exc:
ctx.log(f"cannot timeout: {exc}", level="warning")
return
log_channel = ctx.kv.get("mod_log_channel_id")
if log_channel:
ctx.discord.send_message(channel_id=str(log_channel), embeds=[{
"title": "Member timed out (10 min)",
"description": f"<@{event['author_id']}> used a banned word.",
"color": 0xE05A5A,
"fields": [
{"name": "Word", "value": hit, "inline": True},
{"name": "Channel", "value": f"<#{event['channel_id']}>", "inline": True},
],
}])
Embeds are plain dicts in Discord's embed shape (title, description, color, fields, footer, …) — up to 10 per message.
19) Starboard: repost popular messages
When a message collects enough ⭐ reactions, celebrate it in a dedicated channel. The KV counter crosses the threshold exactly once, so no dedup flag is needed. Reaction emoji arrive only with events:message_content (recipe 2's caveat applies).
from yourbot_sdk.events import ReactionAdd
STAR = "⭐"
THRESHOLD = 3
@plugin.on_slash_command("starboard-here")
def set_board(ctx: Context, event: dict):
ctx.kv.set("starboard_channel_id", event["channel_id"])
ctx.interaction.respond(content="This is now the starboard.")
@plugin.on_event("reaction_add")
def count_star(ctx: Context, event: ReactionAdd):
if event.get("user_bot") or event.get("emoji") != STAR:
return
n = ctx.kv.increment(f"stars:{event['message_id']}")
board = ctx.kv.get("starboard_channel_id")
if n != THRESHOLD or not board or str(board) == str(event["channel_id"]):
return
link = (f"https://discord.com/channels/"
f"{event.get('guild_id') or ctx.server_id}"
f"/{event['channel_id']}/{event['message_id']}")
ctx.discord.send_message(channel_id=str(board),
content=f"{STAR} A message just hit {THRESHOLD} stars: {link}")
20) Weekly digest on a real schedule (production cron)
The daily-gate pattern (recipe 3) needs traffic to fire. When a job must run at a wall-clock time even on a quiet server, use a cron task: decorate the handler and declare it in your manifest's "cron" array — the platform fires it per installed server in UTC (5-minute floor, 5 entries max; see Cron schedules).
@plugin.on_slash_command("digest-here")
def set_digest(ctx: Context, event: dict):
ctx.kv.set("digest_channel_id", event["channel_id"])
ctx.interaction.respond(content="Weekly digest will post here, Fridays 16:00 UTC.")
@plugin.on_event("message_create")
def count(ctx: Context, event: dict):
if not event.get("author_bot"):
ctx.metrics.record("messages")
@plugin.cron("0 16 * * 5") # every Friday, 16:00 UTC
def weekly_digest(ctx: Context):
channel_id = ctx.kv.get("digest_channel_id")
if not channel_id:
return
total = int(ctx.metrics.total("messages", period="7d"))
ctx.discord.send_message(channel_id=str(channel_id),
content=f"📬 This week on the server: {total} messages. See you next Friday!")
And in manifest.json (the platform reads schedules from the manifest, not your code):
"cron": [
{"spec": "0 16 * * 5", "name": "weekly_digest"}
]
Tip: capabilities and command names are auto-detected from these snippets, so a fresh yourbot new my_plugin plus one recipe uploads cleanly. Commands that take arguments still need their slash_commands options declared — copy them from Manifest declarations. Run yourbot validate before pushing to catch any gaps.
SDK v0.9.0 · Last updated July 2026 · Back to Dev Portal