Doorman Mike takes bets now

Share

Doorman Mike has worked the door of our group Slack for years. He greets people, summarizes the links they drop, defines words badly via Urban Dictionary, and remembers birthdays. At some point he also picked up a backstory: he's saving up to retire on a boat. The boat fund grows a little every Friday, and as of late September it's invested in the stock market, with the channel voting each week on which stock he buys.

A man with a brokerage account and a retirement deadline is a man who can be talked into things. So on October 1st he got a new command: /bet.

How it works#

Run /bet and Mike opens a modal. You pick a market category (Sports, Crypto, Economics, and so on), then one of its trending bets on Kalshi, then an outcome, a side, and a stake between $5 and $10. Mike puts that much of his boat fund on it at Kalshi's live price and announces it in the channel, tagging whoever talked him into it:

:game*die: Mike's on the board @Dana talked Mike into $8 on YES: Pittsburgh (PIT Steelers vs CLE Browns) at 15¢. Pays $53.33 if it hits. *"That's not gambling, that's reading."_

When the market settles, he tags you again with the result. Win and drinks are on the boat. Lose and he's "adding it to the list."

These are paper bets. No money moves, there's no Kalshi account, and the bot uses no API keys at all. It reads Kalshi's public market data, pays the stake out of a number in a Postgres row, and adds the payout back when Kalshi says the market is final. The prices and results are real. The money is as fictional as the boat.

Mike also has rules, because a character without limits is just a random number generator:

  • One bet per person per day.
  • One bet per event, ever. No piling ten bets on the same Bears game.
  • No more than $50 riding at once.
  • He never dips below a $250 cushion in the boat fund.

Two Kalshi APIs, trusted differently#

The menu of "trending" bets comes from the same feed that powers the Trending tab on kalshi.com. It's undocumented, it only answers on one particular host, and it could change shape tomorrow. Kalshi's documented trade API, on the other hand, gives you any event or market by ticker, but has no idea what's trending.

So the split is: the undocumented feed only builds the menu, and every step that moves money re-reads the market from the documented API. If the trending feed ever breaks, the worst case is an empty menu and Mike saying the board's dark. It can never place a bet at a bad price or settle one wrong.

That re-read also handles the obvious race. Prices move while you're clicking through a modal, so the bet is placed at the ask as of the moment you hit submit, not the one you saw in the menu, and the confirmation shows the price actually used.

Sports markets had one more trick. A Thursday night game's market stays active until a backstop close time days later, so a game that ended an hour ago still looks open for betting. Mike goes by Kalshi's expected expiration instead, and refuses anything whose expected resolution has already passed.

Keeping fake money honest#

The part I didn't expect to spend the most time on was making sure money that doesn't exist never gets lost or counted twice.

The problem: the Friday greeting already reads the boat fund, spends 10 to 60 seconds talking to an LLM and building charts, then writes the whole fund back. If someone places a bet in that window, the Friday write would silently erase it. The stock vote close does the same thing on a shorter fuse.

Two fixes, together:

  1. Every betting cash move locks the fund row first (SELECT … FOR UPDATE), so concurrent bets and settlements queue up instead of racing.
  2. The Friday write rebases instead of overwriting. It remembers the cash it started from and writes computed + (current − baseCash), so whatever betting did in the meantime carries over.
export function rebaseCash(computed: number, baseCash: number, currentCash: number): number {
	return roundMoney(computed + currentCash - baseCash);
}

On top of that, the database does the real enforcement. The table has a unique constraint on (slack_user_id, placed_on) and another on event_ticker. The friendly "you already bet today, tomorrow pal" message is checked first to save you some clicks, but if two submits race each other, the constraints are what let exactly one through.

"Today" was its own small trap. The container runs on UTC, which would put Mike's day boundary at 7 PM Chicago time. The date gets computed in the bot's configured timezone instead, by the same helper the cron schedule uses, so the two can't disagree.

Settlement#

A cron runs every 30 minutes, every day, since sports settle on weekends. It loads the open bets (the $50 cap means there are never more than ten, so one API call covers them all) and settles a bet only when its market is finalized. Kalshi has an earlier determined state too, but that one can still change in a dispute, so Mike waits.

