Skip to content
[OPEN_POKER]

Build a Poker Bot in Python in Under 50 Lines of Code

O
Open Poker Team||10 min read

You can build a working poker bot in 47 lines of Python. It won't win any tournaments, but it will connect to Open Poker, sit at a table, and play hands against other bots. That's the starting point for everything else.

What do you actually need to get started?

Just Python 3.10+ and one library:

pip install websockets

That's it. No SDK, no framework, no game engine to install. We deliberately kept the protocol simple: your bot connects over WebSocket, receives game state as JSON messages, and sends actions back as JSON. If you can parse a dictionary, you can build a bot.

You'll also need an Open Poker API key: register here if you haven't already. Registration is free and takes about 30 seconds.

Why no SDK? Because SDKs are the wrong abstraction for this problem. They add a dependency surface that has to be maintained, they hide the protocol, and they make debugging harder. When something goes wrong (and it will), you want to print raw messages to see exactly what the server sent. Every bot framework we've seen ends up fighting the SDK instead of working on strategy. The raw WebSocket approach keeps you in control.

What does the full bot look like?

import asyncio
import json
import websockets
 
API_KEY = "your-api-key-here"
WS_URL = "wss://openpoker.ai/ws"
 
async def play():
    headers = {"Authorization": f"Bearer {API_KEY}"}
    async with websockets.connect(WS_URL, additional_headers=headers) as ws:
        msg = json.loads(await ws.recv())
        print(f"Connected as {msg['name']}")
 
        await ws.send(json.dumps({"type": "set_auto_rebuy", "enabled": True}))
        await ws.send(json.dumps({"type": "join_lobby", "buy_in": 2000}))
 
        async for raw in ws:
            msg = json.loads(raw)
            t = msg.get("type")
 
            if t == "your_turn":
                actions = {a["action"]: a for a in msg["valid_actions"]}
                if "check" in actions:
                    act = "check"
                elif "call" in actions:
                    act = "call"
                else:
                    act = "fold"
 
                await ws.send(json.dumps({
                    "type": "action",
                    "action": act,
                    "client_action_id": f"a-{msg['turn_token'][:8]}",
                    "turn_token": msg["turn_token"],
                }))
 
            elif t == "table_closed":
                await ws.send(json.dumps({"type": "join_lobby", "buy_in": 2000}))
 
            elif t == "season_ended":
                await ws.send(json.dumps({"type": "join_lobby", "buy_in": 2000}))
 
            elif t == "hand_result":
                winners = msg.get("winners", [])
                if winners:
                    print(f"Hand won by {winners[0]['name']} (+{winners[0].get('amount', 0)})")
 
asyncio.run(play())

Save it as bot.py, replace your-api-key-here with your actual key, and run python bot.py. You should see output within 30 seconds once another bot joins the queue.

What does this bot actually do?

It's a calling station, and it was our first bot too.

When it's your turn: check if you can (free money). If you can't check, call. If you can't call, fold. This loses chips slowly because you're calling every bet without considering hand strength. But it plays legal poker, stays at the table, and gives you a complete event loop to build on.

The four concepts worth understanding:

set_auto_rebuy tells the server to automatically rebuy 1,500 chips when you bust. Without this, your bot stops playing after losing its stack. With it, the server handles rebuys (subject to a cooldown) and your bot keeps going indefinitely.

join_lobby puts you in the matchmaking queue. The buy_in field sets how many chips to bring to the table. Valid range is 1,000 to 5,000; we default to 2,000, which is 100 big blinds at the 10/20 blind structure. When enough players are queued, the matchmaker creates a 6-max table.

turn_token is an anti-replay token. Every your_turn message includes a fresh token. You must echo it back in your action. If you send a stale token from a previous turn, the action is rejected. Always use the token from the most recent your_turn. Never cache it.

client_action_id is for your own tracking. The server echoes it back in action_ack so you can correlate which action was accepted. Use a unique ID per action; we just slice the turn token for a quick unique string.

Which WebSocket messages does your bot handle?

Your bot receives a continuous stream of JSON messages. Most are informational; you only need to respond to your_turn. But understanding the others is how you build a smarter bot. Here's the full set you'll encounter:

MessageWhat it meansDo you respond?
connectedAuth succeeded, you're onlineNo
lobby_joinedYou're in the matchmaking queueNo
table_joinedYou're seated at a tableNo
hand_startNew hand beginning, here's your seat and the dealerNo
hole_cardsYour two private cards (e.g., ["Ah", "Kd"])No
your_turnYour valid actions, the pot, the boardYes: send an action
player_actionSomeone (maybe you) actedNo
community_cardsFlop, turn, or river dealtNo
hand_resultHand is over, here's who wonNo
bustedYou're out of chipsNo (auto-rebuy handles it)
table_closedTable shut downRejoin lobby
season_endedSeason transitionRejoin lobby

The full message reference is at docs.openpoker.ai/api-reference/message-types. Every field of every message is documented with JSON examples. Worth bookmarking; you'll refer to it constantly.

Making it smarter: three quick wins

The calling station loses about 2-3 big blinds per 100 hands. Three changes immediately improve it, ordered by impact.

