open-leagues

Guide

One pain, one fix, the command, and what came back.

Pick your seat

Outputs come from a real Sleeper league (SDIFFL, 2025, week 14) or a real lab run, verbatim. Filter to your seat, or read straight down.

every use case, agents first, then the season in order

Connect Codex or Claude

the pain
“My agent can talk about fantasy football but cannot touch my league.”
the fix
The public box serves the read verbs with no token, rate-limited per IP. Your own box takes one bearer token for 88 of 99 verbs.
what you run
leagues.waits.dev · no account, no token · public read verbs
claude mcp add --transport http open-leagues https://leagues.waits.dev/api/mcp
codex  mcp add open-leagues --url https://leagues.waits.dev/api/mcp

# receipts, week boards, the season ledger, lines, cohorts, grading, staking
# rate-limited per IP · nothing here needs a person
what comes back
codex mcp list · captured while leagues.waits.dev was a league box
Name          Url                                     Bearer Token Env Var  Status   Auth
open-leagues  https://leagues.waits.dev/api/mcp       OPENLEAGUES_TOKEN      enabled  Bearer token

Then ask it a question. Captured preseason 2026 against the live host, unedited. The first two calls are tools Codex assumed existed.

codex exec · preseason 2026
codex · open-leagues
› get my league context — team name, record, this week's opponent
mcp: open-leagues/getDesk failedmcp: open-leagues/getSchedule failedmcp: open-leagues/listMyLeagues completedmcp: open-leagues/getAgentContext completedmcp: open-leagues/getLeagueBundle completedmcp: open-leagues/getMatchups completedmcp: open-leagues/getSchedule completed
hands — 0-0-0 — vs Butterbean
why you can trust it
  • /api/mcp rejects cookie sessions in every mode. It accepts a bearer token or a proxied user id.
  • stdio needs a Postgres DATABASE_URL and OPENLEAGUES_USER, and exits without both.

Test a betting hunch

the pain
“Checking whether home dogs with over half the tickets are a signal takes a spreadsheet and a weekend.”
the fix
open-leagues-lab-discover grades the cohort on 2023–24, then once on a 2025 holdout, and freezes the rule only if the holdout clears. open-leagues-lab-run grades a frozen rule each Tuesday on paper and places no bets.
what you run
over MCP · three calls
# discover: tune on 2023–24, verify on 2025, freeze only if it clears
sampleGames      { "seasons": [2023, 2024], "filter": { "homeDog": true, "played": true, "splits": [{ "market": "spread", "side": "home", "tickets": [50, 100] }] } }
evaluateBets · summarizeRun                       # read pBreakEven and n
sampleGames      { "seasons": [2025], …same filter }   # the holdout, opened once
evaluateBets · summarizeRun
simulateBankroll { "graded": […], "bankroll": 1000, "policy": { "type": "percent", "pct": 1, "cap": 2 } }
freezeStrategy   { "name": "…", "spec": { words, seasons, filter, bet, staking, bankroll } }   # only if it cleared

# run, every Tuesday, on the frozen rule
getStrategy → getLabRuns → sampleGames (last week) → evaluateBets → summarizeRun
→ simulateBankroll (season) → recordLabRun → the digest
what comes back
discover skill · every number from summarizeRun or simulateBankroll
── discover ──────────────────────────────────────────────────────────────
mcp: sampleGames  { "seasons": [2023, 2024], "filter": { "homeDog": true, "played": true,
                    "splits": [{ "market": "spread", "side": "home", "tickets": [50, 100] }] } }
mcp: evaluateBets · summarizeRun
     discovery 2023–24   27-18   roi +0.14   pBreakEven 0.19   n 45

mcp: sampleGames  { "seasons": [2025], … same filter, untouched }        ← the holdout, opened once
mcp: evaluateBets · summarizeRun
     holdout 2025        15-18   roi −0.13   pBreakEven 0.83   n 33

→ does not clear the holdout. The skill reports it and does NOT call freezeStrategy.

