Stoat is the chat platform formerly known as Revolt — open-source, Discord-like, and young enough that the bot ecosystem is wide open. This guide walks you from zero to a working bot with commands, embeds, and interactive games built on Stoat's message interactions, using stoat.py.
Everything here comes from real experience: I ported a 20-cog Discord economy bot (CartelBot) to Stoat, and this guide includes every gotcha that bit me along the way — including the ones that aren't in the docs.
What you'll need:
- Python 3.10+
- A Stoat account (stoat.chat)
- A server (Stoat's word for a guild) you own, for testing
- Optional: a Linux VPS to run the bot 24/7 (Step 8)
- Log into the Stoat app at stoat.chat/app.
- Open Settings → My Bots and click Create a Bot.
- Give it a name and confirm.
- Click Edit on your new bot. If you want other people to be able to invite it, enable Public Bot.
- Click Copy next to the token. This token is your bot's password — never commit it, never paste it in chat. If it ever leaks, hit Regenerate immediately.
- Grab the Copy Invite Link button and use it to add the bot to your test server.
mkdir mybot && cd mybot
python3 -m venv venv
source venv/bin/activate
pip install stoat.py python-dotenvPut the token in a .env file instead of your code:
# .env
TOKEN=your-bot-token-here
COMMAND_PREFIX=$Lock it down and keep it out of git:
chmod 600 .env
echo -e ".env\nvenv/\n__pycache__/" >> .gitignoreOne trap to avoid: don't name your bot file stoat.py — it will shadow the library and nothing will import.
Create main.py:
import os
import stoat
from dotenv import load_dotenv
load_dotenv()
client = stoat.Client()
@client.on(stoat.ReadyEvent)
async def on_ready(event, /):
print(f"Logged in as {event.me.tag}")
@client.on(stoat.MessageCreateEvent)
async def on_message(event, /):
message = event.message
if message.author.relationship is stoat.RelationshipStatus.user:
return # ignore our own messages
if message.content.startswith("$hello"):
await message.channel.send("Hello!")
client.run(os.getenv("TOKEN"))Run it with python3 main.py, type $hello in your server, and you have a live Stoat bot.
Notice the first big difference from discord.py already: events are class-based. There's no magic on_message name — you subscribe to event types (stoat.MessageCreateEvent, stoat.ReadyEvent), and the message lives at event.message.
Raw startswith checks don't scale. stoat.py ships a command framework that mirrors discord.py's ext.commands closely — if you've written a discord.py bot, this will feel like home:
import os
import stoat
from dotenv import load_dotenv
from stoat.ext import commands
load_dotenv()
bot = commands.Bot(command_prefix=os.getenv("COMMAND_PREFIX", "$"))
@bot.listen()
async def on_ready(event: stoat.ReadyEvent):
print(f"✅ Online as {bot.me}")
@bot.command()
async def ping(ctx):
await ctx.send("Pong! 🏓")
@bot.command()
async def greet(ctx, member: stoat.User):
await ctx.send(f"Hello, {member.mention}!")
bot.run(os.getenv("TOKEN"))Argument converters (member: stoat.User, amount: int) work like discord.py's. The prefix, aliases, and ctx.send all behave the way you'd expect.
Two things to know about on_ready:
ReadyEventfires again on reconnect. If you start background tasks there, guard with a flag (self._ready_once) or you'll stack duplicate tasks.event.serversis the authoritative server list. More on this in the gotchas — don't trustbot.serversat ready time.
Discord.py has Cogs; stoat.py has Gears. Same idea, slightly different spelling. Here's a self-contained gear from a real, running economy bot:
# cogs/fun.py
import random
from stoat.ext import commands
class Fun(commands.Gear):
def __init__(self, bot):
self.bot = bot
@commands.command(name="roll")
async def roll(self, ctx):
"""Roll a die."""
result = random.randint(1, 6)
await ctx.send(f"🎲 You rolled **{result}**!")
async def setup(bot):
await bot.add_gear(Fun(bot))Note that setup is async and calls await bot.add_gear(...) — not the sync add_cog you may remember. Load your gears at startup by subclassing commands.Bot and overriding setup_hook:
import os
from stoat.ext import commands
COGS_DIR = os.path.join(os.path.dirname(__file__), "cogs")
class MyBot(commands.Bot):
async def setup_hook(self):
for f in sorted(os.listdir(COGS_DIR)):
if f.endswith(".py") and not f.startswith("__"):
ext = f"cogs.{f[:-3]}"
try:
await self.load_extension(ext)
print(f"- {f[:-3]} ✅")
except Exception as e:
print(f"- {f[:-3]} ❌ ({e})")Now adding a feature means dropping a file into cogs/ and restarting.
Stoat embeds are SendableEmbed, and they're deliberately minimal: title, description, colour, url, icon_url, media. There is no add_field(). Also:
coloris a CSS string ("#f1c40f"), not an int- You pass embeds as a list:
await ctx.send(embeds=[embed])
If you're porting Discord code full of embed fields, fold them into the markdown description. This little helper let me port 20 cogs almost unchanged:
# modules/ui.py
from stoat import SendableEmbed
def hexcolor(c):
"""Accept a Discord-style int (0x00ff88) or CSS string; return CSS string."""
if c is None:
return None
if isinstance(c, int):
return f"#{c:06x}"
return str(c)
def embed(title=None, description="", color=None, fields=None):
"""fields: list of (name, value) tuples, rendered as bold markdown lines."""
body = description or ""
if fields:
lines = [f"**{name}:** {value}" for name, value in fields]
body = (body + "\n\n" if body else "") + "\n".join(lines)
return SendableEmbed(title=title, description=body, color=hexcolor(color))Usage:
from modules.ui import embed
await ctx.send(embeds=[embed(
title="🏦 Bank Statement",
color=0x00ff88,
fields=[("Wallet", "12,500 Pesos"), ("Bank", "48,000 Pesos")],
)])Here's the biggest adjustment coming from Discord: Stoat has no slash commands, no buttons, no modals, no Views. Instead, its native primitive for interactive messages is message interactions — a declarative set of reaction "buttons" you attach when you send the message. Stoat seeds the emoji for you and can lock the message so users may only react with those emoji. It's the closest thing to Discord buttons, and it's cleaner than manually reacting in a loop.
The pattern that works:
- Send the message with
interactions=stoat.MessageInteractions(...)— Stoat adds the emoji and (optionally) restricts reactions to just those - Store the pending interaction in a dict keyed by message id
- Listen for
stoat.MessageReactEventand match it against the registry
A confirm/decline prompt, straight from a working coinflip challenge:
import stoat
from stoat.ext import commands
ACCEPT, DECLINE = "✅", "❌"
class Duel(commands.Gear):
def __init__(self, bot):
self.bot = bot
self.pending = {} # message_id -> challenge state
@commands.command()
async def duel(self, ctx, member: stoat.User):
msg = await ctx.send(
f"{member.mention}, react {ACCEPT} to accept or {DECLINE} to decline.",
interactions=stoat.MessageInteractions(
reactions=[ACCEPT, DECLINE],
restrict_reactions=True, # only these two emoji can be added
),
)
self.pending[msg.id] = {"challenger": ctx.author, "opponent": member, "msg": msg}
@commands.Gear.listener()
async def on_reaction(self, event: stoat.MessageReactEvent):
p = self.pending.get(event.message_id)
if not p or event.user_id != p["opponent"].id:
return # not one of ours, or wrong person reacting
self.pending.pop(event.message_id, None)
if str(event.emoji) == ACCEPT:
await p["msg"].channel.send("⚔️ Duel accepted!")
else:
await p["msg"].channel.send("🐔 Declined.")
async def setup(bot):
await bot.add_gear(Duel(bot))Why interactions beat a manual msg.react() loop:
- One atomic call. The emoji ship with the message instead of a follow-up
for e in (...): await msg.react(e)round-trip per emoji. Less code, no flicker while the buttons appear. restrict_reactions=Trueis real enforcement. Users can only add the emoji you listed — no junk reactions cluttering your prompt, and the "these are the buttons" contract is enforced by the server, not your handler. (It requires at least one emoji inreactions, and the bot needs thereactpermission.)- No self-reaction noise. Because Stoat seeds the reactions server-side, your bot doesn't fire
MessageReactEventat itself the way a manualmsg.react()loop does — one whole class of "the bot is playing against itself" bugs disappears.
Two details that still matter:
- Checking
event.user_idagainst the expected user is your access control; anyone allowed to react can react. - The listener is unchanged whether you seed reactions manually or via interactions — you always handle the click in
MessageReactEvent. Interactions only change how the emoji get onto the message.
This pattern scales surprisingly far — I've used it for blackjack (hit/stand/double as 🃏/✋/⚡), heist lobbies (🤝 to join, 🚀 to start), and kill-confirm prompts.
On a VPS, the simplest robust option is a systemd service:
# /etc/systemd/system/mybot.service
[Unit]
Description=My Stoat Bot
After=network-online.target
[Service]
User=youruser
WorkingDirectory=/home/youruser/mybot
ExecStart=/home/youruser/mybot/venv/bin/python3 main.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now mybot
journalctl -u mybot -f # live logs(screen/tmux works fine too for development; systemd gets you auto-restart on crash and on reboot.)
Every difference that cost me real debugging time, in one place:
| discord.py | stoat.py | Notes |
|---|---|---|
commands.Cog |
commands.Gear |
await bot.add_gear(...), setup(bot) is async |
ctx.guild |
ctx.server |
"Guild" is "Server" everywhere |
User IDs are int |
ULID strings ("01KX...") |
|
discord.Embed + add_field |
SendableEmbed, no fields |
Fold fields into markdown description; color is a CSS string; pass embeds=[...] |
async def on_message(self, msg) |
@Gear.listener() + typed stoat.MessageCreateEvent param |
Events are class-based; payload is on the event object |
| Slash commands, buttons, Views | Don't exist | Prefix commands + message interactions (MessageInteractions(reactions=[...], restrict_reactions=True) on send, handled in MessageReactEvent) |
ctx.reply(...) |
message.reply(mention=True) |
No reply on ctx |
bot.guilds reliable after ready |
client.servers unreliable at ready |
Returns stubs / flip-flops. Capture event.servers from ReadyEvent — those are fully hydrated |
| Members cached | Not cached | await server.fetch_members(); then member.user.bot, member.user.id |
on_ready usually once |
ReadyEvent refires on reconnect |
Guard startup work with a flag |
If you're porting a bot with a SQLite database: Discord IDs are integers, so schemas commonly use user_id INTEGER PRIMARY KEY. In SQLite, that exact spelling is a rowid alias that rejects non-integer values — and since Stoat IDs are ULID strings, every insert fails with Datatype mismatch. Change the column to user_id TEXT PRIMARY KEY (and rebuild the table). If your data layer only ever touches user.id, that's the only schema change a port needs.
You now have everything you need for a production Stoat bot: commands, gears, embeds, message-interaction UIs, and a deployment story. If you're bringing an existing Discord bot over, the companion guide — Porting a Discord Bot to Stoat — covers the full migration with before/after code. The ecosystem is early — awesome-stoat lists what exists, and the answer is "not much yet." That's an opportunity.
Docs & links:
- stoat.py documentation: stoatpy.readthedocs.io
- stoat.py on GitHub: MCausc78/stoat.py · PyPI
- Stoat itself: stoat.chat · stoatchat on GitHub
This guide was written while porting CartelBot — a 20-cog Discord economy game bot — to Stoat. If you want to see everything above running live, invite CartelBot to your server or browse the source on GitHub.