Engineering deep-dive
The engineering behind Toots
A Discord bartender that answers questions, runs a play-money sportsbook, calls live games, remembers its regulars, and ships its own code. This is how the hard parts actually work.
A few of them: it answers freeform questions under a grounding classifier that won't let it invent a number; it calls live matches in a bartender's voice off real box scores and odds; it remembers its regulars and does callbacks weeks later; and — the one that still surprises people — you can ask it for a feature in chat and it files its own issue, writes the pull request, runs the tests, and ships it.
Breadth is easy to claim. So here is one surface end to end — the one with the least room to hand-wave, because it moves money.
The Bookie — a worked example
A play-money sportsbook inside the bot. It settles real games off live data — and never pays the wrong team.
Bet coins on who wins a live or upcoming game. The bot freezes the line, watches the game end, and pays out in front of the whole room. It looks like a toy. The hard part hides inside "watches the game end and settles correctly": there is no single source of truth for a sports result, and the dropdown you bet from has a three-second deadline.
The hard problems, in one breath:
- 🧩 a game has no stable ID across providers → so we mint one
- 💸 settle by team name, never position → never pay the wrong team
- ⏱️ a 3-second autocomplete over a 5-second fetch → never block
- 🛏️ three feeds, each behind a breaker → never go dark
What it feels like
How it's wired
1 · A game has no stable ID
Three feeds describe the same match differently — flipped home/away, different spellings
(BiH vs Bosnia & Herzegovina), even different ideas of whether it is
still live. So we don't trust any provider's ID. We mint our own: sport + the two team
names, accent-folded, order-independent, alias-collapsed.
The exhibit — minting the key
def match_key_parts(sport, home, away, title=""):
# sorted() -> order-independent (a home/away flip can't split it)
# canonical_team -> folds "BiH" and "Bosnia & Herzegovina" onto one name
teams = "@".join(sorted(t for t in (_canonical_team(home), _canonical_team(away)) if t))
return f"{_norm(sport)}:{teams}" if teams else f"{_norm(sport)}:{_norm(title)}"
Two design choices carry the safety. Sorting the names makes a home/away flip impossible to split. An unresolved name passes straight through, so the worst case is a missed merge (later flagged for an alias to be added) — never a wrong one. And there is deliberately no date in the key: only open bets ever settle, so a rematch can't double-settle an old market.
2 · Settle the money, never wrong
A bet stores the team it backed, not a side. Settlement matches by name — so a provider flipping orientation can't pay the wrong team — and if a name can't be reconciled, the bet refunds. The house eats ambiguity; a player never loses a stake to a spelling gap.
The exhibit — the settlement decision
def bookie_bet_outcome(side, side_label, winner, home, away, stake, decimal_odds):
if winner is None: # no result yet
return "void", stake # -> refund the stake
if winner == "draw":
return ("won", round(stake * decimal_odds)) if side == "draw" else ("lost", 0)
if side == "draw":
return "lost", 0
sl = _canonical_team(side_label)
if sl and sl == _canonical_team(winner):
return "won", round(stake * decimal_odds) # backed the winner
if sl and sl in (_canonical_team(home), _canonical_team(away)):
return "lost", 0 # backed the loser
return "void", stake # can't reconcile a name -> refund, never wrongly lose
It's a pure function: same inputs, same verdict, no database and no clock —
which is exactly why it can be exhaustively unit-tested in isolation. Read the two void
paths: one for "no result yet," one for "a name I cannot reconcile." Both hand the money back.
The only ways to lose are to back the actual loser, or the wrong side of a draw.
Two settlers, one payout. A five-minute settle loop and the live commentator can
both reach a finished game at the same moment. The fix is one transaction that touches only
the still-open rows, FOR UPDATE: whoever gets there second finds nothing to
do. Each bet pays exactly once.
The exhibit — the transaction, and a real bug it caused
async def settle_bookie_game(game_key, winner):
async with pool.acquire() as conn, conn.transaction():
# one transaction: lock the market AND settle every open bet on it
await conn.execute("INSERT INTO locked_games (game_key) VALUES ($1) "
"ON CONFLICT DO NOTHING", game_key)
rows = await conn.fetch(
"SELECT * FROM bets WHERE game_key=$1 AND status='open' FOR UPDATE", game_key)
for r in rows: # the loser of the race finds zero rows here
status, payout = bookie_bet_outcome(...) # the pure decision above
await conn.execute("UPDATE bets SET status=$2, payout=$3 WHERE id=$1", r.id, status, payout)
if payout:
await conn.execute("UPDATE balances SET balance = balance + $2 "
"WHERE user_id=$1", r.user_id, payout)
The bug worth keeping: the commentator settles a game so it can roast the result — but the loop usually wins the race, so the commentator's own call comes back empty, and roasting off that empty result silently blanked the post-game line. The lesson is in the design now: settle, then separately read who got settled, rather than trusting your own return value.
3 · The dropdown can't wait
Discord kills a slash interaction after about three seconds. A cold odds fan-out across the leagues is about five. So the picker is never allowed to block on a live fetch — it serves a warm cache and refreshes behind the scenes.
What keeps it instant
- Warm loop — the cache refreshes only while betting is active, so an idle server spends nothing.
- Single-flight — a burst of keystrokes shares one in-flight refresh instead of launching a fetch each.
- Durable slate — the last-good list survives a redeploy (which wipes the in-memory cache), so an auto-deploy doesn't cold-start the picker.
- Defer-first — placement acknowledges the interaction before doing any work, so resolving the pick can't blow the window either.
4 · Three feeds, never dark
Each outbound feed sits behind its own circuit breaker — a sustained failure trips it and stops the hammering instead of stalling every request. One feed is a dual backstop: it can fill the betting slate and settle games, and it's budget-guarded so a metered key can never be drained.
The degradation paths
| Feed | Healthy | When it craters |
|---|---|---|
| Primary odds | odds + the betting slate | the backstop fills the picker |
| Live scores | live scores + finals | the backstop settles open bets |
| Backstop | idle when the others are healthy | becomes the only source — with a credit reserve it won't spend below |
One product rule falls out of this: only offer what we can settle. Settlement is limited to the sports the score feed covers, so a sport we could price but not settle is never shown in the picker in the first place.
The money always conserves
Placement is one transaction — ensure the account exists, apply the daily top-up, check funds, debit, insert the bet — so a balance can't go negative and a double-spend can't slip through. And every balance reduces to one invariant: start − open stakes + settled payouts. If that ever stops holding, coins were conjured from nothing. It is the single check that proves the whole ledger.
Odds → payout
A sportsbook line and a prediction-market price reduce to the same decimal multiplier, so they compare and pay out on one scale — and the multiplier is frozen at the moment you place the bet, so a later line move never changes what a settled bet pays.
The exact numbers
| Native price | → decimal | $100 pays | implied |
|---|---|---|---|
+130 American | 2.30 | 230 (profit 130) | 43% |
-150 American | 1.667 | 167 (profit 67) | 60% |
0.40 market YES | 2.50 | 250 (profit 150) | 40% |
The payoff: your bets in the live play-by-play
The bot doesn't just take bets — it calls the games, live, in a sharp bartender voice, and folds the sportsbook into the broadcast on the bookend beats:
It settles the game with that same idempotent transaction, then hands the result to the commentary as grounded context (names and numbers only, never invented). It's the moment the feature stops feeling like a slash command and starts feeling like a sportsbook with a live announcer who knows your name.
How it's built
A single Discord cog over raw asyncpg — no ORM. The cog owns the slash
commands, the autocomplete picker, and the settle loop; the ledger and settlement run as explicit
SQL transactions in a thin data layer; and the parts worth trusting most — the settlement
decision and the odds math — are pure functions with no I/O, unit-tested in isolation. Three
small tables: balances, bets, and the locked-game set that makes settlement idempotent.
This document is self-contained — every diagram and code sample is embedded, nothing links out. The code lives in a private repository; the snippets above are lightly trimmed to the teaching essence.