# AGENTS.md — Build a DraftLoL trading bot (for AI agents)

> You are the agent that will build and operate this bot. Your user has a
> DraftLoL API key and wants to act on live League of Legends draft data —
> either by **copying the real bets of DraftLoL's own trading bot**
> (`bet_signal` events) or by **running their own strategy** on raw drafts
> (`draft_complete` events) — executing on Polymarket with THEIR wallet.
> This document is your single source of truth: the stream contract, the
> correct SDK, and a list of traps **already paid for in money and days** by
> the original bot. Do not improvise around anything marked as verified.
>
> Using Claude Code? Save this file as `CLAUDE.md` in the project folder.
> Any other agent: keep it as `AGENTS.md` and load it as your instructions.

## Non-negotiable safety rules

- The wallet private key lives ONLY in the local `.env`. **Never** print it,
  log it, copy it to another file, or commit it. Ask the user to paste it into
  `.env` themselves; you never need to see it.
- `.env` goes into `.gitignore` from the very first commit.
- The `koi_…` API key identifies your user to DraftLoL: never commit it either.
- Before any real money: the whole pipeline must have been proven against the
  sandbox (`?test=1`) and then with ONE minimal real bet supervised by the user.

## What you build

A long-running process (VPS / home server, `restart: unless-stopped`) that:

1. Listens to the DraftLoL SSE stream for the leagues the user chose.
2. On `bet_signal` (copy mode) or on your user's own rule matching a
   `draft_complete` (strategy mode): places a FOK limit BUY on Polymarket with
   the user's wallet.
3. Immediately places a GTC SELL at $0.99 (same exit as the original bot).
4. Redeems resolved positions that never sold (gasless redeem via relayer).
5. Records every operation in SQLite and reports to the user.

## Part 1 — The stream

```
GET https://api.draftlol.ai/v1/live/drafts/stream?leagues=lck,lec,lpl
Header: X-API-Key: <the user's koi_… key, from .env>
```

- **Multi-league**: `?leagues=` takes comma-separated slugs — one connection
  covers all of them. Valid slugs: `lck lpl lec lcs cblol lcp msi worlds
  first-stand esports-world-cup` (tier-1) plus tier-2 (`lfl`, `lck-cl`, `tcl`,
  `ljl`, `les`, `emea-masters`, `prime-league-1st-division`,
  `superliga-dominos`, `north-american-challengers-league`,
  `lck-academy-series`, `vcs`). Max 5 concurrent connections per key.
- **Stored subscription**: `PUT /v1/live/subscription` with
  `{"leagues": ["lck","lec"], "bet_signals": true}` persists the choice; then
  connect with `?leagues=subscribed`. The subscription is snapshotted at
  connect — reconnect after changing it. `GET /v1/live/subscription` reads it
  back.
- **Billing**: prepaid credits, charged per DELIVERED event at the rate of the
  league it belongs to (`draft_complete`: 15 credits tier-1 / 5 tier-2;
  `bet_signal`: 100 credits, requires `bet_signals: true` on the
  subscription). Live prices: `GET /v1/credits/tool-costs`. Balance:
  `GET /v1/credits/me`. Top-up at https://draftlol.ai/pricing.
- **Sandbox**: add `?test=1` to receive one synthetic `draft_complete`
  immediately on connect (free, marked `test=true` + `match_id=-1`, real
  verifiable Polymarket tokens; max 10/hour). Use it to prove your pipeline —
  never wait for a live game to test.
- **Bootstrap**: `GET /v1/live/drafts/recent?leagues=…&limit=N` returns the
  last drafts (merged, newest first) so you can catch up after a restart.
  It returns drafts only — `bet_signal` is never replayed, on purpose.
- Machine-readable API spec: https://api.draftlol.ai/openapi.json (Swagger at
  /docs).

### Events