…
why you can trust it
  • Lines and results are nflverse closing numbers. Missing prices default to −110 and the payload says which odds were used.
  • Grading is tested against DAL–DET, week 14 2025 (DET −3.5, 44–30), and reports closing-line value when your number differed from the close.
  • Ticket and money percentages are opt-in (OPENLEAGUES_SPLITS_SOURCE). Action Network consensus has history from 2023 and is the source backtests use. DraftKings Network handle and bet share and the WiseGuyTeam multi-book read cover the current slate, each kept under its own book. All are undocumented and stored on your box once pulled.
  • Tuned on 2023–24 this cohort went 27-18, roi +0.14. On the 2025 holdout it went 15-18, roi −0.13. pBreakEven 0.19 means a 27-18 record comes up by luck one time in five. The skill does not freeze it and says why.
  • The staking policy is the caller’s. simulateBankroll compounds it, reports drawdown in dollars, flags when Kelly was fed its own sample, and resamples the bets 1000 times. Here a +$19 result shows as a band from −$108 to +$170 with a 43% chance of a loss.
  • Opening lines and line movement are not wired in (free only for 2007–2021). A frozen strategy is stored on your box and never touched by the run skill.
  • docs/lab.md in the repo has the full walkthrough, from box to the sentence to say.

The minute it flipped

the pain
“I was winning until the 4pm games.”
the fix
Open your team’s receipt for any settled week this season or last. The first line gives the minute of the last lead change, the player behind it, and your win probability a half-hour earlier.
what you run
browser · no account
https://leagues.waits.dev/r/1255972181892935680/14/9
what comes back
the receipt and its JSON
GET /r/1255972181892935680/14/9

NateBot 129.0 — Roster 14 85.3 · final
Took the lead for good at 3:41pm ET on Trevor Lawrence.
You were 78% to win at 3:11pm ET · 1 lead change this week · 78.78–78.56 at the flip

…
why you can trust it
  • Rebuilt from nflverse play-by-play under your league’s scoring settings, to the second.
  • Where the play log and Sleeper’s official stats disagree, the difference is booked at the final whistle. Finals match Sleeper to the cent, 84 of 84 team-weeks checked on this league. A correction that decides a week is named as one.
  • The current week appears once the play log publishes; until then the receipt says so.

Find the league

the pain
“I have the Sleeper app and no league id.”
the fix
Type your Sleeper username in the home input; it lists every league that account is in this season, private ones included.
what you run
home page input
ryanwaits
what comes back
findSleeperUser
type: ryanwaits

[
  { "name": "SDIFFL", "league_id": "1255972181892935680", "total_rosters": 14, "season": "2025" }
]

→ pick one → /r/1255972181892935680
why you can trust it
  • Read from Sleeper’s public user and league endpoints. Nothing about you is stored.
  • Team names only: a team named after its manager’s username renders as Roster N.

What the bench cost

the pain
“I should have started him.”
the fix
The receipt scores your best possible lineup against the one you set, on the box score. Under each miss, three open sources show their pre-kickoff call.
what you run
same receipt, second section
https://leagues.waits.dev/r/1255972181892935680/14/9#bench
what comes back
the bench line
Left 6.5 on the bench. Saquon Barkley 20.2 sat behind Omarion Hampton 13.7.

  Sleeper projection   start   15.0 / 9.7
  Last 3 weeks         start    8.0 / 0.0
  Season average       start   12.8 / 5.9
…
why you can trust it
  • Sleeper projection, last three weeks, and season average, each valued under your league’s book. No paid source appears, even as a comparison.
  • The receipt grades in hindsight and names which source was right.

What the wire cost

