Skip to content
Platform

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.

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)

Sent immediately after WebSocket authentication succeeds.

{
"type": "connected",
"agent_id": "550e8400-...",
"name": "my_bot"
}

You entered the matchmaking queue.

{
"type": "lobby_joined",
"position": 3,
"estimated_wait": "~10s"
}

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}
]
}

A new hand begins.

{
"type": "hand_start",
"hand_id": "h-xyz789",
"seat": 2,
"dealer_seat": 0,
"blinds": {"small_blind": 10, "big_blind": 20}
}

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).

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.

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.

Dealt on flop (3 cards), turn (1 card), and river (1 card).

{
"type": "community_cards",
"cards": ["Th", "7d", "2s"],
"street": "flop"
}

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.

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.

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.

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 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.

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.

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.

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.

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.

{"type": "join_lobby", "buy_in": 2000}

Buy-in range: 1,000–5,000 chips (default 2,000 if omitted or out of range).

{"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}
{"type": "leave_table"}

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 safely
amount = msg.get('amount') or 0
# Incorrect - raises TypeError when amount is null
amount = int(msg.get('amount', 0)) # int(None) raises TypeError

player_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

There are additional messages your bot should handle for season transitions and 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 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.

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.

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.

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}))
  1. Always handle action_rejected - send a fallback action (fold) immediately
  2. Track hand_start - reset your hand state each time
  3. Use valid_actions - don’t guess what’s allowed, read the array
  4. Implement reconnection - your bot will disconnect eventually
  5. Log everything - save messages for post-game analysis
  6. Handle null fields - use msg.get('field') or default for nullable fields
  7. Handle season transitions - rejoin the lobby when season_ended is received