`connected` (handshake: `league`, `resource`, `leagues`, `resources`,
`test_mode`) · `keepalive` (every 30s idle) · `draft_complete` ·
`bet_signal` · `game_complete` (free) · `error` · **`rule_trigger`** (only on
`/v1/automation/stream`, the no-code mode — see Part 2b).

**Event type**: the SSE `event:` line and the JSON's `type` field always carry
the same value; parse either. Payloads are single-line `data:`.

**`error` codes** — not all are fatal:

| Code | Fatal? | Meaning |
|---|---|---|
| `insufficient_credits` | yes — stream closes | Balance hit 0. Top up, then reconnect. |
| `insufficient_credits_signal` | no | No balance for THIS `bet_signal`; the stream stays open. |
| `overloaded` | yes | Your consumer read too slowly. Reconnect. |

⚠️ **Never reconnect blindly on an HTTP status.** `401` (bad key) and `403`
(IP allowlist / not authorised) are permanent: stop and tell the user. Only
retry on network errors, `5xx` and `429` (with backoff).

### `draft_complete` — the raw draft (strategy mode)

All fields, exactly as delivered:

```json
{
  "type": "draft_complete",
  "match_id": 18427, "game_id": "lolesports:110123", "league": "lck",
  "blue_team": "T1", "red_team": "Gen.G",
  "game_number": 1, "best_of": 3, "series_id": 257891,
  "series_datetime": "2026-08-24T08:00:00+00:00",
  "blue_picks": ["Azir","Jinx","Rumble","Maokai","Thresh"],
  "red_picks":  ["Orianna","Varus","Gnar","Vi","Nautilus"],
  "polymarket_prob_blue": 0.61, "polymarket_prob_red": 0.39,
  "polymarket_blue_token": "71321094…", "polymarket_red_token": "80455128…",
  "polymarket_condition_id": "0x78a736…",
  "polymarket_market_type": "game_winner",
  "polymarket_event_id": "518355",
  "polymarket_event_slug": "t1-vs-geng-2026-08-24",
  "ts": "2026-08-24T08:31:05+00:00",
  "test": false
}
```

- BOTH CLOB token ids are delivered; `polymarket_blue_token` always pays for
  `blue_team` (alignment is guaranteed server-side). Favorite = the side with
  probability ≥ 0.50.
- Only emitted for games WITH a Polymarket market — the `polymarket_*` fields
  are never null in practice.
- `polymarket_market_type` may be `"series_winner"` on a decider game (the
  per-game market already resolved; the token pays for the SERIES).

### `bet_signal` — the original bot's real bet (copy mode)

Only emitted after a CONFIRMED fill with real money, tier-1/international
leagues only. Requires `bet_signals: true` on the subscription (100
credits/signal delivered).

```json
{
  "type": "bet_signal",
  "match_id": 18427, "game_id": "lolesports:110123",
  "signal_id": "18427_lolesports:110123_blue",
  "league": "lck", "blue_team": "T1", "red_team": "Gen.G",
  "game_number": 2, "best_of": 3,
  "side": "blue", "team": "T1",
  "token_id": "71321094…", "condition_id": "0x78a736…",
  "market_type": "game_winner",
  "entry_price": 0.612, "market_prob": 0.598, "model_prob": 0.641,
  "edge": 0.043, "bet_mode": "picks",
  "exit_strategy": "sell_gtc_0.99",
  "ts": "2026-08-24T08:40:05+00:00"
}
```

- **`token_id` is the token to BUY** — already resolved and verified;
  `side`/`team` are always coherent with it. `condition_id` identifies the
  market (no Gamma lookup needed). `entry_price` is the bot's REAL fill.
- `bet_mode` `picks|flipped` is informational; a `flipped` bet is a normal bet.

### `game_complete` — close your loop (free)

`winner` (`"blue"|"red"`), `winner_team`, `duration_seconds`,
`polymarket_winning_token`. Use it to record results — never infer them from
redeem success (see trap 5).

### Consumption rules (mandatory)