The payout math covers wins, losses, ties and voided markets with one formula. With v as what one YES contract paid out, a YES bet gets contracts × v and a NO bet gets contracts × (1 − v). Settlement is a compare-and-set on status = 'open', so two overlapping runs can't pay the same bet twice. A market Kalshi never finalizes gets voided and refunded 14 days after its close.

Then the channel got involved#

The first version shipped in a day. The next few days were the group using it and me reacting:

  • Pick a category first. The original single dropdown of 25 mixed events became a button per category, each opening that category's own trending feed with when each event resolves and its two favorites.
  • Back it. Every announcement has "Back it · $1/$2" buttons, so anyone else can pile onto a bet at the fresh ask. It uses up their own bet for the day.
  • Swing alerts. When an open bet's price moves 20¢ or more from where it was placed, Mike posts a reaction (the LLM writes one line, gloating or sweating, and code supplies the numbers). They post in the original announcement's thread, and only between 8 AM and 10 PM. A swing found at 3 AM waits for the morning.
  • Mike's tipsters. The Slack Home tab has a leaderboard ranking everyone by how much their calls have made Mike.
  • Wider odds. The original price band was 5¢ to 95¢ to keep out near-certainties. Then someone got refused a No at 96¢, and someone else got refused the Pope at 2¢ for the Nobel Peace Prize. It's now 1¢ to 99¢, everything Kalshi trades, which means a $10 stake can pay $1,000.
  • Recaps. Settled bets get worked into the Friday and Monday greetings.

Mike's pick of the week#

The most fun addition is that Mike now bets on his own. Every Friday after the greeting he puts $10 on a longshot priced between 10¢ and 40¢ and explains his reasoning.

The split of responsibilities here mirrors how his stock picks work. Code builds a shortlist from the trending feed, checks every candidate against Kalshi's real asks, and enforces every money rule. The LLM only picks one from the numbered list and writes why, as JSON. If the answer comes back unusable, code picks a random candidate and Mike's reasoning becomes "Gut feeling. Don't ask."

His picks keep their own record, separate from everyone else's calls, so the channel can watch him be bad at this in public: "Mike's own picks: 2–7 lifetime. Still confident."

The newest piece came from someone wanting to bet on the Oscars. Nothing like that shows up on a trending feed, and the 7-day horizon would have refused it anyway. So now /bet takes a pasted Kalshi link and jumps straight to that event's outcomes, with a 15-month horizon instead of 7 days.

A few things made that trickier than it sounds:

  • The link is never fetched. It's only parsed for its tickers, which are then looked up through the documented API. A crafted URL can't make the bot request anything else.
  • Kalshi pads long-range dates. The Best Picture market for the 2027 ceremony says it resolves December 31st, even though the ceremony is in March. 15 months is what it took for that market to fit all season.
  • Long bets get their own cap. Bets resolving more than a week out can hold at most $20 of the $50 between them, so five Oscars bets can't lock up this weekend's games.

Things I learned along the way#

  • Trust each data source exactly as far as you need to. The undocumented feed decides what's shown. The documented API decides what's paid.
  • Let the database say no. Unique constraints and row locks are less code than any application-level check, and they hold up under retries and restarts.
  • Keep the model in its lane. The LLM writes Mike's voice and chooses from lists code already vetted. It never decides what anything costs or whether a rule applies.
  • Slack's Block Kit has opinions. Carousels aren't allowed in modals, alert blocks are only allowed in modals, a modal's private_metadata caps out at 3,000 characters, and a slash command's trigger_id expires in 3 seconds, so the modal has to open with "Checking the board…" before anything slow happens.
  • Build test fixtures from real responses. Ask Kalshi for an event with its markets nested and they show up inside event.markets, while the top-level markets array comes back empty. You only learn that kind of thing from a real payload.

Every feature here went through the same loop as the rest of KevTools: a design spec, then an implementation plan, then the code, all built with Claude Code. Mike's boat fund is still a long way from a boat. But now when it moves, somebody in the channel gets the credit or the blame.

Comments