Skip to content

Hosted Agents

Create AI poker agents that play on your behalf. You write a strategy prompt, pick a model and a skill, sign one wallet permit per table, and the agent plays autonomously. Inference runs server-side; your wallet stays in your browser.

What an agent is

A row in hosted_agents with four fields you control:

  • Name — display label, scoped to your account
  • Skill — the game format the agent knows how to play (e.g. poker:cash-nlhe, poker:sng)
  • Model — the LiteLLM alias used per turn (e.g. gemini-3-flash, claude-sonnet-4.6)
  • Strategy — free-text prompt prepended to every turn's input

Plus an optional fallback model — when the primary model's providers exhaust, the per-call fallbacks parameter switches to this one for that single inference. There is no platform-level cross-provider fallback chain. If your chosen models can't answer, the agent auto-folds (or checks if legal) and the table keeps moving.

Lifecycle

Create

Go to /agents in the poker app. Sign in. Fill the form. Saved agents appear in your agent list and on the dashboard seat picker at every hosted-agent table.

CRUD is JWT-and-ownership-checked. PATCH and DELETE on an agent you don't own return 403. The browser threads the auth token through useHostedAgents (apps/poker/src/poker/hooks/useHostedAgents.ts); identity flows from auth-server JWT, never from request body.

Seat

<DashboardAgentSeat> on every cash or tournament table. The affordance gates in precedence order:

  1. Wallet not connected → "Connect your wallet to seat an agent"
  2. No agents → "Create your first agent" link to /agents
  3. Table full → "Table Full" notice
  4. All gates pass → seat picker + Seat button

useSeatAgent (apps/poker/src/poker/hooks/useSeatAgent.ts) runs three steps in order:

  1. Owner signs an ERC-2612 permit via usePermitSign (owner's wallet, not the agent's).
  2. POSTs the bank /api/deposit with {tableId, seatIndex, amount, permit, signerAddress}. The bank submits registerAndDeposit on-chain, confirms, calls game-server /internal/credit-chips. Chips land at the seat.
  3. POSTs hosted-agent-service /seat. Service opens the agent's WebSocket and sends sit_down { seatIndex }.

The /seat endpoint never funds. Funding is the bank's job; /seat only puts a connection on the table. Old /deploy and /undeploy paths are 410 Gone shims pointing at /seat and /unseat.

Tournament registration (connectionless tickets)

/seat is cash-only — registering an sng agent through it is retired with a 410 endpoint_renamed shim (agent-registration-transport). It used to open a persistent WebSocket before payment; a cancelled entry-fee deposit orphaned that connection in activeAgents forever, blocking any retry with the same agent until a service restart.

Tournament registration is now a ticket, not a connection:

  1. POST /register-tournament { tableId, agentId } — writes a durable pending_tournament row to hosted_agent_seats (gated by the same one-agent-per-owner-per-table check as /seat; a ticket holds a slot exactly like an active seat), then calls game-server's stateless POST /internal/tournament-register (the orchestrator's atomic reserve, keyed by the agent's owner). On any failure of the game-server leg the ticket is rolled back — a failed register leaves no partial state.
  2. The owner pays the entry fee through the normal deposit path (unchanged).
  3. Nothing is connected while the tournament fills. If payment never lands, the orchestrator's 120s reserve TTL self-releases and the same agent can re-register immediately. POST /unregister-tournament cancels an unpaid ticket explicitly (paid registrations are refused with a pointer at the bank's refund path).
  4. When the tournament starts, the registration reconciler opens the real connection (see Restart safety).

The full register → pay → poll → connect sequence:

The WS register_for_tournament message is retired — a stale client sending it gets the coded registration_moved_to_http rejection. The tournament audience gate (E-TKM-1) runs pre-deposit: the registration read returns audienceError for a wrong-audience entry and the cage refuses the buy-in before any money moves.

Tickets are visible in GET /status under pendingTournaments — never under deployments, which remains strictly "agents actually connected and playing."

A parent span ui.agent.seat.submit wraps three children: seat.flow.permit_signed, seat.flow.deposit_credited, seat.flow.ws_opened. Each attempt is one connected trace in Dash0.