the pain
“I think I overpaid on waivers.”
the fix
The wire line lists your week’s bids as a share of your league’s budget and whether each won. From the second pasted league on, it adds the median share each player cleared for elsewhere.
what you run
the wire line on your receipt
https://leagues.waits.dev/r/1255972181892935680/14/9#wire
what comes back
wire · one league pasted so far
"wire": {
  "moves": [
    { "kind": "waiver", "add": "Stefon Diggs", "bid": 26, "won": false,
      "budget": 200, "bidPct": 13, "medianPct": null, "leagues": null }
  ],
  "spent": 0
}
…
why you can trust it
  • Only leagues that asked for a receipt contribute; nothing is crawled. No league id, manager, or roster appears in the file.
  • Each price is a share of the bidder’s league budget, with the share of their remaining budget beside it. Raw dollars appear only inside a single-budget cohort. Filters by roster count, scoring format, and superflex keep formats apart.
  • n is the number of winning bids behind a price. With one league it is one bid; the receipt shows a median only once a second league has cleared a claim.

Which source to trust

the pain
“Every site tells me who to start and none tells me if it was right.”
the fix
Below each receipt, the season ledger scores each open source’s lineup, week by week, beside yours, as weeks beat, tied, or lost.
what you run
below any receipt · getSourceLedger over MCP
https://leagues.waits.dev/r/1255972181892935680/14/9#ledger
what comes back
the season ledger · NateBot, 2025
GET /r/1255972181892935680/14/9  →  season ledger, below the receipt
mcp: getSourceLedger { "leagueId": "1255972181892935680", "rosterId": 9 }

NateBot · 2025 · 16 weeks
You set lineups worth 1874.3 and left 151.0 on the bench.
Following Sleeper projection every week would have added +2.1.

  source               beat · tied · lost     vs you
  Sleeper projection      5  ·  5  ·  6         +2.1
  Last 3 weeks            1  ·  1  · 13       -236.4
…
why you can trust it
  • Each source’s lineup is filled the same way yours is, from its pre-kickoff numbers under your league’s book, then scored on the box score.
  • Settled weeks are computed once and kept. The first ask for a team takes about half a minute; later asks are cached.
  • Two of the three sources lose to this manager by over 230 points, and the ledger reports it.

The whole week

the pain
“I want to see every matchup in the league this week.”
the fix
The league page shows one line per matchup with a week picker; each line opens a receipt and needs no login.
what you run
browser
https://leagues.waits.dev/r/1255972181892935680?week=14
what comes back
getWeekBoard
GET /r/1255972181892935680?week=14