1. Fold bad hands pre-flop

Most starting hands in poker are losers. Fold the bottom 60% before the flop and you're already ahead of every calling station on the platform. Starting hand selection is the single biggest improvement you can make.

def should_play(cards):
    """Return True for top ~40% of starting hands."""
    ranks = "23456789TJQKA"
    r1 = ranks.index(cards[0][0])
    r2 = ranks.index(cards[1][0])
    high, low = max(r1, r2), min(r1, r2)
    pair = r1 == r2
    suited = cards[0][1] == cards[1][1]
 
    if pair: return True                    # All pairs
    if high >= 10: return True              # Any two broadway
    if high >= 9 and suited: return True    # Suited connectors 9+
    if high == 12 and low >= 7: return True # A7+
    return False

Store your hole cards when you receive hole_cards, then check should_play() in your your_turn handler. Fold everything else pre-flop.

2. Raise your strong hands

The calling station never raises. This means opponents get to see cheap flops against you every single hand. Fix: raise with your strongest 15% of hands pre-flop.

if "raise" in actions and should_raise(my_cards):
    await ws.send(json.dumps({
        "type": "action",
        "action": "raise",
        "amount": actions["raise"]["min"],  # minimum raise
        "client_action_id": next_id(),
        "turn_token": msg["turn_token"],
    }))

The raise entry in valid_actions tells you the exact min and max amounts. The amount field is a raise-to amount (total bet size), not an increment. If the big blind is 20 and you want to raise to 60, send "amount": 60.

3. Use pot odds post-flop

After the flop, you have real information. Pot odds tell you whether calling is mathematically correct: if the price you're paying is lower than your probability of winning, call. Otherwise fold.

def pot_odds_say_call(pot, call_amount, estimated_win_pct=0.3):
    if call_amount == 0:
        return True
    odds = call_amount / (pot + call_amount)
    return estimated_win_pct > odds

Even a rough estimate of your win probability (30% as a default, higher with top pair, lower with nothing) combined with pot odds beats the pure calling station by a wide margin. The your_turn message includes the current pot size, so you have everything you need.

What we learned running this bot

We ran the calling station for over 1,200 hands to get a real baseline. It lost 2.4 big blinds per 100 hands, not catastrophic but a steady drain. The biggest leak wasn't calling too many bets. It was calling river bets with nothing. The calling station has no concept of "I've missed everything and this bet is large relative to pot"; it just calls, every time, and bleeds.

The second thing that surprised us: auto-rebuy cooldowns matter more than you'd think. After busting, there's an escalating cooldown: instant first, then 10 minutes, then 1 hour cap. A bot that busts frequently spends a lot of time sitting out. Getting stack management right (not busting in the first place) has compounding returns beyond just chip conservation.

Adding should_play() from the section above dropped the loss rate to around 0.8 bb/100 in our testing, a 3x improvement from one function. The bot still loses, but it's now losing like a mediocre player rather than a broken one. That's the starting point for real strategy work.

We're not claiming these are rigorous sample sizes. Variance at 6-max is high, and 1,200 hands is a small window. But directionally, the pattern is consistent: pre-flop selection is the first lever, post-flop aggression is the second.

What to expect on the leaderboard

With the base calling station, you'll finish at the bottom of the leaderboard. Add the three improvements above and you'll sit in the middle. To reach the top, you'll need hand evaluation, opponent modeling, and position awareness, topics we'll cover in future posts.

Your bot needs at least 10 hands to appear on the leaderboard. With continuous play and auto-rebuy enabled, you'll hit that in a few minutes.

The full platform documentation is at docs.openpoker.ai. The actions and strategy guide covers raise semantics, turn tokens, and timeout behavior in detail. The websockets library documentation is worth reading if you want async connection handling beyond the basics shown here.

FAQ

My bot connects but never gets seated. The matchmaker needs 2+ players in the queue. If nobody else is playing, your bot waits. Run two bots with different API keys, or check the leaderboard to see if others are active.

I get action_rejected errors. Check that you're including turn_token from the most recent your_turn message. Stale tokens are the #1 cause of rejections. Don't cache the token between turns.

My bot disconnected and lost its seat. You have 120 seconds to reconnect. If you reconnect in time, your seat is preserved. After 120 seconds, your stack is returned to your balance and you'll need to rejoin the lobby.

Can I run this bot 24/7? Yes. Enable auto-rebuy and handle table_closed + season_ended by rejoining the lobby. The bot in this post does both. We've run it for multi-day stretches without any intervention.

How much should I buy in for? The valid range is 1,000 to 5,000 chips. We use 2,000 in the examples (100 big blinds at 10/20 blinds), which is a standard deep-stack starting amount. Buying in shorter (1,000) reduces variance but also limits how much you can win in a single hand. Buying in deeper (5,000) is fine once your bot has a basic fold/raise strategy; don't do it with a pure calling station.


Ready to run your first bot? Register for an API key and you'll be playing hands within five minutes. The calling station is a fine place to start; every competitive bot on the leaderboard began somewhere similar.

Keep Reading