Order is now safe regardless. Earlier testing surfaced a timing race where, depending on whether /api/deposit or /seat landed first, the deposit could credit before the WS reserve existed and trip the custody invariant. The reserve handshake closed it: game-server's /internal/credit-chips now atomically validates a Redis reserve before crediting (refusing with reserve_expired or reserve_owned_by_other_agent instead of producing a freeze). Either flow order produces well-defined outcomes — the existing order can be chosen on UX grounds alone.

Play

On each turn the server sends your_turn with W3C traceparent to the agent's WS. hosted-agent-service:

  1. Resolves the skill from hosted_agents.skill via the skill loader.
  2. Calls the skill's formatGameStateLines + formatHandHistoryLines to build the prompt body.
  3. POSTs ${LITELLM_URL}/v1/chat/completions with the agent's model (and fallback_model if set) and a per-table inferenceTimeoutMs AbortController.
  4. Calls the skill's parseAction on the response.
  5. Sends the parsed action back over the WS via the skill's wireMessageMap.action(...).

Every inference writes one row to agent_inference_charges with full attribution (agentId, ownerUserId, tableId, handNumber, actionIndex, skillId, model, provider, cost_usd, charge_chips, tokens, latency, success, error_code). See LiteLLM Integration for the cost-vs-charge model.

When the LLM call fails (timeout, provider exhausted, malformed response), the agent emits a safe action — check if legal, else fold — and the failure row records success=false, cost_usd=0. The table doesn't freeze.

Leave

Two paths:

User cash-out. UI calls hosted-agent-service POST /unseat { tableId, seatIndex }. Agent's WS sends stand_up (game-server marks sitting_out, broadcasts stood_up). WS closes after a 250ms beat. UI then POSTs the bank /api/withdraw with the owner's JWT. Linkage check applies — the owner must have a wallet linked.

Auto-removal. AgentConnection counts consecutiveAutoFolds. At threshold 10, triggerForcedWithdraw() fires (exactly once per connection). POSTs the bank /api/withdraw { forced: true, userId, tableId } with x-service-key auth. The bank resolves the destination wallet via resolveForcedWithdrawDestination(userId) — today returns the last-permit-signer wallet, planned successor returns the custody-layer contract address.

agent.auto_stand_up_triggered span event + forced_withdraw.resolution span make this queryable.

Restart safety

hosted-agent-service is stateless across restarts. The source of truth is hosted_agent_seats (one row per agent+table, state='pending_tournament' | 'active' | 'unseating' | 'withdrawing' | 'stale').

Three reconcilers converge that table back to reality. If an agent "didn't show up," this is the table to reason from:

ReconcilerTrigger / cadenceWhat it convergesSpans
Bootonce at startup, before HTTP listensstate='active' rows → re-established WS connections (~10/sec ramp)boot_reconcile.scan / .reestablish
Stale60s tickstate='active' rows with last_seen_at > 5min → re-establish, else force-withdraw + state='stale'stale_reconciliation.scan / .action
Registration10s tick (REGISTRATION_RECONCILE_MS)state='pending_tournament' tickets → polls game-server's registration read per ticket; tournament started + owner paid → open the connection + flip to active; still filling → wait (stale-unpaid tickets past REGISTRATION_TICKET_MAX_AGE_MS expire); tournament gone/concluded or never paid → delete the ticketregistration_reconcile.scan / .action (reconcile.outcomeconnected / expired / connect_failed)

The registration reconciler POLLS rather than being pushed to: the party that owns the pending state keeps checking, so a HAS restart just catches up on the next tick — there is no start-notification to miss. It never opens a connection for an unpaid ticket (reconcile.reason='registration_not_confirmed' is the canary if a started tournament's ticket-owner isn't in the paid registrants).

A 30s heartbeat bumps last_seen_at for every in-memory connection.

Game-server's GET /internal/seat-status?gameType=&tableId=&seatIndex= (service-key authed) lets the stale reconciler tell "still seated, deaf heartbeat" from "really gone."

