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.

System map of the bot's nine surfaces on one Claude core.
Nine surfaces, one bot, one set of Claude models routed by the job.

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:

What it feels like

A bet ticket, an automatic settle, and a payout card in the channel.
A public ticket the room sees, an automatic settle, a payout card that names winners and losers — no claim button, no admin.

How it's wired

The Bookie cog reading three feeds and writing three Postgres tables.
One cog, three feeds, three tables. Settlement runs on its own clock.

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.

Three providers naming one game, collapsed into one provider-independent key.
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.

A bet resolving to won, lost, or a refunded void.
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.

Two settlers racing; FOR UPDATE on open rows means one settles and the other no-ops.
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.

The autocomplete cache decision: warm serves instantly, stale refreshes behind, cold yields a hint.
What keeps it instant

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.

Two primary feeds behind breakers, with a budget-guarded dual backstop.
The degradation paths
FeedHealthyWhen it craters
Primary oddsodds + the betting slatethe backstop fills the picker
Live scoreslive scores + finalsthe backstop settles open bets
Backstopidle when the others are healthybecomes 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

The conservation invariant: balance equals start minus open stakes plus settled payouts.

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

American odds and a market price normalizing to one decimal multiplier and a 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 paysimplied
+130 American2.30230 (profit 130)43%
-150 American1.667167 (profit 67)60%
0.40 market YES2.50250 (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:

Pregame and full-time commentary cards that work the bets into the broadcast.

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.