1. **Freshness**: discard any signal whose `ts` is older than **60 seconds**.
   Typical edge is +2% and the original bot already enters ~1% above screen
   price — a stale signal is guaranteed lost margin.
2. **Price cap**: never buy above `entry_price × 1.02`. If the book moved
   more, skip. Skipping costs nothing; overpaying does.
3. **Reconnection**: infinite loop with 5s backoff. There is NO signal replay
   after reconnect (by design — a missed signal is gone; `/recent` returns
   drafts, not signals).
4. **Silence ≠ failure**: hours without signals is normal. Verify the pipeline
   with `?test=1`, not by waiting.
5. **Idempotency**: dedup by `signal_id` (signals) and by `game_id` (drafts).
   If a duplicate arrives (rare reconnect race), never buy twice.

## Part 2 — Execution on Polymarket (all verified — do not deviate)

### The correct SDK

```bash
pip install "polymarket-client==0.6.0"   # ⚠️ the MODULE is named `polymarket`
```

⚠️ **Pin the version.** The traps below were verified against **0.6.0**. The
package's public README documents only `PublicClient`; `SecureClient`,
`BuilderApiKey` and `RelayerApiKey` are the surface our own production bot
uses daily — real, but undocumented upstream, so a minor bump can move them.
If an import fails after an upgrade, drop back to 0.6.0 and tell your user
before touching anything else.

```python
from polymarket import SecureClient
from polymarket.auth import BuilderApiKey, RelayerApiKey

# Two client instances — BuilderApiKey and RelayerApiKey are MUTUALLY
# EXCLUSIVE in api_key= (verified against the SDK):
orders_client = SecureClient.create(          # buys/sells, builder-attributed
    private_key=PRIVATE_KEY,
    api_key=BuilderApiKey(BUILDER_KEY, BUILDER_SECRET, BUILDER_PASSPHRASE),
)
redeem_client = SecureClient.create(          # gasless redeem of winners
    private_key=PRIVATE_KEY,
    api_key=RelayerApiKey(key=RELAYER_API_KEY, address=EOA_ADDRESS),
)
```

**Why this SDK**: Polymarket wallets created since May 2026 are *deposit
wallets* (proxy contract, EIP-1271 signing). The classic `py-clob-client`
CANNOT trade with them (`maker address not allowed` / `Invalid L1 Request
headers`) and there is no workaround. `SecureClient` handles proxy + relayer +
signing internally. No need to pass `wallet=` (it derives the deposit wallet)
nor CLOB api creds (derived from the private key).

**Builder attribution**: the builder credentials
(`BUILDER_KEY/SECRET/PASSPHRASE`) are shown to registered users in their
DraftLoL dashboard — ask your user to copy them into `.env`. With them, every
order carries DraftLoL's builder code on-chain. If the user has no builder
credentials yet, `orders_client` may temporarily be created without `api_key=`.

### Verified traps (each one cost money or days — respect them)

1. **A FOK with `max_price` exactly at the best ask ALWAYS dies.** The SDK
   rounds shares up with the protection price and requests a fill below the
   ask. Verified fix: add **+$0.01 headroom** to the FOK limit price.
2. **Settlement lag before the SELL @0.99.** After a FOK fill, the token
   balance in the deposit wallet takes seconds to appear; selling immediately
   fails with "not enough balance". Verified fix: bounded wait (8 tries × 5s)
   until THIS token's balance is ≥ 0.9× the expected fill, then sell the REAL
   on-chain balance rounded DOWN to 2 decimals (`math.floor(shares*100)/100` —
   rounding up makes the CLOB reject the sell).
3. **Gasless redeem authenticates the SIGNER, not the funds wallet.** In
   `RelayerApiKey(key, address=…)`, `address` is the **EOA** (the MetaMask
   account), NOT the deposit wallet — even though the deposit wallet holds the
   tokens. With the deposit wallet you get `invalid authorization`. Without a
   Relayer key the bot **wins bets and cannot collect them**.