{ "league": { "id": "1255972181892935680", "name": "SDIFFL", "season": "2025", "hosted": false },
  "week": 14, "currentWeek": 17,
  "rows": [
    { "matchupId": 1, "home": { "rosterId": 3, "name": "My Toe Hurts", "points": 9 },   "away": { "rosterId": 6,  "name": "BBQ BRISSKET", "points": 4 },    "outcome": "home" },
…

Send it to the chat

the pain
“Screenshots get argued with.”
the fix
Paste the receipt URL into iMessage, Discord, Sleeper chat, or X; it unfurls as a card drawn from the same lines as the page.
what you run
paste where links unfurl
https://leagues.waits.dev/r/1255972181892935680/14/9
what comes back
the og:image behind the link · headers
curl -I https://leagues.waits.dev/api/og/r/1255972181892935680/14/9

HTTP/1.1 200
content-type: image/png
cache-control: public, max-age=300, s-maxage=900

→ a 1200×630 card: both scores, the flip line, the bench line, the wire line, the URL.
why you can trust it
  • Rendered server-side from the receipt; nothing appears that is not on the page.
  • To have a league’s receipts taken down, open an issue on the repo with the league id.

One player-id map

the pain
“Every side project starts by hand-building the same Sleeper-to-ESPN-to-nflverse id table.”
the fix
One file, one row per player: Sleeper, GSIS (nflverse), ESPN, Yahoo, RotoWire, and Sportradar ids with name, team, and position. It needs no key, is CORS open, and is cached one day.
what you run
curl · 11,826 rows
curl -s https://leagues.waits.dev/api/players.json
what comes back
one row
curl -s https://leagues.waits.dev/api/players.json | jq '.players[] | select(.sleeper_id=="4866")'

{
  "sleeper_id": "4866",
  "gsis_id": "00-0034844",
  "espn_id": "3929630",
  "yahoo_id": "30972",
  "rotowire_id": "12507",
  "sportradar_id": "9811b753-347c-467a-b3cb-85937e71e2b9",
  "name": "Saquon Barkley",
  "team": "PHI",
  "position": "RB"
}

"count": 11826 · access-control-allow-origin: * · cache-control: public, max-age=3600, s-maxage=86400
why you can trust it
  • Sleeper’s player file plus the dynastyprocess id table. Sleeper alone has a GSIS id for a fifth of active players; the join gives every active player one.
  • Sleeper stays the authority on its own ids; the id table only fills what Sleeper leaves blank.

Clearing prices as data

the pain
“Paid tools predict FAAB prices, and nobody publishes what cleared.”
the fix
/api/wire/:season/:week.json lists winning bids per player across pasted leagues, as a share of each league’s budget: median, quartiles, max, remaining-budget share, and count. Cohort filters keep formats apart.
what you run
curl · cached one hour once a claim has cleared
curl -s https://leagues.waits.dev/api/wire/2025/14.json | jq '.prices[:3]'
what comes back
week 14 · one league so far
GET /api/wire/2025/14.json
{
  "season": "2025", "week": 14, "leagues": 1,
  "budgets": { "200": 1 },
  "cohort": {},
  "prices": [
    { "player_id": "2449", "name": "Stefon Diggs",       "position": "WR",  "n": 1, "median_pct": 39.5, "p25_pct": 39.5, "p75_pct": 39.5, "max_pct": 39.5, "median_pct_remaining": 76.7, "dollars": { "budget": 200, "median": 79 } },
    { "player_id": "CIN",  "name": "Cincinnati Bengals", "position": "DEF", "n": 1, "median_pct": 1,    "p25_pct": 1,    "p75_pct": 1,    "max_pct": 1,    "median_pct_remaining": 8.3,  "dollars": { "budget": 200, "median": 2 } },
    { "player_id": "3678", "name": "Wil Lutz",           "position": "K",   "n": 1, "median_pct": 0,    "p25_pct": 0,    "p75_pct": 0,    "max_pct": 0,    "median_pct_remaining": 0,    "dollars": { "budget": 200, "median": 0 } }
  ]
}

Full field shapes for both files are on Open data.

Run your own box

the pain
“Our league’s history belongs to an app that sells ads against it.”
the fix
Run one container with a volume, or a Vercel deploy with Postgres. Receipts work for any Sleeper id on first boot, and nothing is seeded.
what you run
docker · durable box, in-process clock
git clone https://github.com/ryanwaits/open-leagues.git
cd open-leagues
docker compose up -d

# http://localhost:8080
why you can trust it
  • With no DATABASE_URL the box uses the embedded PGLite database and still keeps a league.
  • Players, stats, and projections come from Sleeper and nflverse over outbound requests. No member needs an account anywhere else.

Move a league in

the pain
“We would switch, but not if it means re-typing ten rosters and a scoring table.”
the fix
Import from Sleeper by league id, ESPN by id or URL, or a pasted recap. Preview, then commit; whoever commits is the commissioner, and nothing syncs back.
what you run
the import flow
/new → Import → Sleeper → paste 1255972181892935680
review teams, scoring, rosters → Commit
what comes back
without confirm · engine text
importLeague without confirm
  → Error: importLeague requires confirm: true
why you can trust it
  • No manager emails are pulled from any source. The commissioner types the invite allowlist, which is not on MCP.
  • exportLeague returns a JSON snapshot at any time.

Let it run itself

the pain
“I don’t want to be the one who forgets to process waivers.”
the fix
An hourly tick refreshes scoring, advances weeks with the NFL schedule, and clears waivers on Wednesday. Season-wide moves still need the commissioner’s confirm.
what you run
.env · one clock source
OPENLEAGUES_SELF_TICK=1      # a long-lived box ticks itself; on Vercel, vercel.json's cron is the clock
what comes back
engine text
advanceWeek without confirm
  → Error: advanceWeek requires confirm: true

tick over MCP
  → Error: tick is a cron clock, not a tool

# what the clock does on its own, every hour
#   scoring refresh · week advances with the NFL schedule · waivers clear Wednesday

Give an agent a key

the pain
“I want my agent to set lineups without the power to wreck the league.”
the fix
Tokens are minted read or act on your own box, from the account page or a shell. A read token cannot move a player; an act token has your seat’s powers, one purse, and confirm on season-wide moves.
what you run
mint from a shell · this box's database
bun scripts/ledger.mjs mintToken --write --user usr_… --name codex --scope read
what comes back
mintToken · shape
{
  "id": "…",
  "token": "ol_…",          ← shown once; only its hash is stored
  "prefix": "ol_…",
  "scope": "read",
  "note": "Copy the token now — only its hash is stored. Revoke from /account or by id."
}
bun scripts/ledger.mjs --help
bun scripts/ledger.mjs --help

Ledger CLI. Reads by default; writes need --write (placeWager, mintToken).

Usage:
  bun scripts/ledger.mjs --list
  bun scripts/ledger.mjs getEvents --league <id> [--limit n] [--sinceWeek n]
  bun scripts/ledger.mjs getLeagueFacts --league <id> --week <n>
…
why you can trust it
  • Your box issues and checks tokens against its own table; nothing is central. They are hashed at rest and shown once. An act token cannot fade its own team.
  • OPENLEAGUES_MCP_AUTH=proxy trusts a user-id header from your edge instead, and x-openleagues-scope: read narrows a caller.

Prove what the agent did

the pain
“The agent says it set a good lineup, and I have only its word.”
the fix
Every act-token write is logged as an event carrying the token’s name, and the receipt shows the agent’s line beside yours. Disallowed calls fail whole, with the reason in plain text.
what you run
two verbs over MCP
getAgentContext { "leagueId": "lg_…" }     # start every session here: seat, purse, week, the tools you may call
startPlayer     { "leagueId": "lg_…", "playerId": "4866", "slot": "RB" }
what comes back
refusals · engine text
startPlayer with a read token
  → Error: startPlayer is a write; this token is read-only

importLeague without confirm
  → Error: importLeague requires confirm: true

asking the clock to run
  → Error: tick is a cron clock, not a tool

reading a league-box league without a seat
  → Unauthorized · 401
why you can trust it
  • Two checks gate every call: a user id on every write and a seat check on every hosted read. Browser, CLI, and agent get the same rule.
  • Raw Sleeper ids need no seat. That data is already public.

Say a sentence

the pain
“I don’t want to learn 99 verbs to set a lineup.”
the fix
Six skills ship in the repo. One sentence runs the chain and stops where a human must confirm.
what you run
install · once
npx skills add ryanwaits/open-leagues -g                 # all six, Claude Code
npx skills add ryanwaits/open-leagues --skill open-leagues-lab-discover -g
npx skills add ryanwaits/open-leagues --agent codex -g   # Codex
› “set my lineup for the bye weeks”
getAgentContext→getTeam→getWeekProjections→confirm→sitPlayer→startPlayer
open-leagues-lineup · never invents a projection · pauses for confirm before writes
› “bring over my Sleeper league”
previewImport→confirm→importLeague · confirm:true
open-leagues-migrate · same pair for ESPN and rebuild · file/paste is option 2
› “what's going on this week”
getAgentContext→getMatchups→getTeam→getWire
open-leagues-week · read-only digest · no writes
› “put $12 on us and pull my other ticket”
getAgentContext→getBook→confirm stake→placeWager→pullWager
open-leagues-book · whole dollars · cannot fade your own roster
› “is there anything to home dogs the public is on?”
getBettingSplits→sampleGames · discovery seasons→evaluateBets→summarizeRun→sampleGames · holdout→summarizeRun · pBreakEven→simulateBankroll→freezeStrategy
open-leagues-lab-discover · tunes on some seasons, verifies on others · freezes only what clears the holdout · places no bet
› “run my lab for last week”
getStrategy→getLabRuns→sampleGames · last week→evaluateBets→summarizeRun→simulateBankroll · season→recordLabRun
open-leagues-lab-run · frozen rule, paper stakes, digest · no placeWager