Message Handling
Your bot receives JSON messages over WebSocket. Here’s how to handle each one. Gameplay amounts in these messages are integer virtual chips.
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}, {"seat": 2, "name": "my_bot", "stack": 2000} ]}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, "big_blind": 20}}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", "hand_id": "h-xyz789", "valid_actions": [ {"action": "fold"}, {"action": "call", "amount": 20}, {"action": "raise", "min": 40, "max": 2000}, {"action": "all_in", "amount": 2000} ], "pot": 30, "community_cards": ["Th", "7d", "2s"], "players": [ {"seat": 0, "name": "alpha_bot", "stack": 1980}, {"seat": 2, "name": "my_bot", "stack": 1980} ], "min_raise": 40, "max_raise": 2000, "turn_token": "tt-abc123"}Echo this hand_id and turn_token in the action response and generate a fresh
client_action_id. Without all three fields, the server rejects the action.
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, "street": "flop", "stack": 1940, "pot": 90}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, "amount": 60, "hand_description": "Pair of Aces" } ], "pot": 60, "total_pot": 60, "transferable_pot": 60, "payouts": [{"seat": 2, "amount": 60}], "outcome_kind": "win", "final_stacks": {"0": 1940, "2": 2060}, "pot_kind": "transferable", "shown_cards": {"2": ["Ah", "Kd"]}}| Field | Type | Description |
|---|---|---|
final_stacks |
object |
Map of seat number to final stack after the hand |
pot |
int |
Legacy net chips transferred between players |
total_pot |
int |
Gross contested chips across all pots; unmatched returns are excluded |
transferable_pot |
int |
Explicit form of legacy pot |
payouts |
list |
Gross contested-pot award per seat; amounts sum to total_pot |
outcome_kind |
"win" | "split" | "multiple_winners" |
One winner, a true tied split, or different winners across main/side pots |
pot_kind |
string |
Pot type, e.g. "transferable" |
shown_cards |
object? |
Map of seat number to hole cards shown at showdown. Omitted for mucked hands. |
Do not infer that a pot: 0 result had no betting. A complete tie has zero net transfer, but total_pot and payouts still contain the real contested chips. winners[].amount is net profit, so tied winners can correctly have amount: 0. A multiple_winners result is not a tie; it means different seats won different pots.
busted
Section titled “busted”You ran out of chips.
{ "type": "busted", "options": ["rebuy", "leave"]}After the table-removal flow completes, respond with {"type": "rebuy", "amount": 0}
to continue, or {"type": "leave_table"} to exit. The schema requires amount,
but the server ignores its value and applies the configured 1,500-chip grant.
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": 0, "chip_balance": 2000}The rebuy credits the off-table chip_balance; new_stack is 0. Retry
join_lobby to buy in and get seated.
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}{"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”Snapshot of the current table state. Sent after state changes and during resync.
{ "type": "table_state", "hand_id": "h-xyz789", "street": "flop", "dealer_seat": 0, "small_blind": 10, "big_blind": 20, "pot": 120, "actor_seat": 2, "to_call": 40, "min_raise_to": 80, "max_raise_to": 1880, "board": ["Th", "7d", "2s"], "waiting_reason": null, "waiting_details": null, "seats": [ {"seat": 0, "name": "alpha_bot", "stack": 1940, "bet": 0, "status": "active", "in_hand": true, "folded": true, "is_pro": false}, {"seat": 1, "name": null, "stack": 0, "bet": 0, "status": "empty", "is_pro": false}, {"seat": 2, "name": "my_bot", "stack": 1960, "bet": 40, "status": "active", "in_hand": true, "folded": false, "is_pro": true} ], "hero": { "seat": 2, "hole_cards": ["Ah", "Kd"], "valid_actions": [ {"action": "fold"}, {"action": "call", "amount": 40}, {"action": "raise", "min": 80, "max": 1880} ], "turn_token": "tt-abc123" }}Use this to rebuild current game state after reconnection. The hero section contains
private information and is absent for spectators. status describes seat/connection
lifecycle; in_hand means the seat was dealt into the hand, including a player that
later folded. For in-hand seats, folded is the authoritative current fold state.
Preserve a same-hand fold event only when an older snapshot omits that field.
See State Consistency & Reducers and the
complete seat schema.
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".
Apply retained events first for history and hand-local markers, then atomically
replace current table fields with snapshot. Do not install the snapshot first and
then double-apply its preceding events.
When the requesting player is the current actor, snapshot.hero contains the
existing turn_token and legal actions. Use them before the unchanged deadline.
Non-actors receive no token, and spectators receive no private hero state.
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 |
error is the protocol error envelope and always uses top-level code. |
|
action_rejected is a different message type and also always has a top-level stable |
|
code. Branch on it, not the prose reason; details.code mirrors it for |
|
| compatibility. |
action_rejected
Section titled “action_rejected”Your action was invalid.
{ "type": "action_rejected", "code": "invalid_action", "reason": "Invalid raise amount", "details": {"code": "invalid_action"}}Old self-host bots that send {"type":"action","action":"fold"} receive top-level 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 action-authority message (your_turn, or an acting-player resync snapshot), 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, "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 hand_id and turn_token from the latest action-authority message
(your_turn, or an acting-player resync_response.snapshot) and use a fresh
client_action_id. Missing fields are rejected as legacy_action_protocol; stale
hand ids are rejected as stale_hand_action.
{"type": "rebuy", "amount": 0}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
# Incorrect - raises TypeError when amount is nullamount = int(msg.get('amount', 0)) # int(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 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}Handling auto_rebuy_set
Section titled “Handling auto_rebuy_set”The server confirms that the current-season preference was saved:
{"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": 300}The server handles the rebuy automatically. Your bot waits for the credit, then
retries join_lobby. If cooldown_seconds is 0, the first rebuy is immediate;
later cooldowns are 300 seconds for Free or 120 seconds for Pro.
Handling chips_skimmed
Section titled “Handling chips_skimmed”If a future season enables a maximum table stack, the server may move excess chips to the off-table balance after settlement:
{"type": "chips_skimmed", "excess": 500, "new_stack": 5000, "new_balance": 2500}Update both the table stack and season balance. The current production cap is disabled, so this message normally does not appear.
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