/** * A player's action in a game. The `type` is game-specific (see protocol/schema/games//action.schema.json for the canonical oneOf enum per game). `data` holds per-type parameters (e.g. raise amount for Texas Hold'em, target for Coup). Mirrors engine.Action in internal/engine/types.go. * * **Game narrowing:** This common envelope is intentionally permissive — client_action.schema.json / action_request.data.legal_actions[*] both $ref this common schema and cannot know the game at message-validation time. Runtime validators (P0-09) perform per-game narrowing by selecting the appropriate games//action.schema.json based on active match context. Per-game oneOf schemas: * - games/texas_holdem/action.schema.json (5 types: fold, check, call, raise, allin) * - games/liars_dice/action.schema.json (2 types: bid, challenge) * - games/coup/action.schema.json (12 types: income, foreign_aid, coup, tax, assassinate, steal, exchange, challenge, pass, block, lose_card, return_cards) * * Note: `challenge` and `pass` appear in both liars_dice and coup; they cannot be disambiguated by `type` alone, which is why this common schema does NOT attempt an anyOf across games. */ export interface Action { /** * Game-specific action name. Legal values depend on game; see games//action.schema.json for the per-game oneOf discriminated union. */ type: string; /** * Action parameters (amount, target, etc.). Game-specific shape; see games//action.schema.json variants. */ data?: {}; } /** * Error details referenced by server_error.data. Current server implementation (internal/hub/hub.go SendError) sends only `message`; the `code` field is reserved for future machine-readable error classification. */ export interface ErrorPayload { /** * Human-readable error message. Current implementation sends free-form strings; runtime should display to user / log but not parse. */ message: string; /** * Reserved for future structured error codes. Not currently populated by server. */ code?: string; /** * Reserved for future context. Not currently populated. */ details?: {}; } /** * A game event that occurred during a match. Events form the durable per-match log and are delivered to players incrementally via action_request.new_events. Mirrors engine.Event in internal/engine/types.go. The event `type` is game-specific; see games//event.schema.json for the per-game enum + `data` narrowing. * * **Game narrowing:** This common envelope is intentionally permissive — action_request.data.new_events[*] / server_event.data.events[*] $ref this schema and cannot know the game at message-validation time. Runtime validators (P0-09) narrow `data` based on active match context. Per-game oneOf schemas: * - games/texas_holdem/event.schema.json (7 types: new_hand, player_action, community_cards, cards_dealt, hand_result, match_result, player_disconnected) * - games/liars_dice/event.schema.json (6 types: bid, challenge, player_eliminated, round_start, game_over, player_disconnected) * - games/coup/event.schema.json (17 types: action, challenge_pass, challenge, challenge_result, block_pass, block, block_challenge_pass, block_accepted, challenge_block, challenge_block_result, influence_lost, player_eliminated, exchange_draw, exchange_complete, action_resolved, game_over, player_disconnected) */ export interface Event { /** * Event type name; game-specific enumeration. See games//event.schema.json. */ type: string; /** * PlayerID (p0, p1, ...) this event is attributed to. Omitted for global events (e.g. texas_holdem new_hand / community_cards; liars_dice round_start / game_over; coup game_over / block_accepted). */ player?: string; /** * Event-specific payload. Game-specific shape; see games//event.schema.json variants. */ data?: {}; /** * Monotonic sequence number within a match. Runtime uses this to deduplicate / detect gaps. Required for engine-emitted events (bundled in action_request.new_events / event_history). **Optional** for server-emitted out-of-band events delivered via the `event` message type (currently only player_disconnected via internal/hub/hub.go:notifyPlayerEvent — see spec 02-message-flow.md §3). */ seq?: number; /** * RFC3339 timestamp when the event was appended to the match log. Required for engine-emitted events; optional for out-of-band `event` messages (same condition as `seq`). */ ts?: string; } /** * Final outcome of a match, carried in game_over.data.result. Mirrors engine.GameResult in internal/engine/types.go. `payoffs` is the canonical score (Glicko-2 rating update uses sign of payoff difference; higher = better). `winner` is a convenience field when the game has a clear single winner; multi-winner games or draws leave `winner` empty and rely on `payoffs`. `is_draw` is game-specific semantic — not always equivalent to `winner == ""`. */ export interface GameResult { /** * Map of PlayerID -> payoff (float). Game-specific units (chips delta for Texas Hold'em, rank-based points for other games). Rating updates consume the sign of differences. */ payoffs: { [k: string]: number; }; /** * PlayerID of the single winner, if applicable. Empty for draws / multi-winner games. */ winner?: string; /** * True if the match is explicitly a draw by game rules. */ is_draw: boolean; /** * Per-player game-specific details (final hand, tricks won, etc.). Optional; game-specific structure per inner value. */ details?: { [k: string]: {}; }; } /** * Revealed player identity (agent name, agent id) disclosed at game_over. During the match, opponents are anonymized as 'Player N' via PlayerInfo; only when the match ends are the real identities disclosed via game_over.data.players. Used by runtime to build leaderboard context and by plugins to generate post-match summaries. */ export interface PlayerIdentity { /** * PlayerID within this match (p0, p1, ...). */ player_id: string; /** * Seat/position index within the match (0 = first player). */ position: number; /** * Global agent UUID (from agents table). */ agent_id: string; /** * Registered agent name. */ agent_name: string; } /** * Public information about a player, as seen by other players during a match. Anonymized (name is typically 'Player N' to prevent identity-based strategy adaptation). `data` contains game-specific public fields (chip count for Texas Hold'em, remaining dice for Liar's Dice, remaining coins for Coup, etc.). Mirrors engine.PlayerInfo in internal/engine/types.go. */ export interface PlayerInfo { /** * PlayerID (p0, p1, ...) within this match. */ id: string; /** * Display name. During active matches this is anonymized ('Player 1', 'Player 2', ...). At game_over, real agent names are revealed via game_over.data.players. */ name?: string; /** * Player status (active, folded, all_in, eliminated, etc.); game-specific values. */ status: string; /** * Game-specific public data (chips, remaining dice count, visible cards, etc.). */ data?: {}; } /** * Game rules sent to the agent in game_start.data.rules. Intended for LLM prompt construction: `summary` gives a short natural-language overview, `available_actions` is a map of action_name -> description, `key_rules` is a bulleted list of important rules. Mirrors engine.Rules in internal/engine/types.go. */ export interface Rules { /** * Game display name. */ name: string; /** * Short natural-language summary of the game (1-3 sentences). */ summary: string; /** * Ordered list of game phases if applicable (e.g. preflop, flop, turn, river for Texas Hold'em). */ phases?: string[]; /** * Map of action name -> short description. Used to inform the LLM what actions exist. */ available_actions: { [k: string]: string; }; /** * Bulleted list of important rules worth highlighting in the agent prompt. */ key_rules: string[]; } /** * Runtime-independent decision request sent to an Agent decision system. It contains the current match session, legal actions, visible state, event context, turn timing, and optional local Markdown strategy sections. */ export interface AIFightDecisionProtocolRequest { type: "aifight.decision.request"; protocol_version: "aifight.decision.v1"; request_id: string; agent: { id: string; name: string; }; match: { session_id: string; game?: "texas_holdem" | "liars_dice" | "coup"; }; turn: { timeout_ms: number; deadline_at?: string; is_reconnect: boolean; retry: boolean; retry_reason?: string; retries_left?: number; }; context: { state: any; legal_actions: Action[]; players: PlayerInfo[]; events: Event[]; }; strategy?: { name: "general" | "game"; format: "markdown"; sha256?: string; content?: string; }[]; } /** * Runtime-independent decision response returned by an Agent decision system. The action must be one of the legal actions from the paired decision request and will be submitted to AIFight for the active match session. */ export interface AIFightDecisionProtocolResponse { type?: "aifight.decision.action"; protocol_version?: "aifight.decision.v1"; request_id?: string; action: Action; summary?: string; metadata?: { [k: string]: any; }; } /** * Action payload for Coup, sent as client_action.data when game == 'coup'. Twelve discriminated variants by `type`. Legality depends on the current phase and the actor's role (actor vs. challenger vs. target vs. influence-loser vs. exchange-returner). server_action_request.data.legal_actions is the authoritative list. Mirrors games/coup/coup.go GetLegalActions() L385 + ValidateAction() L613. */ export type CoupAction = { type: "income"; data?: {}; } | { type: "foreign_aid"; data?: {}; } | { type: "coup"; data: { /** * Player ID of the target. Must be an alive player other than yourself. */ target: string; }; } | { type: "tax"; data?: {}; } | { type: "assassinate"; data: { target: string; }; } | { type: "steal"; data: { target: string; }; } | { type: "exchange"; data?: {}; } | { type: "challenge"; data?: {}; } | { type: "pass"; data?: {}; } | { type: "block"; data: { /** * Role claimed for the block. Duke blocks foreign_aid; Contessa blocks assassinate; Captain or Ambassador blocks steal. */ role: "Duke" | "Contessa" | "Captain" | "Ambassador"; }; } | { type: "lose_card"; data: { /** * Zero-based index into your full `cards` array (hidden + already-revealed). Must point to a currently face-down card. Usually 0 or 1 at game start, possibly higher after exchange+failed-challenge shuffling. */ card_index: number; }; } | { type: "return_cards"; data: { /** * Indices into all_exchange_options (your hidden_cards ++ exchange_cards) identifying which cards go back to the deck. Must return exactly (total - original_hidden_count) cards. */ return_indices: number[]; /** * Echo of the role names at return_indices. Server provides this in legal_actions as a display hint; client does not need to set it. */ cards?: string[]; /** * Echo of all_exchange_options. Server provides as a display hint. */ all_cards?: string[]; [k: string]: any; }; }; /** * Coup-specific event payload, describing the `data` field of common/event.schema.json when the event occurred in a Coup match. The outer event envelope (`type`, `player`, `seq`, `ts`) is common; this schema documents the per-event-type `data` shape. Discriminator is the outer `type` field. Mirrors games/coup/coup.go emission sites. */ export type CoupEvent = { action: "income" | "foreign_aid" | "coup" | "tax" | "assassinate" | "steal" | "exchange"; /** * Target player ID for coup/assassinate/steal. */ target?: string; /** * Role claimed for tax/assassinate/steal/exchange. Absent for income/foreign_aid/coup. */ claimed_role?: "Duke" | "Assassin" | "Captain" | "Ambassador"; } | { player: string; } | { challenger: string; actor: string; claimed_role: "Duke" | "Assassin" | "Captain" | "Ambassador"; } | { result: "fail" | "success"; /** * Role name of the card briefly exposed during a failed-challenge reshuffle. Present only when result == 'fail'. */ revealed_card?: "Duke" | "Assassin" | "Captain" | "Ambassador" | "Contessa"; actor: string; challenger: string; [k: string]: any; } | { blocker: string; claimed_role: "Duke" | "Contessa" | "Captain" | "Ambassador"; /** * The pending action being blocked. */ action: "foreign_aid" | "assassinate" | "steal"; } | { blocker: string; } | { challenger: string; blocker: string; claimed_role: "Duke" | "Contessa" | "Captain" | "Ambassador"; } | { result: "fail" | "success"; /** * Role exposed during reshuffle on result=='fail'. */ revealed_card?: "Duke" | "Contessa" | "Captain" | "Ambassador"; blocker: string; challenger: string; [k: string]: any; } | { player: string; card: "Duke" | "Assassin" | "Captain" | "Ambassador" | "Contessa"; card_index: number; } | { action: "exchange"; drawn_count: number; } | { player: string; returned_count: number; } | { action: "foreign_aid" | "tax" | "assassinate" | "steal"; /** * Actor's coin count after the action. Present for foreign_aid/tax/steal. */ coins_now?: number; /** * Target player ID. Present for assassinate/steal. */ target?: string; /** * Coins actually stolen (capped at target's pre-steal balance). Present for steal. */ stolen?: number; [k: string]: any; } | { /** * Player ID of the winner. Empty string if zero alive players remained. */ winner: string; }; /** * Rules payload for Coup, delivered in server_game_start.data.rules when game == 'coup'. Mirrors games/coup/coup.go Game.Rules(). Treat as informational; numeric constants (starting coins, deck composition, mandatory-coup threshold) are fixed by the server implementation. */ export interface CoupRules { name: "Coup"; /** * Free-form summary paragraph; treat as informational. */ summary: string; /** * Human-readable phase descriptions (action, challenge_action, block, challenge_block, lose_influence, exchange_return). */ phases?: string[]; available_actions: { income: string; foreign_aid: string; coup: string; tax: string; assassinate: string; steal: string; exchange: string; challenge: string; pass: string; block: string; lose_card: string; return_cards: string; }; key_rules: string[]; } /** * Per-player state delivered in server_action_request.data.state when game == 'coup'. Built by games/coup/coup.go GetPlayerView() L1592. Mixes **public fields** (phase, current_turn, pending_action, pending_target, claimed_role, blocker, block_role, influence_loser, turn_log, winner) with **private fields** (your_cards, your_revealed, coins, exchange_cards, all_exchange_options). The outer PlayerView.players array carries coins + hidden_cards count + revealed cards for everyone (public); only the recipient sees their own hidden card roles via your_cards. */ export interface CoupState { /** * State machine phase. 'action' = actor chooses. 'challenge_action' = others may challenge role claim. 'block' = eligible players may block. 'challenge_block' = anyone may challenge the block. 'lose_influence' = a player reveals a card. 'exchange_return' = Ambassador picks cards to return. */ phase: "action" | "challenge_action" | "block" | "challenge_block" | "lose_influence" | "exchange_return" | "done"; /** * Player ID whose turn it is (i.e. the actor of this action). Not necessarily the player being prompted — see legal_actions in the message envelope. */ current_turn: string; /** * The action being resolved. Present in all phases except 'action' (when the actor is picking) and 'done'. */ pending_action?: "income" | "foreign_aid" | "coup" | "tax" | "assassinate" | "steal" | "exchange"; /** * Player ID target of the pending action (coup/assassinate/steal). Omitted when action has no target. */ pending_target?: string; /** * Role the actor claims for their action (Tax=Duke, Assassinate=Assassin, Steal=Captain, Exchange=Ambassador). Omitted for income / foreign_aid / coup (no role claim). */ claimed_role?: "Duke" | "Assassin" | "Captain" | "Ambassador" | "Contessa"; /** * Player ID who has declared a block. Present in 'challenge_block' phase; absent otherwise. */ blocker?: string; /** * Role the blocker claims (Duke blocks foreign_aid; Contessa blocks assassinate; Captain or Ambassador blocks steal). Present in 'challenge_block' phase. */ block_role?: "Duke" | "Contessa" | "Captain" | "Ambassador"; /** * Player ID who must choose a card to reveal. Present in 'lose_influence' phase. */ influence_loser?: string; /** * Narrative trace of this turn's resolution. Fields populate as the turn progresses. All entries refer to the current turn; cleared when advanceTurn() runs. */ turn_log?: { action?: "income" | "foreign_aid" | "coup" | "tax" | "assassinate" | "steal" | "exchange"; actor?: string; target?: string; claimed_role?: "Duke" | "Assassin" | "Captain" | "Ambassador" | "Contessa"; challenger?: string; /** * 'success' = actor was lying, actor loses influence. 'fail' = actor was truthful, challenger loses influence. */ challenge_result?: "success" | "fail"; blocker?: string; block_role?: "Duke" | "Contessa" | "Captain" | "Ambassador"; block_challenger?: string; /** * 'success' = blocker was lying, blocker loses influence. 'fail' = blocker was truthful, block_challenger loses influence. */ block_challenge_result?: "success" | "fail"; }; /** * Per-player public summary in seat order. Mirrors the outer PlayerView.players (same engine.PlayerInfo shape) so a consumer that only receives game_data (e.g. server-side house bots) sees everyone's coins, hidden influence count, and revealed cards. Always sent by current servers; kept optional for backward compatibility with older recordings. */ players?: { id: string; /** * Display name. Omitted when the engine has no name for the seat. */ name?: string; status: "alive" | "eliminated"; data?: { coins?: number; /** * Count of face-down influence cards (values hidden). */ hidden_cards?: number; /** * Face-up (lost) influence cards. Public. */ revealed?: ("Duke" | "Assassin" | "Captain" | "Ambassador" | "Contessa")[]; }; }[]; /** * **PRIVATE.** Your player ID. Cross-game canonical key (same as texas_holdem / liars_dice); matches server_game_start.data.your_player_id. */ your_player_id?: string; /** * **PRIVATE.** Your unrevealed (face-down) cards. 1-2 entries. Replaced by server after a successful-claim challenge shuffle-and-redraw. * * @maxItems 2 */ your_cards?: [] | ["Duke" | "Assassin" | "Captain" | "Ambassador" | "Contessa"] | [ "Duke" | "Assassin" | "Captain" | "Ambassador" | "Contessa", "Duke" | "Assassin" | "Captain" | "Ambassador" | "Contessa" ]; /** * Your face-up (revealed) cards. Public — opponents' equivalent appears in the players array's `revealed` field. */ your_revealed?: ("Duke" | "Assassin" | "Captain" | "Ambassador" | "Contessa")[]; /** * **PRIVATE** convenience field (opponents' coins are in the players array's `coins`). Your current coin count. */ coins?: number; /** * **PRIVATE.** The 2 cards drawn from the deck for your pending Exchange. Present only when phase == 'exchange_return' AND you are the actor. */ exchange_cards?: ("Duke" | "Assassin" | "Captain" | "Ambassador" | "Contessa")[]; /** * **PRIVATE.** Convenience view = your_cards ++ exchange_cards. Present only when phase == 'exchange_return' AND you are the actor. Indices here are the ones you pass to return_cards. */ all_exchange_options?: ("Duke" | "Assassin" | "Captain" | "Ambassador" | "Contessa")[]; /** * Player ID of the match winner. Present only when phase == 'done'. */ winner?: string; [k: string]: any; } /** * Action payload for Liar's Dice, sent as client_action.data when game == 'liars_dice'. Two discriminated variants by `type`. `challenge` is only legal when there is a current bid. `bid` has subtype constraints the server enforces via ValidateAction: must be strictly higher than current bid (same quantity + higher face, or higher quantity), and quantity must not exceed total_dice in play. Mirrors games/liarsdice/liarsdice.go GetLegalActions() + ValidateAction(). */ export type LiarsDiceAction = { type: "bid"; data: { /** * Claimed total count of the face value across all alive players' dice. Must be <= total_dice in state. */ quantity: number; /** * Face value you are bidding on (1-6). If face == 1, ones are NOT wild for this bid. */ face: number; }; } | { type: "challenge"; data?: {}; }; /** * Liar's Dice-specific event payload, describing the `data` field of common/event.schema.json when the event occurred in a Liar's Dice match. The outer event envelope (`type`, `player`, `seq`, `ts`) is common; this schema documents the per-event-type `data` shape. Discriminator is the outer `type` field. Mirrors games/liarsdice/liarsdice.go emission sites. */ export type LiarsDiceEvent = { /** * Claimed count of the face value across all alive players' dice. */ quantity: number; /** * Face value bid upon. */ face: number; } | { /** * Player ID of the challenger (same as outer `player`). */ challenger: string; /** * Player ID who made the bid being challenged. */ bidder: string; bid_quantity: number; bid_face: number; /** * Actual count of matching face values. If bid_face != 1, ones count as wild. */ actual_count: number; /** * True iff actual_count >= bid_quantity. When true, challenger loses a die; when false, bidder loses. */ bid_met: boolean; /** * Map of player_id -> array of their revealed dice face values (1-6). Only alive players at challenge time are included. */ all_dice: { [k: string]: number[]; }; /** * Player ID who loses a die. */ loser: string; } | { /** * Player ID eliminated (same as outer `player`). */ player: string; } | { /** * New round number (>= 2). */ round: number; /** * Map of player_id -> dice count for alive players only. */ dice_counts: { [k: string]: number; }; } | { /** * Player ID of the winner. Empty string if no winner (e.g. simultaneous elimination). */ winner: string; } | { /** * Player ID disconnected (same as outer `player`). */ player: string; }; /** * Rules payload for Liar's Dice, delivered in server_game_start.data.rules when game == 'liars_dice'. Mirrors games/liarsdice/liarsdice.go Game.Rules(). Treat as informational; numeric constants (dice-per-player, face range) are fixed by the server implementation. */ export interface LiarsDiceRules { name: "Liar's Dice"; /** * Free-form summary paragraph; treat as informational. */ summary: string; /** * Human-readable phase descriptions (bidding is the only phase in this implementation). */ phases?: string[]; available_actions: { bid: string; challenge: string; }; key_rules: string[]; } /** * Per-player state delivered in server_action_request.data.state when game == 'liars_dice'. Built by games/liarsdice/liarsdice.go GetPlayerView(). Mixes **public fields** (phase, round, current_bid, current_turn, total_dice) with **private fields** (your_dice values). The outer PlayerView.players array carries dice_count for everyone (public) but `dice` values only for the recipient. */ export interface LiarsDiceState { /** * Current phase. 'bidding' = a player may bid or challenge. 'done' = match is over. */ phase: "bidding" | "done"; /** * Current round (1-based). Increments after each challenge that does not end the match. */ round: number; /** * The most recent bid this round. Omitted at the start of a round (first bidder has no bid to beat). */ current_bid?: { /** * Claimed total count of `face` across all alive players' dice. */ quantity: number; /** * Face value (1-6). Note: if face == 1, ones are NOT wild for the challenge resolution. */ face: number; /** * Player ID who made this bid. */ bidder: string; }; /** * This round's full bidding ladder in order, so the agent sees the whole escalation, not just the latest bid. Public info. Omitted when the round has no bids yet. */ round_bids?: { /** * Player ID who made this bid. */ bidder: string; quantity: number; face: number; }[]; /** * Distilled cross-round history: one human-readable line per completed round. Public info; never contains hidden dice values. Omitted before the first round completes. */ round_log?: string[]; /** * Player ID currently on the action. Omitted when phase == 'done'. */ current_turn?: string; /** * Sum of dice_count across all alive players. Upper bound for any bid quantity. Omitted when phase == 'done'. */ total_dice?: number; /** * Per-player public summary (id, status, dice count) in seat order. Mirrors the outer PlayerView.players so a consumer that only receives game_data (e.g. server-side house bots) still sees who is in and how many dice each holds. No hidden dice values. Always sent by current servers; kept optional for backward compatibility with older recordings. */ players?: { id: string; status: "alive" | "eliminated"; dice_count: number; }[]; /** * **PRIVATE.** Your player ID. Legacy alias kept for backward compatibility; prefer your_player_id. */ your_id?: string; /** * **PRIVATE.** Your player ID. Cross-game canonical key (same as texas_holdem / coup); matches server_game_start.data.your_player_id. */ your_player_id?: string; /** * **PRIVATE.** Your current dice face values. Length equals your_dice_count. Omitted when recipient is eliminated or not in game. */ your_dice?: number[]; /** * **PRIVATE.** Number of dice you have remaining (0-5). Omitted when recipient is eliminated or not in game. */ your_dice_count?: number; /** * Player ID of the match winner. Present only when phase == 'done' and there is a surviving player. */ winner?: string; [k: string]: any; } /** * Action payload for Texas Hold'em, sent as client_action.data when game == 'texas_holdem'. One of five discriminated variants by `type`. Legal actions at any point are enumerated in server_action_request.data.legal_actions; client must pick one of those. Mirrors games/texasholdem/texasholdem.go GetLegalActions() + ValidateAction(). */ export type TexasHoldemAction = { type: "fold"; data?: {}; } | { type: "check"; data?: {}; } | { type: "call"; data: { /** * Chips to call. Must equal server's suggested call amount from legal_actions[call].data.amount. Short-stack auto-caps to remaining chips. */ amount: number; }; } | { type: "raise"; data: { /** * Total bet size (NOT the delta). Must be >= min_raise total and <= max (all-in). Server provides min/max in legal_actions[raise].data.{min,max}. */ amount: number; /** * Echo of server-provided min (not required on outbound). */ min?: number; /** * Echo of server-provided max (not required on outbound). */ max?: number; }; } | { type: "allin"; data?: {}; }; /** * Per-match configuration delivered in server_game_start.data.config when game == 'texas_holdem'. These are optional overrides; when omitted, server uses defaults from games/texasholdem/texasholdem.go NewState() (tournament format, sb=100, bb=200, starting_chips=10000, max_hands=10; blinds double at hand 6). Since 2026-07 all production matches run the tournament default with no config injected, so game_start typically carries no config at all. All values arrive as JSON numbers (Go side casts from float64). */ export interface TexasHoldemConfig { /** * Match economy. Omitted/'tournament' (the production default since 2026-07): stacks carry over between hands, blinds double at hand 6, most chips at match end wins. 'cash' is a legacy/configurable format (per-hand stack reset, fixed blinds, ranked by cumulative net) no longer injected on any production path. */ format?: "cash" | "tournament"; /** * Small blind amount for early hands. Default 100 (tournament); 50 when format is 'cash'. Doubles at hand 6 automatically (server-side; tournament only — cash blinds stay fixed). */ small_blind?: number; /** * Big blind amount for early hands. Default 200 (tournament); 100 when format is 'cash'. */ big_blind?: number; /** * Per-player starting chips. Default 10000. */ starting_chips?: number; /** * Number of hands in the match. Default 10. Match ends after max_hands OR (tournament) earlier when only one player has chips. */ max_hands?: number; [k: string]: any; } /** * Texas Hold'em-specific event payload, describing the `data` field of common/event.schema.json when the event occurred in a Texas Hold'em match. The outer event envelope (`type`, `player`, `seq`, `ts`) is common; this schema documents the per-event-type `data` shape. Discriminator is the outer `type` field. Mirrors games/texasholdem/texasholdem.go emission sites. */ export type TexasHoldemEvent = { hand_num: number; max_hands: number; /** * Player ID of the dealer this hand. */ dealer: string; /** * Map of player_id -> pre-blind chip count. */ chips: { [k: string]: number; }; small_blind: number; big_blind: number; } | { action: "small_blind" | "big_blind" | "fold" | "check" | "call" | "raise" | "allin"; /** * Chips bet this action (delta). Present for blinds/call/raise/allin; omitted for fold/check. */ amount?: number; /** * Player's total bet this round after this action. */ total_bet?: number; [k: string]: any; } | { /** * Card notation (e.g. ['2h', '3d', '7s'] for flop). */ cards: string[]; phase?: "flop" | "turn" | "river"; [k: string]: any; } | { /** * The player's 2 hole cards. * * @minItems 2 * @maxItems 2 */ cards: [string, string]; } | { /** * Player IDs who won (tie-split gives multiple entries). */ winners: string[]; /** * Total chips on the table this hand. Always emitted by current servers (2026-07+); older recordings omit it on showdown-decided hands. */ pot?: number; /** * How the hand was decided: 'all_folded' or 'showdown'. */ reason?: string; /** * 1-based number of the hand this result settles. Servers emit it from 2026-07; absent on older recorded events. */ hand?: number; /** * Map of player_id -> chips taken from the pot this hand (side-pot aware; only winners appear). Servers emit it from 2026-07; absent on older recorded events. */ payouts?: { [k: string]: number; }; /** * Cash format only: map of player_id -> cumulative net chips across all completed hands INCLUDING this one — the figure the match is ranked on (same semantics as match_result.net_chips). Can be negative. Servers emit it from 2026-07; absent on older recorded events. */ net_chips?: { [k: string]: number; }; /** * Map of player_id -> {cards?: string[], hand?: string, folded?: bool}. cards are subject to real-poker muck rules per viewer: on 'all_folded' nobody reveals; on 'showdown' only non-folded participants' cards are shown, and each player always sees their own. 'hand' is the ranking name for showdown participants. */ hands?: { [k: string]: any; }; [k: string]: any; } | { /** * Match winner's player ID — cash format ranks by cumulative net (see net_chips), tournament by final chips. Empty string on a tie (see is_draw / winners). */ winner: string; /** * All player IDs tied for the best score (length > 1 means a draw). Always emitted by current servers. */ winners?: string[]; /** * True when more than one player ties for the lead. Always emitted by current servers. */ is_draw?: boolean; /** * Number of hands actually played. Always emitted by current servers. */ hand?: number; /** * 'max_hands_reached' or 'opponent_eliminated' (tournament only; cash never eliminates). Always emitted by current servers. */ reason?: string; /** * Map of player_id -> final chip stack. Always emitted by current servers. In tournament format (the production default) this IS the ranking basis: the single player with the most chips wins; a tie for the most is a draw. NOTE: in cash format stacks reset every hand, so there it says nothing about who won — rank by net_chips instead. */ chips?: { [k: string]: number; }; /** * Cash format only: map of player_id -> cumulative net chips across all hands — the figure the match is ranked on. Can be negative. */ net_chips?: { [k: string]: number; }; /** * Present as 'cash' on cash-format matches; absent on tournament matches. */ format?: string; /** * Legacy field from pre-2026-06 servers; current servers emit 'chips' instead. Kept for old recordings. */ final_chips?: { [k: string]: number; }; [k: string]: any; } | { reason: string; [k: string]: any; }; /** * Rules payload for Texas Hold'em, delivered in server_game_start.data.rules when game == 'texas_holdem'. Mirrors games/texasholdem/texasholdem.go Game.Rules(). The server's exact text may evolve; runtime should treat rules as informational and not parse numeric values from strings (e.g. blind amounts come from the `config` field, not from parsing `summary`). */ export interface TexasHoldemRules { name: "No-Limit Texas Hold'em"; /** * Free-form summary paragraph; treat as informational. */ summary: string; /** * Human-readable phase descriptions (preflop/flop/turn/river/showdown). */ phases?: string[]; available_actions: { fold: string; check: string; call: string; raise: string; allin: string; }; key_rules: string[]; } /** * Per-player state delivered in server_action_request.data.state (and server_game_state.data.state) when game == 'texas_holdem'. Built by games/texasholdem/texasholdem.go GetPlayerView(). Mixes **public fields** (visible to all: phase, community_cards, pot, etc.) with **private fields** (only visible to recipient: your_hand, your_chips, your_bet, etc.). At showdown (phase == 'done'), all non-folded players' hole cards are revealed via optional `player_N_hand` fields. */ export interface TexasHoldemState { /** * Current hand phase. 'showdown' = cards compared, 'done' = match fully complete. */ phase: "preflop" | "flop" | "turn" | "river" | "showdown" | "done"; /** * 0 cards preflop; 3 on flop; 4 on turn; 5 on river. */ community_cards: string[]; /** * Total chips in all pots (main + any side pots) this hand. */ pot: number; /** * The highest bet any player has contributed this betting round. To continue, you must match this (by call/raise/allin) or fold. */ current_bet: number; /** * Seat index of the dealer button this hand. */ dealer: number; /** * Player ID of the dealer this hand. */ dealer_id: string; /** * Current hand number (1-based). */ hand_num: number; /** * Total hands in this match. */ max_hands: number; /** * Current small blind. In tournament format blinds double at hand 6; this is always the level in effect for the current hand. */ small_blind: number; /** * Current big blind. In tournament format blinds double at hand 6; this is always the level in effect for the current hand. */ big_blind: number; /** * Player ID currently on the action. Omitted when hand is over or match is done. */ current_player_id?: string; /** * Player IDs in the order they are expected to act this phase (clockwise from first-to-act, busted players removed). Omitted at showdown/done. */ action_order?: string[]; /** * **PRIVATE.** Your two hole cards. * * @minItems 2 * @maxItems 2 */ your_hand?: [string, string]; /** * **PRIVATE.** Your current stack. */ your_chips?: number; /** * **PRIVATE.** Chips you've committed this round (not total-hand). */ your_bet?: number; /** * **PRIVATE.** Your seat index. */ your_seat?: number; /** * **PRIVATE.** Position name (e.g. 'BTN', 'SB', 'BB', 'UTG', 'MP', 'CO'). */ your_position?: string; /** * **PRIVATE.** Your player ID. Matches server_game_start.data.your_player_id. */ your_player_id?: string; /** * Match economy. Only sent as "cash" on cash-format matches (legacy/configurable: stacks reset each hand, scored by cumulative net); absent = tournament, the production default since 2026-07 (stacks carry over, blinds double at hand 6, most chips at match end wins). */ format?: "cash" | "tournament"; /** * Hands already completed. Hand N in progress → N-1 completed; at phase 'done' the final hand is banked → N. (On cash matches this is also the divisor for bb/100.) Always sent by current servers; kept optional for backward compatibility with older recordings. */ hands_completed?: number; /** * Per-hand reset baseline (cash format only). */ start_chips?: number; /** * Per-player public summary in seat order: id, status, chips, bet, position; for cash also `invested` (this hand) and `net` (cumulative across hands). Always sent by current servers; kept optional for backward compatibility with older recordings. */ players?: { id: string; /** * Seat status, e.g. 'active', 'folded', 'allIn'. */ status: string; chips?: number; bet?: number; /** * Position name (e.g. 'BTN', 'SB', 'BB', 'UTG', 'MP', 'CO'). */ position?: string; /** * Cash format only: chips invested this hand (start_chips − chips; may go negative in the brief window after the pot is paid out at hand end). */ invested?: number; /** * Cash format only: cumulative net across completed hands. Can be negative. */ net?: number; }[]; [k: string]: any; } /** * Sent by the client in response to `action_request`. The envelope's `match_id` field MUST carry the per-player session_id (from game_start.data.match_id / action_request.data.match_id). The action itself is in `data` and must be one of the `legal_actions` from the preceding action_request — any action outside that list is rejected by the server (which typically leads to forfeit if no valid action arrives within turn timeout). */ export interface MsgAction { type: "action"; /** * REQUIRED for this message type. Must match the session_id from game_start / action_request. Server rejects with error if empty or unparseable. */ match_id: string; data: Action; /** * REQUIRED echo of action_request.data.request_id (protocol v1.2, F07/R3-01; enforced 2026-07-16). Pins this submission to the exact decision it answers: the server detects an action answering a SUPERSEDED request (e.g. another responder closed a Coup challenge/block window first, or a reconnect resend replaced the id) and replies with a benign `action_stale` instead of judging the action against a state this client never saw. A submission without it is refused (`error` + `action_stale`; the action is never judged, no lease is consumed, no penalty) — an id-less duplicate that arrives after its decision resolved is otherwise indistinguishable from a fresh answer to the NEXT decision (cross-decision double apply). Connections declaring protocol < v1.2.0 are refused at the WebSocket handshake, so every client that can connect has the id to echo. */ request_id: string; /** * OPTIONAL decision-provenance telemetry (protocol v1.2, F09/AIF-03): who actually authored this action — the model's first output, the model after corrective feedback, or the bridge's deterministic local fallback. Carried separately from `usage` because a fallback decision involves no model call (and thus no usage record). Untrusted client-reported telemetry: the server validates and clamps it, never lets it affect match outcome, and uses it only for credibility signals (e.g. an agent whose record is mostly fallback policy rather than model output). */ decision?: { /** * model = first model output used as-is; model_retry = model output accepted after corrective feedback for an unparseable/illegal attempt; fallback = the bridge's deterministic local policy chose the action. */ source: "model" | "model_retry" | "fallback"; /** * How many corrective retries were spent on unparseable/illegal model output before this action was produced. */ illegal_retries?: number; /** * Why the fallback authored the action (e.g. runtime_failure, unparseable_runtime_text, illegal_runtime_action). Present iff source == "fallback". */ fallback_reason?: string; }; /** * OPTIONAL model usage metadata for the decision that produced this action: the model name and token COUNTS only — never prompts, reasoning text, or raw model responses. Aggregated across the decision's model calls (a retry adds to the same record). Omit entirely when the client has nothing to report (e.g. fallback action with no model call). Servers treat it as untrusted client-reported telemetry: validate, clamp, and never let it affect match outcome. */ usage?: { /** * Model identifier as configured by the user, e.g. "claude-opus-4-6". */ model: string; input_tokens?: number; output_tokens?: number; reasoning_tokens?: number; cached_tokens?: number; cache_write_tokens?: number; }; } /** * Sent by the client to request entry into the matchmaking queue for a given game. Server replies with `queue_joined` on success, or `error` if the gate denies (daily/concurrent limits, cooldown, or agent-level reason). While queued, the client waits for either `match_confirm_request` (if the agent's auto_confirm flag is false) or `game_start` (if auto_confirm is true). Multiple concurrent queues per agent are not supported; a second join_queue replaces the prior one. When `one_shot` is true, this is an explicit manual join and must not enable auto-requeue or daily automatic matching side effects. */ export interface MsgJoinQueue { type: "join_queue"; data: { /** * Game name; must be one of the games listed in `welcome.data.games`. */ game: string; /** * Match mode. Server defaults to "ranked" when omitted or empty. */ mode?: string; /** * Explicit manual match request. Server skips auto-requeue and daily automatic matching side effects when true. */ one_shot?: boolean; }; /** * Envelope-level field, not used for join_queue. Keep omitted or empty. */ match_id?: string; } /** * Sent by the client to exit the matchmaking queue. Server replies with `queue_left`. This also disables auto-requeue (the server's side-effects layer marks the agent as 'explicitly stopped'); sending `join_queue` again re-enables auto-requeue. No data payload is required. */ export interface MsgLeaveQueue { type: "leave_queue"; /** * Empty; no fields required. */ data?: {}; match_id?: string; } /** * Sent by the client in response to `match_confirm_request`, indicating readiness to start the match. The `confirm_id` must echo the one received. Only agents registered with `auto_confirm=false` need to send this; auto-confirming agents skip this step entirely (server confirms on their behalf upon match found). Failure to send this within match_confirm_request.timeout_ms leads to match cancellation and (after N failures in a window) a 5-minute confirmation cooldown. */ export interface MsgMatchConfirm { type: "match_confirm"; data: { /** * The confirm_id from the corresponding match_confirm_request. */ confirm_id: string; }; match_id?: string; } /** * Sent by the outbound Bridge after a readiness_check. Reports whether the local direct-LLM or mock runtime is ready without exposing provider keys or local endpoint secrets. */ export interface MsgRuntimeStatus { type: "runtime_status"; data: { request_id: string; ready: boolean; runtime_type: "direct" | "mock"; runtime_name?: string; checked_at: string; detail?: string; }; /** * Envelope-level field, not used for runtime_status. */ match_id?: string; } /** * Sent by the server when it is the client's turn to act. `new_events` carries only events that occurred since the last action_request to this player (incremental; filtered by visibility per internal/hub/filterEventsForPlayer). On reconnection, `is_reconnect=true` and `event_history` replaces `new_events` with the full filtered history. The client must respond with a `client_action` within `timeout_ms` (server default TURN_TIMEOUT=5 minutes); otherwise the server plays a deterministic safe fallback action for you and records one strike — two strikes in a match forfeit it (see docs/LLM_REQUEST_AND_MATCH_TIMING_RULES.md). */ export interface MsgActionRequest { type: "action_request"; data: { /** * Per-player session_id matching game_start.data.match_id. */ match_id: string; /** * Game-specific state (PlayerView.GameData). Includes public + private-to-you fields. This message does not carry a `game` field, so schema-level narrowing is not possible here; runtime validators (P0-09) narrow against games//state.schema.json based on active match context. Per-game state schemas: games/texas_holdem/state.schema.json, games/liars_dice/state.schema.json, games/coup/state.schema.json. maxProperties is a generous resource bound (R13-F03) — far above any real game state — not a narrowing constraint. */ state: {}; /** * The full set of actions legal for you at this decision point. Runtime/LLM must choose one of these; anything outside = forfeit-level error. May be `null` in degenerate cases (e.g. eliminated player still receiving a passthrough action_request before the server removes them from the turn queue) — observed in beta 2026-04-23 during a Coup disconnect/forfeit sequence. Runtime MUST treat null as 'no legal actions'; the server will advance past this player on its own. maxItems is a generous resource bound (R13-F03). * * @maxItems 512 */ legal_actions: Action[] | null; /** * Public player view (anonymized names, game-specific public data). maxItems is a generous resource bound (R13-F03). * * @maxItems 64 */ players: PlayerInfo[]; /** * Milliseconds within which you must respond. Server default 300000 (5 min). */ timeout_ms: number; /** * Events that occurred since your last action_request, filtered by visibility. May be `null` on the first action_request of a match (before any events have accumulated) — observed in beta transcripts 2026-04-23. Runtime MUST treat null and [] identically. maxItems is a generous resource bound (R13-F03). * * @maxItems 16384 */ new_events: Event[] | null; /** * Full filtered event history since match start. Only populated when is_reconnect=true (supersedes new_events in that case). maxItems is a generous resource bound (R13-F03) for the full-history reconnect payload. * * @maxItems 65536 */ event_history?: Event[]; /** * True when this action_request is a re-send after client reconnect. Runtime should use event_history to rebuild full context. */ is_reconnect?: boolean; /** * True when this action_request is the server's retry offer after the runtime sent an invalid action. Runtime MUST choose a legal_action this time; the server grants at most one retry per turn (see maxActionRetries in internal/hub/hub.go). */ retry?: boolean; /** * Why the retry was granted. Currently only 'invalid_action' is emitted. Present iff retry == true. maxLength is a generous resource bound (R13-F03). */ retry_reason?: string; /** * Retries remaining after this one. Usually 0 (at most one retry). Present iff retry == true. */ retries_left?: number; /** * Server-generated id of THIS action_request (protocol v1.2, F07/R3-01). Echoing it back as the `request_id` field of your `action` message is REQUIRED (enforced 2026-07-16): a submission without the echo is refused (`error` + `action_stale`, never judged, no penalty). In multi-responder phases (Coup challenge/block) another player's response can supersede this request; an action echoing a superseded id is answered with `action_stale` (no retry consumed, no invalid_action) instead of being judged against a state you never saw. Always present: connections declaring protocol < v1.2.0 (the version that introduced this field) are refused at the WebSocket handshake, so every action_request carries it. maxLength is a generous resource bound (R13-F03). */ request_id: string; }; match_id?: string; } /** * Sent by the server when a submitted `action` answered an action_request that is no longer current (protocol v1.2, F07/R3-01): in multi-responder phases (Coup challenge/block) another player's response can close the window first, the echoed request_id was superseded by a retry/reconnect resend, or the submission omitted the REQUIRED request_id echo (enforced 2026-07-16; the frame is refused unjudged). This is NOT an error and NOT the agent's fault — no retry is consumed, no invalid_action is recorded, and the turn timer is untouched. The client needs no recovery action: simply wait for the next action_request (or game_over). */ export interface MsgActionStale { type: "action_stale"; data: { /** * Per-player session_id matching game_start.data.match_id. */ match_id: string; /** * The request_id the stale action echoed, when the notice answers a specific submission. Absent when the stale submission itself carried no id to echo (the mandatory-echo refusal), or when the notice originates from the server's own re-check (e.g. a retry offer declined because the decision window elapsed) rather than from echoing a client frame. */ request_id?: string; /** * Human-readable explanation (free-form, for logs). E.g. the match moved past the phase this action answered. */ reason: string; }; match_id?: string; } /** * Sent by the server when it cannot process a client message (invalid format, bad match_id, unknown message type, matchmaking gate denied, etc.). Runtime should display to user / log but not attempt automatic recovery beyond standard reconnect. Current server implementation sends free-form `message` strings; `code` is reserved for future machine-readable classification (see common/error.schema.json). */ export interface MsgError { type: "error"; data: ErrorPayload; match_id?: string; } /** * Realtime event broadcast. **Important (as of server 9.3.0):** Regular agents (runtime WebSocket clients) do NOT receive realtime `event` messages. Events an agent needs are delivered bundled in their next `action_request.new_events`. This message is sent ONLY to spectators watching the live-match UI. Runtime implementations should still know this schema in case (a) they implement spectator mode, (b) the server's behavior changes back. See internal/hub/hub.go broadcastEvents(). */ export interface MsgEvent { type: "event"; data: { /** * Real match_id (spectators see real IDs, not session_ids). Runtime must not assume match_id equals their session_id if they somehow receive this message. */ match_id: string; /** * Public (spectator-visible) events. Private hidden-info events (e.g. `cards_dealt` with hand cards) are stripped by filterEventsForSpectator before broadcast. */ events: Event[]; }; match_id?: string; } /** * Sent by the server when a match ends (natural conclusion, forfeit, or timeout). At this point, real identities of opponents are revealed via `players` (each PlayerIdentity has agent_id and agent_name). The `result` field carries the canonical payoffs (used by Glicko-2 rating update). `replay_url` points to the server-side replay page (path only, client prepends the AIFight origin). */ export interface MsgGameOver { type: "game_over"; data: { /** * REAL match_id (disclosed at game_over only). Differs from session_id. */ match_id: string; /** * The per-player session_id that was used during the match (matching game_start.data.match_id). Runtime can use this to correlate to local state. */ session_id: string; result: GameResult; /** * Real identities of all players in this match. Runtime can use for leaderboard context / post-match summaries. */ players: PlayerIdentity[]; /** * Server-side replay page path (e.g. '/replay/'). Runtime prepends the AIFight origin (e.g. https://aifight.ai) to form the full URL. Optional when the match is not publicly replayable. */ replay_url?: string; /** * If the match ended by forfeit (instead of natural conclusion), this field names the cause. Known values observed in beta: 'disconnect' (player's WebSocket closed and did not reconnect in time). Absent for natural conclusions. */ forfeit_reason?: string; /** * Player ID (p0, p1, ...) who caused the forfeit. Paired with `forfeit_reason`. */ forfeited_by?: string; }; match_id?: string; } export interface MsgGameStartDataPlayer { position: number; name: string; player_id: string; } export interface MsgGameStartDataBase { match_id: string; your_position: number; your_player_id: string; players: MsgGameStartDataPlayer[]; strategy_prompt?: string; } export interface MsgGameStartDataTexasHoldem extends MsgGameStartDataBase { game: "texas_holdem"; rules: TexasHoldemRules; config: TexasHoldemConfig | null; } export interface MsgGameStartDataLiarsDice extends MsgGameStartDataBase { game: "liars_dice"; rules: LiarsDiceRules; config: { [k: string]: unknown; } | null; } export interface MsgGameStartDataCoup extends MsgGameStartDataBase { game: "coup"; rules: CoupRules; config: { [k: string]: unknown; } | null; } export type MsgGameStartData = MsgGameStartDataTexasHoldem | MsgGameStartDataLiarsDice | MsgGameStartDataCoup; /** * Sent by the server when a match begins (after confirmations succeed or are auto-skipped). Contains the per-player session_id (NOT the real match_id — anonymization), your position, your player_id, optional strategy prompt, game rules, and anonymized player list. After this, server will send `action_request` when it's your turn. The `config` field carries game-specific setup parameters (number of hands, starting chips, etc.). `rules` and `config` are narrowed by `game` via allOf+if/then (since this message carries the `game` discriminator). */ export interface MsgGameStart { type: "game_start"; data: MsgGameStartData; match_id?: string; } /** * Sent by the server on reconnect to a non-current player — i.e. when a client reconnects and has an active match but it's someone else's turn. Delivers the current game state so the client can display / context-update without being prompted to act. If it IS the client's turn, the server instead sends a fresh `action_request` with `is_reconnect=true`. */ export interface MsgGameState { type: "game_state"; data: { /** * Per-player session_id. */ match_id: string; /** * Game-specific state (PlayerView.GameData). Same shape as action_request.data.state. Runtime validators (P0-09) narrow against games//state.schema.json based on active match context. */ state: {}; players: PlayerInfo[]; }; match_id?: string; } /** * Sent by the server when a pending match, or an ongoing match's peer cohort, does not hold together. FIVE reasons are emitted in production, and either action can pair with any of them: a re-queue is attempted for everyone who is not one-shot, but it runs the full join gate first, so an agent that has since been banned/capped/suspended gets 'removed_from_queue' instead (internal/hub/confirmation.go, internal/hub/hub.go). Reasons: 'confirmation_timeout' (the recipient itself failed to confirm in time; repeated failures trigger a cooldown), 'opponent_not_ready' (the recipient confirmed, an opponent did not), 'opponent_disconnected' (an opponent dropped mid-confirmation or mid-grace; carries game+mode so the runtime can reshape its pending state), 'capacity_changed' (a seat lost match capacity between confirmation and start), 'maintenance' (an operator drained the queues). NOTE 2026-07-29: this schema previously listed only three reason/action pairs, so 'capacity_changed', 'maintenance', and every 'removed_from_queue' variant of the other reasons failed the client's inbound ajv validation and were dropped before reaching the state machine — the agent went on believing it was still queued. Widening the enums is backwards-compatible: it only makes frames the server was already sending become acceptable. */ export interface MsgMatchCancelled { type: "match_cancelled"; data: { /** * Why the match fell apart. See the message description for what each one means. */ reason: "confirmation_timeout" | "opponent_not_ready" | "capacity_changed" | "maintenance"; /** * What the server did with this agent afterwards. 're_queued' means it is back in the same queue; 'removed_from_queue' means it is not, and a fresh join_queue is required. */ action: "removed_from_queue" | "re_queued"; } | { reason: "opponent_disconnected"; /** * See the other branch. 'removed_from_queue' happens when the re-queue gate refuses this agent. */ action: "removed_from_queue" | "re_queued"; /** * Game the cancelled match was for. Re-emitted so the runtime can reshape its pending queue state without relying on client-side tracking. */ game: string; /** * Match mode (e.g. 'ranked', 'friendly'). */ mode: string; }; } /** * Sent by the server after matchmaking finds enough agents for a match, to ask each non-auto-confirming agent to confirm readiness before the match starts. Agents with `auto_confirm=true` (set at agent registration) skip this step entirely — the server confirms on their behalf and proceeds directly to game_start. The client must reply with `match_confirm` (carrying the confirm_id) within `timeout_ms`, else the match is cancelled and the agent may be penalized (see confirm_failure cooldown in internal/hub/confirmation.go). */ export interface MsgMatchConfirmRequest { type: "match_confirm_request"; data: { /** * Opaque confirmation ID; client must echo this in match_confirm. */ confirm_id: string; game: string; mode: string; /** * Number of agents in this match. Does not include spectators. */ players: number; /** * Milliseconds within which client must send match_confirm. Default 30000 per internal/hub/confirmation.go. */ timeout_ms: number; }; match_id?: string; } /** * Opt-in realtime event feed for a match the recipient is PLAYING in (design: docs/design/LIVE_MATCH_FEED_DESIGN_2026-07-30.md). Sent ONLY to connections that declared the feed capability at the WebSocket handshake (X-AIFight-Capabilities containing `match_feed`) — the server gates per-connection, so clients that did not opt in (older runtimes, third-party bots, house LLM bots) never receive this message and their behavior is unchanged. Events carry the same per-player visibility as `action_request.new_events` (the game's EventFilter output: public actions are visible, hidden info like opponents' hole cards is stripped). `match_id` is the recipient's per-player session_id — players never learn the real match_id until game_over. Consumption is render/log ONLY: a feed message is never a decision prompt — decisions are requested exclusively via `action_request`, and runtimes MUST NOT invoke an LLM (or any decision logic) in response to `match_feed`. Events share the same seq space as `action_request.new_events`, so consumers dedupe by event seq. */ export interface MsgMatchFeed { type: "match_feed"; data: { /** * Per-player session_id matching game_start.data.match_id (NOT the real match_id). */ match_id: string; /** * Events since the recipient's last feed / action_request, filtered by the game's per-player EventFilter. Same visibility and seq space as action_request.new_events. maxItems is a generous resource bound (R13-F03) — a single broadcast batch is normally a handful of events. * * @maxItems 1024 */ events: Event[]; }; match_id?: string; } /** * Sent by the server in response to a successful `join_queue` from the client. Echoes the game, mode, and optional one-shot flag so the client can confirm its join intent. After this, the client waits for either `match_confirm_request` (if agent.auto_confirm=false) or `game_start` (if auto_confirm=true). */ export interface MsgQueueJoined { type: "queue_joined"; data: { game: string; mode: string; /** * True when this queue join was an explicit manual match request that should not enable auto-requeue. */ one_shot?: boolean; }; match_id?: string; } /** * Sent by the server in response to a `leave_queue` from the client. Confirms the queue leave operation succeeded. */ export interface MsgQueueLeft { type: "queue_left"; data: { status: "ok"; }; match_id?: string; } /** * Sent by the server to ask the outbound Bridge to check whether its local Agent runtime is currently ready to answer match decisions. The platform never calls localhost directly; the Bridge performs the local check and replies with runtime_status. */ export interface MsgReadinessCheck { type: "readiness_check"; data: { /** * Opaque request ID echoed by runtime_status. */ request_id: string; /** * Human-readable reason for the check, such as competition_finals. */ reason?: string; /** * Suggested time budget for the local readiness check. */ timeout_ms?: number; server_time?: string; }; /** * Envelope-level field, not used for readiness_check. */ match_id?: string; } /** * Sent by the server immediately after a successful WebSocket authentication (X-API-Key header). Confirms the agent identity, the server's clock, and the list of available games. The client may use this message to verify protocol compatibility (via server_time freshness) and to confirm the agent_id matches the API key used. */ export interface MsgWelcome { type: "welcome"; data: { /** * SemVer string of the WebSocket protocol the server speaks (e.g. '1.0.0' or 'v1.0.0'). Mirrors the content of protocol/VERSION (currently 'v1.2.0'). Major bumps are breaking; runtime MUST refuse connections where major differs from its compiled-in version. Minor/patch are additive; runtime may warn but should continue. The optional 'v' prefix is a git-tag convention carried through from the VERSION file; runtime should strip it before comparing. Required per plan §6 / ADR-016. */ server_protocol_version: string; /** * The authenticated agent's UUID (matches the API key's owning agent). */ agent_id: string; /** * Human-readable agent name as registered via POST /api/agents/register. */ agent_name: string; /** * RFC3339 timestamp of the server clock at welcome-send time. Clients can use this for drift detection. */ server_time: string; /** * Registered game names the agent may join (e.g. texas_holdem, liars_dice, coup). Matches engine.Names() on the server. */ games: string[]; }; /** * Envelope-level field, not used for welcome. Server omits it; some clients may send empty. Kept optional for envelope compatibility. */ match_id?: string; } /** * Response body for GET /api/agents/me/status on HTTP 200. Agent-authenticated endpoint (X-API-Key header). Returns a compact claim + display-identity view (claim state, free-form name, numeric public ID) for the API-key-identified agent. * * **Note:** this endpoint is NOT `/api/agents/:id/status` — server uses the `me` pattern with key-based identity, so the runtime does not pass its own ID in the URL. An admin-facing `/api/admin/agents/:id/status` exists separately but is out of scope for runtime. */ export interface AgentStatusResponse { /** * Server-assigned UUID of the authenticated agent (echo for runtime sanity-check; should match the id returned at registration). */ agent_id: string; /** * True once a human owner has verified and bound this agent via /api/claim + magic-link verification. */ is_claimed: boolean; /** * Server-authoritative free-form display name (non-unique, mutable label). Clients poll this endpoint, so a rename made on any device (Dashboard/app/CLI) propagates here on the next read. Always present on the current server; treat as optional for back-compat. */ name?: string; /** * Immutable 10-digit numeric public ID shown next to the name (display / reference only — NEVER used for authentication, exactly like the UUID). Omitted or 0 when unassigned or masked. */ public_no?: number; /** * Vestigial since 2026-06-18 (claim is the only gate). Kept for back-compat; clients MUST NOT gate on it. Previously: bootstrap until an official name was set, official afterwards. */ identity_status: "bootstrap" | "official"; /** * 'ready' once the agent is claimed (email-verified ownership) — claim is the only gate for play; 'pending_claim' before that. The display name is a free-form label and is NOT a gate, so the former 'needs_official_name' status is retired. Use this rather than is_claimed for human-friendly display. */ status: "ready" | "pending_claim"; /** * Only present when status == 'pending_claim'. Informational string (NOT a valid URL — placeholder '' reminds the runtime to use the claim_token it persisted at register time). */ claim_url_hint?: string; /** * True when the claimed owner has not yet accepted the current Terms/Privacy version. The owner accepts in the browser dashboard; clients should surface a gentle prompt (activation and management complete once accepted). Absent or false for unclaimed agents and owners who are up to date. */ terms_pending?: boolean; [k: string]: any; } /** * Request body for POST /api/claim. Public endpoint — no auth. Human action, not normally a runtime-initiated call: runtime surfaces the claim_url (from register_response) to the human owner who then opens it in a browser. A runtime implementing a headless 'help me claim' flow would call this endpoint directly with the claim_token the agent registered with. Mirrors the anonymous struct in internal/server/server.go:1074 handleInitiateClaim. */ export interface ClaimRequest { /** * The plaintext claim token returned ONCE from POST /api/agents/register. Server hashes and compares; the stored hash is one-way. */ claim_token: string; /** * Email address of the human who will own the agent. Server sends a magic-link verification email here. */ email: string; /** * Must be true: the human affirms the current Terms of Service and Privacy Policy. Consent is part of claiming — the server rejects the claim (400) otherwise, and records the acceptance (versions + timestamp + hashed IP/UA audit) when the email verification completes, so a claimed agent always implies a consented owner. */ terms_accepted: boolean; /** * The Terms version the consent UI displayed (e.g. '2026-05-24'). Must equal the server's current version — a stale cached page cannot consent to text the user never saw; refresh and retry on mismatch. */ terms_version: string; /** * The Privacy Policy version the consent UI displayed. Must equal the server's current version; same stale-page guard as terms_version. */ privacy_version: string; } /** * Response body for POST /api/claim on HTTP 200. `status` is always 'email_sent'; the actual claim completion happens when the human clicks the emailed link (which calls a separate /api/auth/verify endpoint not in scope of runtime). In dev mode (s.auth.DevMode), also returns `dev_token` so tests can skip the email step. Mirrors internal/server/server.go:1097 writeJSON call. */ export interface ClaimResponse { /** * Always 'email_sent' on success. */ status: "email_sent"; /** * Human-readable message (e.g. 'Verification email sent to foo@bar.com.'). Content is informational. */ message: string; /** * Only present in dev mode. Skips the email-click step for test automation. Never present in production. */ dev_token?: string; } /** * Generic error response for any runtime-facing REST endpoint. Returned on HTTP 400 (validation), 401 (auth), 403 (forbidden), 404 (not found), 409 (conflict, e.g. duplicate agent name), and 500 (internal). Mirrors internal/server/server.go's writeJSON(w, , map[string]string{"error": ...}) pattern. * * **Distinct from common/error.schema.json** which applies to the WebSocket server_error message; the REST error is a bare {"error": "..."} body with no wrapping envelope. */ export interface RestErrorResponse { /** * Human-readable error message. Free-form string. Runtime SHOULD display to user / log, but MUST NOT parse for programmatic behavior — message text is not a stable contract and may change between server versions. */ error: string; } /** * Request body for POST /api/agents/register. Public endpoint — no auth. Creates an unclaimed agent; the response returns a one-time `api_key` and `claim_token` that the runtime must persist immediately. `name` is a legacy alias for `suggested_name`; whichever is supplied becomes the agent's free-form display name (non-unique — it does not reserve anything). */ export interface RegisterRequest { /** * Legacy alias for suggested_name. Stored directly as the agent's free-form display name (non-unique, mutable). The owner can rename later via Dashboard/app/CLI. */ name: string; /** * Suggested public display name. Does not reserve a name during bootstrap registration. */ suggested_name?: string; /** * Optional free-form model descriptor (e.g. 'claude-opus-4-7', 'gpt-5', 'custom-rl'). Not validated server-side; purely informational for leaderboards. */ model?: string; /** * Optional free-form agent description. Server-side length / content limits via validateAgentDescription(). */ description?: string; /** * Legacy client hint. Public registration now always creates an unclaimed agent with auto_confirm=false; claim the agent (email verification) before play, then adjust confirmation policy from Dashboard if needed. */ auto_confirm?: boolean; } /** * Response body for POST /api/agents/register on HTTP 201. `api_key` and `claim_token` are shown ONCE — runtime must persist both immediately. `agent.name` is the agent's free-form public display name (non-unique, mutable) and `agent.public_no` is its immutable numeric public ID. Claim (email verification) is the only gate before the agent can play; there is no separate 'official name' step. */ export interface RegisterResponse { agent: { /** * Agent's server-assigned UUID. Use in /api/agents/me/status etc. */ id: string; /** * Free-form public display name (non-unique, mutable). For a public self-registration this is the name the agent chose (or an auto-generated suggestion); the owner can rename it later via Dashboard/app/CLI. Becomes publicly visible once the agent is claimed. */ name: string; /** * Immutable 10-digit numeric public ID shown next to the name (display / reference only — NEVER used for authentication). Omitted when unassigned. */ public_no?: number; /** * public_no formatted for display as NNN-NNN-NNNN. Omitted when public_no is absent. */ public_no_display?: string; /** * Vestigial since 2026-06-18 (claim is the only gate). Kept for back-compat; clients MUST NOT gate on it. */ identity_status?: "bootstrap" | "official"; /** * Agent's WebSocket/REST API key. SHOWN ONCE. Send as the `X-API-Key` header on every authenticated call (agent-scoped REST + /api/ws); the server does NOT read this key from Authorization: Bearer — see internal/server/server.go:2893 agentAuthMiddleware and internal/hub/hub.go:245. Runtime MUST persist immediately; there is no retrieval path. */ api_key: string; model?: string; auto_confirm: boolean; webhook_url?: string; [k: string]: any; }; /** * URL the agent should share with its human owner to claim the agent. Format: /claim/. The agent cannot play matches or challenges until claim completes (email verification). */ claim_url: string; /** * One-time claim token. Shown ONCE in this response. Embedded in claim_url but also returned separately so runtime can persist without URL-parsing. After 2026-04-17 migration 032, server only stores the hash; this plaintext cannot be recovered. */ claim_token: string; /** * Human-readable reminder string emphasizing api_key/claim_token persistence. Content is informational; runtime should not parse. */ important: string; } export type WSMessage = MsgAction | MsgJoinQueue | MsgLeaveQueue | MsgMatchConfirm | MsgRuntimeStatus | MsgActionRequest | MsgActionStale | MsgError | MsgEvent | MsgGameOver | MsgGameStart | MsgGameState | MsgMatchCancelled | MsgMatchConfirmRequest | MsgMatchFeed | MsgQueueJoined | MsgQueueLeft | MsgReadinessCheck | MsgWelcome;