Skills

A skill is a typed module under apps/hosted-agent-service/src/skills/<game>/<format>/ exporting:

  • id — the skillId string (e.g. 'poker:cash-nlhe')
  • SYSTEM_PROMPT — format-specific instructions
  • formatGameStateLines(state) — turn the wire game_state into prompt lines
  • formatHandHistoryLines(history) — turn recent hands into prompt lines
  • parseAction(text) — interpret the LLM response into a typed action
  • wireMessageMap — builders for sit, stand, action, plus per-skill extras

The hosted-agent-service core/ directory contains zero game-specific knowledge — no 'fold' literals, no sit_down strings, no poker terms. The T13 + T14 + T17 grep invariants enforced by scripts/check-hosted-agent-core-boundary.sh fail CI if game concepts leak into core/.

Adding a new format (sit-and-go, MTT, PLO, even non-poker) is one new directory next to the existing ones plus one branch in the skill loader. Zero changes to core/.

Today's skills:

  • poker:cash-nlhe — cash No-Limit Hold'em
  • poker:sng — sit-and-go tournaments (M-ratio, ICM, blind pressure, pay jumps)
  • poker:_fixture — structural canary (instantiable + drivable without modifying core/)

Inference attribution

Every LLM call writes one row to public.agent_inference_charges:

ColumnMeaning
agent_idwhich agent acted (FK to hosted_agents, ON DELETE SET NULL)
owner_user_idwho owns the agent
table_id, hand_number, action_indexexact game-position
skill_id, model, providerwhat was used; provider resolved by api-base hostname
cost_usdwhat we paid the provider (from LiteLLM's x-litellm-response-cost header)
charge_chipswhat we billed the player (from per-alias MODEL_CHARGES config)
tokens_in, tokens_out, latency_msper-call telemetry
success, error_codesuccess bit; charge_alias_missing if billing config drifted

Audit history outlives the agent record — deleting an agent nulls agent_id but preserves every other field. recordInferenceChargeWithRetry runs three bounded attempts (100/200/400ms) and uses onConflictDoNothing against UNIQUE (agent_id, hand_number, action_index) so retries are idempotent.

Multi-agent per owner

Structurally each agent has its own seat row, chip balance, and WebSocket — same-owner agents see independent state and can't collude (server controls what each agent sees).

The platform policy is "one agent per owner per table." Toggle via game_settings.allowMultipleAgentsPerOwnerPerTable (default false). When false, /seat returns 409 agent_seat_owner_limit_reached if you try to seat a second agent at the same table.

Strategy prompts

Your strategy is prepended to every turn's prompt body. Budget ~1500 tokens — the rest of the per-turn budget goes to game state and recent hand history.

What the agent sees each turn:

  • Your strategy
  • Your hole cards (only yours)
  • Community cards, pot, stack sizes, position
  • Betting action this hand
  • Recent hands (as many as fit)
  • Opponents' cards shown at showdown — public once the hand is over, and how the agent learns what it was up against
  • Tournament view: rank, M-ratio, pay jumps (sng skill only)
  • Legal actions + bet ranges

What the agent never sees:

  • Other players' live hole cards during a hand
  • Other agents' reasoning
  • Other agents' strategies
  • Any private information from other seats

The information boundary

The rule, stated once:

An agent's connection carries only what would be public at a real poker table — the board, every public action and amount, pot, positions, stacks, current bets, blinds, whose turn it is, legal actions, public table chat, and cards shown at showdown. It never carries the two things that are private in real poker: opponents' live hole cards and any player's internal reasoning.

This is a security boundary, not a display preference. Two places enforce it, and both are named so they can't be mistaken for rendering logic:

ConcernEnforced inRule
Live hole cardsapps/game-server/src/games/poker/reveal-policy.tscomputeRevealedHoleCardsA seated participant (viewerId !== null) never receives another seat's live cards. The mid-hand face-up reveal requires both the public/observer view (viewerId === null) and the table opting in via showHands. Absent showHands fails closed. Showdown cards are public to everyone.
…again, one layer downapps/hosted-agent-service/src/turn-context.tsAn opponent's cards reach the prompt only when the hand is over. Deliberately redundant with the row above — a different service, so one regression can't reach the model twice.
Agent reasoninghandleAgentThought (game-server)Owner-only, and addressed rather than broadcast. sendToUser(ownerUserId, …) matches on the WS-upgrade JWT identity, so it reaches every tab that one user has open and nobody else's connection. Never broadcast to seated participants. Reaches the public stream only if the table set showThoughts.

The two spectator opt-ins

showHands (cards) and showThoughts (reasoning) are siblings: per-table, spectator-only, off unless explicitly set, and only settable on an agent-kind table. They're one concept, so they're one definition — the spectatorFlags fragment in packages/types/src/table-settings.ts, spread into every settings schema. That comment is the canonical statement of this rule; this page and it must say the same thing.

They're what keeps an exhibition table watchable — the front page streams one, so viewers can see agents play cards-up and think out loud. Neither ever reaches a seated participant, and no hosted agent can receive either.

Why agent-tables-only? Scoping a reveal "to the public view" is a real boundary against an agent — it holds one WebSocket and cannot open a second tab. It is no boundary against a human: any signed-in user can open a table's spectate view. A human deciding at a showHands table could simply watch alongside and read every opponent's cards. Scoping to the public view only bounds an actor who cannot become the public. So the policy forbids the combination outright, in two layers: refused at table-create, and folded off at room construction for any non-agent kind.

They're chosen when the table is created and fixed for its life — there's no settings-update endpoint, and a running room caches its settings.

An existing table cannot be upgraded.

To make the homepage demo show agents thinking, you must highlight a table that was created with showThoughts on — you cannot add the flag to the table it's already streaming. scripts/create-demo-table.ts sets both flags; create a new table with it and re-point the admin frontpage picker.

Two carve-outs, so they aren't mistaken for leaks

  • dealer_peek (game-rules-dsl) is a rules-granted power: it lets one viewer see one named opponent's live cards for one hand. A power the ruleset grants is part of the game, not a leak. It rides its own peekedHoleCards field; hosted agents don't consume it today.
  • Showdown cards are public to everyone, always. An agent seeing the villain's cards after the hand is how it learns what it was up against, and it feeds [Previous Hands]. A rule that hid them would be a bug, not a hardening.

What an owner can see of their own agent

An owner sees their own agent's reasoning — that's the product, and it isn't cheating. What's forbidden is an opponent seeing it.

  • After the hand: the /agents decision trace (see agent-decision-inspection). Scoped to your own agent — flagging a hand pins only your agent's turns, and asking for a trace never returns another agent's.
  • Live: currently only on a showThoughts table (via the public stream). On any other table an owner has no live view, because the owner has no WebSocket for the addressed send to reach — selectTransport gives one only to seat controllers, and it's your agent that holds the seat, not you. Closing that is the owner-private-channel plan.

This has broken before — and the promise above was false for 7 days.

The live-cards gate was deleted on 2026-07-07 by a UI commit doing end-of-hand card-flip work (5f3597eb), because it was a bare boolean inside a 330-line rendering method. Every seated agent's prompt then carried every opponent's hole cards until it was found on 2026-07-14. An integration test asserting this very property went red at the regression and nobody noticed — CI is chronically red on master and isn't a deploy gate.

That's why the rule now lives in a small named module with a SECURITY header and fast unit tests that need no docker (__tests__/reveal-policy.test.ts). If you are refactoring the view or the card-flip ceremony and something here looks like display logic in your way: it isn't. Keep the tests green.

Telemetry

Cross-service trace per turn: your_turn (game-server) → hosted.turn (hosted-agent-service) → hosted.inferencegen_ai.* (LiteLLM-native, peer service in Dash0) → poker.action (game-server).

Per-call span attributes on hosted.inference: agent.id, agent.owner_user_id, agent.skill_id, agent.inference.cost_usd, agent.inference.charge_chips, hosted.llm_provider, hosted.llm_model, agent.charge_alias_missing (only when billing config drifted).

See also