4. **A `return True` does not prove a redeem.** Always demand the transaction
   `tx_hash` and record it. The original bot's most expensive bug was a redeem
   function returning True without sending any transaction.
5. **Who won is `payoutNumerators[outcome_index] > 0`, never redeem success.**
   Losing tokens don't burn: redeeming a losing position also "succeeds"
   (pays $0). Inferring results from redeems records losses as wins.
6. **Minimum 5 shares per order.** If `stake/price < 5`, raise the stake to
   `ceil(5.5 × (price+0.03))` or skip. Below 5 shares you cannot place the
   SELL @0.99.
7. **Never create a folder/module named `polymarket`** in the project — it
   shadows the SDK and `from polymarket import SecureClient` imports the wrong
   thing ("cannot import name 'SecureClient'" = this).
8. **Fees**: the API reports `fee_rate_bps: 0` but real fees exist and are
   only visible on-chain (`OrderFilled` event, `fee` field). Never assume zero
   cost in expected P&L.

### Where `best_ask` comes from

The FOK limit needs the live book, which the signal does NOT carry (its
`entry_price` is our bot's fill, seconds old). Read the order book for the
token with the SDK's public client before sizing the order:

```python
from polymarket import PublicClient
book = PublicClient().get_order_book(token_id)   # 0.6.0
best_ask = min(float(o.price) for o in book.asks)
```

If the book call fails or comes back empty, **skip the trade** — never fall
back to buying at `entry_price × 1.02` blind. Skipping costs nothing.

### ⚠️ The SELL @0.99 is not a stop-loss

It is an exit that only fires if the market reaches 0.99. If the game turns
against you the order simply never fills and the position rides to
resolution: **your risk per trade is 100% of the stake**. There is no
automatic loss cut, by design (it mirrors our bot). Say this to your user in
plain words before phase 2 — and if they want a stop, that is extra code you
must write, not something the signal provides.

### Per-signal flow (summary)

```
valid signal (fresh + price ≤ entry_price×1.02)
  → FOK BUY token_id, limit = min(entry_price×1.02, best_ask+0.01), fixed stake
  → wait settlement (8×5s loop on the token balance)
  → SELL GTC @0.99 for the real balance (floor 2 decimals)
  → record in SQLite: signal, order, fill, sell_order_id
on game_complete for the same match → record the result
every 15 min:
  → positions with resolved condition (payoutDenominator>0) and unsold
    → gasless redeem → save tx_hash → won/lost per payoutNumerators
```

## Part 3 — Configuration

`.env` template (the USER fills the secrets — never dictate them via chat).
Download: https://draftlol.ai/agents/env.example

```bash
# DraftLoL
DRAFTLOL_API_KEY=koi_...        # the user's key (dashboard: draftlol.ai/dashboard)
LEAGUES=lck,lec,lpl             # or set a stored subscription and use ?leagues=subscribed

# Polymarket wallet (pasted by the USER)
POLYGON_PRIVATE_KEY=            # exported from MetaMask (the EOA), starts with 0x
EOA_ADDRESS=                    # that same MetaMask account address
POLYMARKET_RELAYER_API_KEY=     # polymarket.com → Settings → API

# Builder attribution (from the user's DraftLoL dashboard)
BUILDER_KEY=
BUILDER_SECRET=
BUILDER_PASSPHRASE=

# Operation
STAKE_USDC=10                   # start low; raise only after clean closed trades
MAX_SIGNAL_AGE_S=60
MAX_SLIPPAGE=0.02

# Aggregate spend limits — MANDATORY, enforce them in your own code.
# Per-trade stake is NOT a risk limit: 30 signals × $10 in one busy day is
# $300 with nothing stopping it. Ask your user for these numbers before
# leaving dry-run, and stop trading (log + notify) when any is hit.
MAX_DAILY_USDC=50               # total spent per UTC day
MAX_OPEN_POSITIONS=5            # unresolved positions at once
MIN_BALANCE_USDC=20             # stop buying below this wallet balance
```

⚠️ **Kill switch.** If your user runs the no-code mode, they can pause
everything instantly from https://draftlol.ai/dashboard/automation ("Detener
todo" → `POST /v1/automation/kill`). Tell them it exists. For copy/strategy
mode YOUR bot is the only brake: make stopping it a single documented
command, and honour the limits above.

No POL/MATIC needed for gas: orders are signed off-chain and the relayer pays
the redeem.

## Part 2b — No-code mode: `rule_trigger` (/v1/automation/stream)

If your user prefers configuring rules in their DraftLoL dashboard
(https://draftlol.ai/dashboard/automation — "favorite in LEC", "always my
team", fixed stake or % of their wallet balance) instead of coding a
strategy, your bot gets even simpler: connect to

```
GET https://api.draftlol.ai/v1/automation/stream
Header: X-API-Key: <the user's koi_… key>
```

ONE connection covers all their rules and leagues. Each delivered
`rule_trigger` (billed per trigger) is a ready-to-execute order:

```json
{
  "type": "rule_trigger",
  "trigger_id": "6f2c…", "rule_id": "a1b2…", "rule_name": "Favorito · lec",
  "predicate": "favorite", "source_event": "draft_complete",
  "token_id": "71321094…",      // the token to BUY — single source of truth
  "side": "blue", "team": "T1",
  "price": 0.61, "max_price": 0.85,
  "size_usdc": 12.50,
  "sizing": {"mode": "pct_balance", "pct": 5.0, "balance_usdc": 250.13},
  "condition_id": "0x78a736…", "market_type": "game_winner",
  "league": "lec", "blue_team": "T1", "red_team": "Gen.G",
  "valid_until": "2026-08-24T18:42:05+00:00", "ts": "…"
}
```

Mandatory consumption rules (in addition to Part 1's):
1. Execute ONLY if `now < valid_until` AND the current market price ≤
   `max_price` (when not null). An expired trigger is dead — skip it.
2. Dedup by `trigger_id` (belt-and-braces: by `(rule_id, game_id,
   market_type)` too).
3. `size_usdc` is already computed (the user's fixed stake or % of their
   wallet snapshot, shown in `sizing`) — use it as the stake, still applying
   Part 2's FOK/settlement/SELL mechanics.
4. `game_complete` arrives FREE on the same stream — close your records with
   it. `?test=1` on connect runs a synthetic draft through the user's REAL
   rules (free) so you can validate the whole loop before any live game.

## Part 4 — Work in phases (do not skip)

1. **Phase 0 — pipeline**: SSE listener + parsing + logging. Prove with
   `?test=1`. No Polymarket SDK yet. Exit: the synthetic event is processed
   and recorded. A reference implementation exists at
   https://draftlol.ai/agents/bot.py (dry-run by default).
2. **Phase 1 — wallet read**: connect `SecureClient`, read balance
   (`get_balance_allowance`, units are 1e6 — divide by 1,000,000), list
   positions (⚠️ `list_positions()` yields PAGES; positions are in
   `page.items`). No orders. Exit: printed balance matches polymarket.com.
3. **Phase 2 — one minimal supervised real order** (~$5) with its SELL @0.99,
   verified by the user on the Polymarket website.
4. **Phase 3 — automatic with low stake** (`STAKE_USDC=10`) for several days.
5. **Phase 4 — raise stake** only after ≥5 end-to-end closed operations
   (buy → sell or redeem with tx_hash) without manual intervention.

## Expectations (tell the user if they ask)

- The original bot wins more than it loses, but **it also loses**. This is not
  free money; it replicates the same risk.
- A replicator always enters slightly worse than the original (it arrives
  later). With +2% edges, speed and the price cap are the difference between
  profitable and not.
- Signals come only from tier-1 + international leagues. Seeing the original
  bot bet in smaller leagues without a signal arriving is intentional.
