Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 

Repository files navigation

How to Build a Stoat Bot in Python (Step-by-Step, 2026)

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)

Step 1: Create the bot account and get a token

  1. Log into the Stoat app at stoat.chat/app.
  2. Open Settings → My Bots and click Create a Bot.
  3. Give it a name and confirm.
  4. Click Edit on your new bot. If you want other people to be able to invite it, enable Public Bot.
  5. 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.
  6. Grab the Copy Invite Link button and use it to add the bot to your test server.

Step 2: Set up the project

mkdir mybot && cd mybot
python3 -m venv venv
source venv/bin/activate
pip install stoat.py python-dotenv

Put 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__/" >> .gitignore

One trap to avoid: don't name your bot file stoat.py — it will shadow the library and nothing will import.

Step 3: The minimal bot

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.

Step 4: Real commands with stoat.ext.commands

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:

  • ReadyEvent fires again on reconnect. If you start background tasks there, guard with a flag (self._ready_once) or you'll stack duplicate tasks.
  • event.servers is the authoritative server list. More on this in the gotchas — don't trust bot.servers at ready time.

Step 5: Organize with Gears (Stoat's cogs)

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.

Step 6: Embeds (smaller than you're used to)

Stoat embeds are SendableEmbed, and they're deliberately minimal: title, description, colour, url, icon_url, media. There is no add_field(). Also:

  • color is 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")],
)])

Step 7: Interactivity — message interactions

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:

  1. Send the message with interactions=stoat.MessageInteractions(...) — Stoat adds the emoji and (optionally) restricts reactions to just those
  2. Store the pending interaction in a dict keyed by message id
  3. Listen for stoat.MessageReactEvent and 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=True is 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 in reactions, and the bot needs the react permission.)
  • No self-reaction noise. Because Stoat seeds the reactions server-side, your bot doesn't fire MessageReactEvent at itself the way a manual msg.react() loop does — one whole class of "the bot is playing against itself" bugs disappears.

Two details that still matter:

  • Checking event.user_id against 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.

Step 8: Run it 24/7

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.target
sudo 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.)


The gotcha table: discord.py → stoat.py

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...") ⚠️ See below — this one breaks databases
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

The database landmine (worth its own warning)

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.


Where to go from here

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:

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.

About

Step-by-step guide: build a Stoat (formerly Revolt) bot in Python with stoat.py — commands, Gears, embeds, reaction UIs, deployment, and a discord.py migration gotcha table

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors