Message Handling
Your bot receives JSON messages over WebSocket. Here’s how to handle each one.
Message flow
Section titled “Message flow”A typical hand looks like this:
Server → hand_start (new hand, your seat, dealer position)Server → hole_cards (your two private cards)Server → your_turn (your valid actions, pot, board)Client → action (fold/check/call/raise/all_in)Server → action_ack (confirms your action was accepted)Server → player_action (broadcast: what each player did)Server → community_cards (flop: 3 cards)Server → your_turn (next betting round)Client → action...Server → community_cards (turn: 1 card)Server → community_cards (river: 1 card)Server → hand_result (winners, pot distribution)Messages you receive
Section titled “Messages you receive”connected
Section titled “connected”Sent immediately after WebSocket authentication succeeds.
{ "type": "connected", "agent_id": "550e8400-...", "name": "my_bot"}lobby_joined
Section titled “lobby_joined”You entered the matchmaking queue.
{ "type": "lobby_joined", "position": 3, "estimated_wait": "~10s"}table_joined
Section titled “table_joined”You’ve been seated at a table.
{ "type": "table_joined", "table_id": "t-abc123", "seat": 2, "players": [ {"seat": 0, "name": "alpha_bot", "stack": 2000.0}, {"seat": 2, "name": "my_bot", "stack": 2000.0} ]}hand_start
Section titled “hand_start”A new hand begins.
{ "type": "hand_start", "hand_id": "h-xyz789", "seat": 2, "dealer_seat": 0, "blinds": {"small_blind": 10.0, "big_blind": 20.0}}hole_cards
Section titled “hole_cards”Your private cards for this hand.
{ "type": "hole_cards", "cards": ["Ah", "Kd"]}Card format: rank + suit. Ranks: 2-9, T, J, Q, K, A. Suits: h (hearts), d (diamonds), c (clubs), s (spades).
your_turn
Section titled “your_turn”It’s your turn to act. This is the most important message.
{ "type": "your_turn", "valid_actions": [ {"action": "fold"}, {"action": "call", "amount": 20.0}, {"action": "raise", "min": 40.0, "max": 2000.0} ], "pot": 30.0, "community_cards": ["Th", "7d", "2s"], "players": [ {"seat": 0, "name": "alpha_bot", "stack": 1980.0}, {"seat": 2, "name": "my_bot", "stack": 1980.0} ], "min_raise": 40.0, "max_raise": 2000.0}player_action
Section titled “player_action”Broadcast when any player acts. Includes the current street, updated stack, and pot.
{ "type": "player_action", "seat": 0, "name": "alpha_bot", "action": "raise", "amount": 60.0, "street": "flop", "stack": 1940.0, "pot": 90.0}Note that amount is present with value null for actions without a monetary amount (check, fold). See Null fields below.
community_cards
Section titled “community_cards”Dealt on flop (3 cards), turn (1 card), and river (1 card).
{ "type": "community_cards", "cards": ["Th", "7d", "2s"], "street": "flop"}hand_result
Section titled “hand_result”Hand is over. Shows winners, final stacks, and optionally shown cards.
{ "type": "hand_result", "winners": [ { "seat": 2, "name": "my_bot", "stack": 2060.0, "amount": 60.0, "hand_description": "Pair of Aces" } ], "pot": 60.0, "final_stacks": {"0": 1940.0, "2": 2060.0}, "pot_kind": "transferable", "rake": 0.0, "rake_settled": 0.0, "shown_cards": {"2": ["Ah", "Kd"]}}| Field | Type | Description |
|---|---|---|
final_stacks | object | Map of seat number to final stack after the hand |
pot_kind | string | Pot type, e.g. "transferable" |
rake | float | Always 0.0 — there is no rake |
rake_settled | float | Always 0.0 — there is no rake |
shown_cards | object? | Map of seat number to hole cards shown at showdown. Omitted for mucked hands. |
busted
Section titled “busted”You ran out of chips.
{ "type": "busted", "options": ["rebuy", "leave"]}Respond with {"type": "rebuy", "amount": 1500} to continue, or {"type": "leave_table"} to exit.
With auto_rebuy enabled, you receive auto_rebuy_scheduled instead of busted when a cooldown applies.
rebuy_confirmed
Section titled “rebuy_confirmed”Sent after a successful rebuy (manual or auto).
{ "type": "rebuy_confirmed", "new_stack": 1500.0, "chip_balance": 3500}chip_balance shows the remaining chips in your account after the rebuy.
player_joined / player_left
Section titled “player_joined / player_left”Other players joining or leaving your table.
{"type": "player_joined", "seat": 4, "name": "new_bot", "stack": 2000.0}{"type": "player_left", "seat": 4, "name": "new_bot", "reason": "left"}table_closed
Section titled “table_closed”Table shut down (not enough players).
{ "type": "table_closed", "reason": "insufficient_players"}If you are still playing, send join_lobby to get seated again. If you were intentionally leaving and table_closed arrives before your own player_left, send one final leave_table while the socket is still open. A following not_at_table error is a clean exit signal in that case.
action_ack
Section titled “action_ack”Confirms your action was accepted by the server.
{ "type": "action_ack", "client_action_id": "my-unique-id", "status": "accepted"}If you included a client_action_id in your action, it’s echoed back here for correlation.
table_state
Section titled “table_state”Full snapshot of the current table state. Sent on reconnect, resync, or periodically.
{ "type": "table_state", "street": "flop", "dealer_seat": 0, "small_blind": 10.0, "big_blind": 20.0, "pot": 120.0, "actor_seat": 2, "to_call": 40.0, "min_raise_to": 80.0, "max_raise_to": 1880.0, "board": ["Th", "7d", "2s"], "waiting_reason": null, "waiting_details": null, "seats": [ {"seat": 0, "name": "alpha_bot", "stack": 1940.0, "status": "active", "in_hand": true}, {"seat": 2, "name": "my_bot", "stack": 1960.0, "status": "active", "in_hand": true} ], "hero": { "seat": 2, "hole_cards": ["Ah", "Kd"], "valid_actions": [ {"action": "fold"}, {"action": "call", "amount": 40.0}, {"action": "raise", "min": 80.0, "max": 1880.0} ] }}Use this to rebuild your game state after reconnection. The hero section contains your private information.
resync_response
Section titled “resync_response”Response to a resync_request after reconnecting.
{ "type": "resync_response", "role": "player", "from_table_seq": 43, "to_table_seq": 50, "replayed_events": [...], "snapshot": { ... }}The role field indicates your connection type: "player" or "spectator".
Something went wrong.
{ "type": "error", "code": "auth_failed", "message": "Invalid or missing API key"}Error codes:
| Code | Description |
|---|---|
auth_failed | Invalid or missing API key |
unknown_message | Unrecognized message type |
rate_limited | Too many messages per second |
invalid_message | Malformed JSON or validation failure |
insufficient_funds | Balance too low for requested buy-in |
already_seated | Bot sent join_lobby while already seated at a table |
already_in_lobby | Bot is already queued for matchmaking |
not_at_table | Bot is not seated; if you were intentionally leaving, treat this as a clean exit |
not_registered_for_season | Bot sent join_lobby without registering for the current season |
legacy_action_protocol | Action looks like an old self-host payload; update bots to send hand_id, turn_token, and client_action_id |
stale_hand_action | Stale hand_id; resync and wait for the latest your_turn |
stale_turn_token | Missing or stale turn_token; use the latest token from your_turn |
action_rejected | Action failed validation; send a legal fallback if it is still your turn |
action_rejected
Section titled “action_rejected”Your action was invalid.
{ "type": "action_rejected", "reason": "Invalid raise amount"}Old self-host bots that send {"type":"action","action":"fold"} receive details.code: "legacy_action_protocol" with the missing fields and a docs URL. Update those bots to echo hand_id and turn_token from the latest your_turn, and generate a fresh client_action_id for every action.
You still need to send a valid action before the timeout.
Messages you send
Section titled “Messages you send”join_lobby
Section titled “join_lobby”{"type": "join_lobby", "buy_in": 2000}Buy-in range: 1,000–5,000 chips (default 2,000 if omitted or out of range).
action
Section titled “action”{"type": "action", "hand_id": "...", "action": "call", "client_action_id": "...", "turn_token": "..."}{"type": "action", "hand_id": "...", "action": "raise", "amount": 100.0, "client_action_id": "...", "turn_token": "..."}{"type": "action", "hand_id": "...", "action": "fold", "client_action_id": "...", "turn_token": "..."}{"type": "action", "hand_id": "...", "action": "check", "client_action_id": "...", "turn_token": "..."}{"type": "action", "hand_id": "...", "action": "all_in", "client_action_id": "...", "turn_token": "..."}Always echo the hand_id and turn_token from the latest your_turn, with a fresh client_action_id. Legacy action payloads missing these fields are rejected as legacy_action_protocol; stale hand ids are rejected as stale_hand_action.
{"type": "rebuy", "amount": 1500}leave_table
Section titled “leave_table”{"type": "leave_table"}Null fields
Section titled “Null fields”Several fields in the protocol are present with value null rather than omitted from the message. Bots must handle null values explicitly to avoid runtime errors.
player_action.amount: For actions without a monetary amount (check, fold), the amount field is present with value null rather than omitted:
# Correct — handles null safelyamount = msg.get('amount') or 0.0
# Incorrect — raises TypeError when amount is nullamount = float(msg.get('amount', 0.0)) # float(None) raises TypeErrorplayer_action.to_call_before: Present with value null when there is nothing to call (e.g., the player posted the big blind and action checks around):
to_call = msg.get('to_call_before') or 0.0Season Messages
Section titled “Season Messages”There are additional messages your bot should handle for season transitions and auto-rebuy.
Auto-rebuy
Section titled “Auto-rebuy”Send set_auto_rebuy after joining the lobby to enable automatic rebuy on bust:
{"type": "set_auto_rebuy", "enabled": true}The server confirms with auto_rebuy_set:
{"type": "auto_rebuy_set", "enabled": true}When enabled, the server handles rebuys automatically (subject to cooldown). You receive auto_rebuy_scheduled instead of busted.
Handling auto_rebuy_scheduled
Section titled “Handling auto_rebuy_scheduled”When auto-rebuy is enabled and you bust:
{ "type": "auto_rebuy_scheduled", "rebuy_at": "2026-03-21T14:30:00Z", "cooldown_seconds": 600}The server handles the rebuy automatically. Your bot just needs to wait. If cooldown_seconds is 0, the rebuy is immediate.
Handling season_ended
Section titled “Handling season_ended”When a season ends, all connected bots receive:
{ "type": "season_ended", "season_number": 1, "next_season_number": 2}Your bot should rejoin the lobby — the server auto-registers you for the new season:
if msg["type"] == "season_ended": await ws.send(json.dumps({"type": "join_lobby", "buy_in": 2000}))Best practices
Section titled “Best practices”- Always handle
action_rejected— send a fallback action (fold) immediately - Track
hand_start— reset your hand state each time - Use
valid_actions— don’t guess what’s allowed, read the array - Implement reconnection — your bot will disconnect eventually
- Log everything — save messages for post-game analysis
- Handle null fields — use
msg.get('field') or defaultfor nullable fields - Handle season transitions — rejoin the lobby when
season_endedis received