{
  "openapi": "3.1.0",
  "info": {
    "title": "Chessfolio API",
    "version": "1.4.0",
    "description": "Owner-scoped personal chess data for the authenticated Chessfolio user: stats, rating progress, games (list, detail, PGN attachment and review requests), problem opening lines, the latest weekly report, puzzle activity, and a personal study collection separate from the user's own played games — upload a PGN to study (a classic, a friend's game), list it, request the same engine review, and delete it again.\n\n**Auth:** personal access token (PAT), created at chessfolio.io → Settings → API access, sent as `Authorization: Bearer cfp_…`. Tokens are shown once at creation and revocable at any time.\n\n**Scope:** every endpoint is limited to the token owner's own data. Five write operations exist. Two are scoped to one owned game, exactly as before: `POST /api/v1/me/games/{id}` accepts an exact owned game id and a JSON body containing only one PGN string; `POST /api/v1/me/games/{id}/review` queues (or, with an optional bounded `wait`, waits for) an engine review of that game, returning 202 while queued and 200 once complete. The other three work over the separate personal study collection: `POST /api/v1/me/study` accepts a JSON body containing only one PGN string and creates a new study-collection row — not an existing owned game, since the study collection holds games the user wants to study rather than games the user played; `POST /api/v1/me/study/{id}/review` queues (or waits for) an engine review of that stored study game, exactly like the games-review endpoint; and `DELETE /api/v1/me/study/{id}` removes one stored study game and its per-ply notes from the collection — the collection row only, never the underlying analysed game record. The four non-delete writes accept PGN text or a wait duration only — never a URL, path or arbitrary file — the delete accepts nothing but an owned study-game id in the path, and none of the five can edit a game's result, rating or metadata, or touch another user's data.\n\n**Rate limit:** 120 requests/minute per user, shared with the MCP server at /api/mcp. Four capabilities are tighter, each in its own bucket and each shared across REST and MCP: PGN attachments allow 20/hour; newly queued reviews 20/hour — one allowance shared between game reviews and study-game analyses, since both spend the same underlying engine time (an already-reviewed game or study game returns its existing review free and does not spend it); study-collection uploads have their own, separate 20/hour ceiling; and the two per-ply analysis reads, `/analysis` and `/critical-moments`, 30/minute, because they replay every move through a chess engine rather than answering from a query. The review, study-upload and analysis buckets are all enforced atomically and fail closed: if any of those three limiters is unavailable the endpoint returns 503 rather than proceeding unbounded.\n\n**MCP:** these sixteen personal capabilities are also exposed as MCP tools at `https://chessfolio.io/api/mcp` (streamable HTTP, same bearer token), alongside six token-less public tools — three tournament lookups, an ECF rating calculator, and two reads over a curated classic-games library — 22 in total. See /.well-known/mcp.json.",
    "contact": {
      "url": "https://chessfolio.io/developers"
    }
  },
  "servers": [
    {
      "url": "https://chessfolio.io"
    }
  ],
  "security": [
    {
      "pat": []
    }
  ],
  "components": {
    "securitySchemes": {
      "pat": {
        "type": "http",
        "scheme": "bearer",
        "description": "Personal access token from chessfolio.io → Settings → API access. Format `cfp_` + 43 url-safe chars."
      }
    },
    "responses": {
      "Unauthorised": {
        "description": "Missing, invalid or revoked token.",
        "content": {
          "application/json": {
            "example": {
              "error": "Invalid or revoked token."
            }
          }
        }
      },
      "RateLimited": {
        "description": "More than 120 requests in a minute, or more than 30 in a minute on the two per-ply analysis reads.",
        "content": {
          "application/json": {
            "example": {
              "error": "Rate limit exceeded — try again in a minute."
            }
          }
        }
      },
      "TournamentRateLimited": {
        "description": "More than 60 requests in a minute from one IP (the public tournament limit).",
        "content": {
          "application/json": {
            "example": {
              "error": "Rate limit exceeded — try again in a minute."
            }
          }
        }
      },
      "TournamentUpstream": {
        "description": "The tournament source could not be read — Chess-Results changed its page layout, or the upstream fetch failed. The parser fails loudly upstream rather than returning half-parsed data; the web layer surfaces a fixed, caller-safe message.",
        "content": {
          "application/json": {
            "example": {
              "error": "Tournament data source is unavailable."
            }
          }
        }
      },
      "TournamentBusy": {
        "description": "The global outbound courtesy cap to Chess-Results was reached. Retryable.",
        "content": {
          "application/json": {
            "example": {
              "error": "Rate limited upstream, try again shortly."
            }
          }
        }
      },
      "TournamentNoPairing": {
        "description": "The Dutch engine found no legal pairing for the requested round — e.g. the round is already fully played, or too few players remain available.",
        "content": {
          "application/json": {
            "example": {
              "error": "No legal pairing could be estimated for this round."
            }
          }
        }
      },
      "PublicRateLimited": {
        "description": "More than 60 requests in a minute from one caller (the public limit).",
        "content": {
          "application/json": {
            "example": {
              "error": "Rate limit exceeded — try again in a minute."
            }
          }
        }
      }
    }
  },
  "paths": {
    "/api/v1/me": {
      "get": {
        "operationId": "getProfile",
        "summary": "Profile & sync status",
        "description": "The token owner's profile: display name, linked platform usernames, each connected source (chess.com / Lichess / ECF) with its status and last-synced time, and total game count. Use this first to learn which sources exist and how fresh the data is — every other endpoint reads the same synced store, so `lastSyncedAt` bounds the freshness of everything.",
        "responses": {
          "200": {
            "description": "The profile.",
            "content": {
              "application/json": {
                "example": {
                  "displayName": "Tim Bland",
                  "email": "tim@example.com",
                  "memberSince": "2026-01-01T00:00:00Z",
                  "usernames": {
                    "chesscom": "timb",
                    "lichess": "timb"
                  },
                  "connections": [
                    {
                      "provider": "chesscom",
                      "status": "active",
                      "lastSyncedAt": "2026-07-20T02:30:00Z",
                      "connectedAt": "2026-06-01T00:00:00Z"
                    }
                  ],
                  "totals": {
                    "games": 23009,
                    "connections": 3
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/stats": {
      "get": {
        "operationId": "getChessStats",
        "summary": "Aggregate chess statistics",
        "description": "Aggregate statistics over the user's games for the chosen window — the same compute the chessfolio.io dashboard runs: win/draw/loss split by colour, online vs over-the-board comparison (with per-group accuracy and opponent strength), performance rating, best win / worst defeat and streaks, last-10 form, weekday performance, opponent-strength breakdown, game-length breakdown, top-20 openings, monthly form, and average effective accuracy. `capped: true` means the window exceeded 10,000 games and aggregates cover the most recent 10,000.",
        "parameters": [
          {
            "name": "range",
            "in": "query",
            "required": false,
            "description": "Time window: `30d`, `90d`, `1y` (default) or `all`.",
            "schema": {
              "type": "string",
              "enum": [
                "30d",
                "90d",
                "1y",
                "all"
              ]
            }
          },
          {
            "name": "provider",
            "in": "query",
            "required": false,
            "description": "Comma-separated sources to include: `chesscom`, `lichess`, `ecf` (OTB). Omit for all.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "timeClass",
            "in": "query",
            "required": false,
            "description": "Comma-separated time-control buckets: `bullet`, `blitz`, `rapid`, `standard`. Omit for all.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "opening",
            "in": "query",
            "required": false,
            "description": "Opening-family prefix filter, e.g. `Sicilian`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Aggregate blocks keyed by concern.",
            "content": {
              "application/json": {
                "example": {
                  "filters": {
                    "range": "1y",
                    "providers": null,
                    "timeClasses": null,
                    "opening": null
                  },
                  "window": {
                    "from": "2025-07-21",
                    "to": null
                  },
                  "games": 4664,
                  "capped": false,
                  "totals": {
                    "white": {
                      "win": 1200,
                      "draw": 200,
                      "loss": 900,
                      "total": 2300,
                      "winRate": 52.2
                    },
                    "black": {
                      "win": 1050,
                      "draw": 250,
                      "loss": 1064,
                      "total": 2364,
                      "winRate": 44.4
                    },
                    "overall": {
                      "win": 2250,
                      "draw": 450,
                      "loss": 1964,
                      "total": 4664,
                      "winRate": 48.2
                    }
                  },
                  "accuracy": {
                    "games": 4069,
                    "average": 78.4
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/ratings": {
      "get": {
        "operationId": "getRatingProgress",
        "summary": "Rating progress",
        "description": "Rating series per provider and time control (including ECF over-the-board), each with `start`, `end` and `delta` over the window — the same lines the chessfolio.io dashboard chart draws. Honesty rules: series longer than 60 points are evenly downsampled (first and last points always kept) and flagged with `pointsDownsampled: true`; and the window's opening value is seeded from the latest pre-window rating, so a player who last played before the window still enters it at their standing rating and `delta` matches the dashboard instead of measuring from the first in-window snapshot.",
        "parameters": [
          {
            "name": "range",
            "in": "query",
            "required": false,
            "description": "Time window: `30d`, `90d`, `1y` (default) or `all`.",
            "schema": {
              "type": "string",
              "enum": [
                "30d",
                "90d",
                "1y",
                "all"
              ]
            }
          },
          {
            "name": "provider",
            "in": "query",
            "required": false,
            "description": "Comma-separated sources to include: `chesscom`, `lichess`, `ecf` (OTB). Omit for all.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "timeClass",
            "in": "query",
            "required": false,
            "description": "Comma-separated time-control buckets: `bullet`, `blitz`, `rapid`, `standard`. Omit for all.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One line per provider × time-control combination present in the window.",
            "content": {
              "application/json": {
                "example": {
                  "filters": {
                    "range": "1y",
                    "providers": null,
                    "timeClasses": null,
                    "opening": null
                  },
                  "window": {
                    "from": "2025-07-21",
                    "to": null
                  },
                  "lines": [
                    {
                      "provider": "lichess",
                      "timeClass": "blitz",
                      "points": [
                        {
                          "date": "2025-07-21",
                          "rating": 1493
                        },
                        {
                          "date": "2026-07-18",
                          "rating": 1541
                        }
                      ],
                      "pointsDownsampled": true,
                      "start": 1493,
                      "end": 1541,
                      "delta": 48
                    },
                    {
                      "provider": "ecf",
                      "timeClass": "standard",
                      "points": [
                        {
                          "date": "2025-08-01",
                          "rating": 1612
                        },
                        {
                          "date": "2026-07-01",
                          "rating": 1630
                        }
                      ],
                      "pointsDownsampled": false,
                      "start": 1612,
                      "end": 1630,
                      "delta": 18
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/games": {
      "get": {
        "operationId": "listGames",
        "summary": "List games",
        "description": "Paged list of the user's games (50 per page, newest first by default) across chess.com, Lichess, ECF (OTB), manual and PGN imports. Filters match the chessfolio.io games library exactly: provider, result, colour, time class, date range, move-count range, opening prefix, opponent contains, SAN opening-line prefix (`lineMoves` — use a value returned by /api/v1/me/problem-lines to drill into a problem line) and free-text search (`q`). Each game's `accuracy` is the effective accuracy — the platform's own value preferred, chessfolio's engine-review value as fallback — and `accuracySource` says which you are looking at (`platform`, `review` or null when neither exists). A `page` beyond the last page falls back to page 1 rather than erroring.",
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "1-based page number (50 games per page). Out-of-range values fall back to page 1.",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort order: `date-desc` (default), `date-asc`, `accuracy-desc`, `accuracy-asc`. Accuracy sorts use effective accuracy.",
            "schema": {
              "type": "string",
              "enum": [
                "date-desc",
                "date-asc",
                "accuracy-desc",
                "accuracy-asc"
              ]
            }
          },
          {
            "name": "provider",
            "in": "query",
            "required": false,
            "description": "Comma-separated sources to include: `chesscom`, `lichess`, `ecf`, `manual`, `pgn`. Omit for all.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "result",
            "in": "query",
            "required": false,
            "description": "Comma-separated results to include: `win`, `draw`, `loss`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "colour",
            "in": "query",
            "required": false,
            "description": "Comma-separated colours the user played: `white`, `black`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "timeClass",
            "in": "query",
            "required": false,
            "description": "Comma-separated RAW time classes (unlike the dashboard's four buckets): `ultraBullet`, `bullet`, `blitz`, `rapid`, `classical`, `daily`, `correspondence`, `standard`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Inclusive lower date bound, `YYYY-MM-DD` (UTC). An inverted from/to pair drops both bounds.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Inclusive upper date bound, `YYYY-MM-DD` (UTC).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "movesMin",
            "in": "query",
            "required": false,
            "description": "Minimum full-move count (inclusive).",
            "schema": {
              "type": "integer",
              "minimum": 0
            }
          },
          {
            "name": "movesMax",
            "in": "query",
            "required": false,
            "description": "Maximum full-move count (inclusive).",
            "schema": {
              "type": "integer",
              "minimum": 0
            }
          },
          {
            "name": "opening",
            "in": "query",
            "required": false,
            "description": "Opening-name prefix filter, e.g. `Sicilian` matches the family and all its variations.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "opponent",
            "in": "query",
            "required": false,
            "description": "Opponent-name contains filter (case-insensitive).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "lineMoves",
            "in": "query",
            "required": false,
            "description": "Space-separated SAN opening-line prefix, exactly as returned in a problem line's `lineMoves` (case-sensitive — SAN casing is meaningful).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Free-text search over opponent, opening, event and ECO code.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One page of games plus paging info and the parsed-filter echo.",
            "content": {
              "application/json": {
                "example": {
                  "filters": {
                    "q": null,
                    "providers": null,
                    "results": null,
                    "colours": null,
                    "timeClasses": null,
                    "from": null,
                    "to": null,
                    "movesMin": null,
                    "movesMax": null,
                    "opening": null,
                    "opponent": null,
                    "lineMoves": null,
                    "sort": "date-desc"
                  },
                  "page": 1,
                  "pageCount": 94,
                  "total": 4664,
                  "pageSize": 50,
                  "games": [
                    {
                      "id": "6a3b0c9e-…",
                      "provider": "lichess",
                      "playedAt": "2026-07-18T19:42:00Z",
                      "colour": "white",
                      "result": "win",
                      "opponent": "opponent42",
                      "opponentRating": 1480,
                      "userRating": 1502,
                      "ratingDelta": 8,
                      "timeClass": "blitz",
                      "rated": true,
                      "eco": "B01",
                      "opening": "Scandinavian Defense",
                      "event": null,
                      "movesCount": 41,
                      "accuracy": 84.2,
                      "accuracySource": "platform",
                      "sourceUrl": "https://lichess.org/abcd1234"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/games/{id}": {
      "get": {
        "operationId": "getGame",
        "summary": "Game detail",
        "description": "One of the token owner's games in full: the same summary fields as a /api/v1/me/games row, plus the game's `moves` (SAN mainline), the raw `pgn` where one is stored, and a `review` summary where a Chessfolio analysis exists. `movesSource` is honest about what the moves are: `pgn` when the full game is stored (reviewed or PGN-attached games), `opening-only` when only the recorded opening line is known, or `null` for an OTB/online game that has no attached PGN — that row carries no moves at all and none are fabricated. `review` (null unless analysed) trims the heavy per-ply data: it carries `accuracyWhite`/`accuracyBlack`/`accuracyForUser`, the `criticalMoments` list and a `classificationCounts` histogram, but not `moveEvals`. `id` is a game id exactly as returned by /api/v1/me/games. Owner-scoped: a game id that is not yours, or does not exist, both return 404 identically.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Game id, exactly as returned in a /api/v1/me/games row's `id`.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The game's summary, moves and (when analysed) review summary.",
            "content": {
              "application/json": {
                "example": {
                  "id": "6a3b0c9e-…",
                  "provider": "lichess",
                  "playedAt": "2026-07-18T19:42:00Z",
                  "colour": "white",
                  "result": "win",
                  "opponent": "opponent42",
                  "opponentRating": 1480,
                  "userRating": 1502,
                  "ratingDelta": 8,
                  "timeClass": "blitz",
                  "rated": true,
                  "eco": "B01",
                  "opening": "Scandinavian Defense",
                  "event": null,
                  "movesCount": 41,
                  "accuracy": 84.2,
                  "accuracySource": "platform",
                  "sourceUrl": "https://lichess.org/abcd1234",
                  "moves": [
                    "e4",
                    "d5",
                    "exd5",
                    "Qxd5",
                    "Nc3",
                    "Qa5"
                  ],
                  "movesSource": "pgn",
                  "pgn": "[Event \"Rated Blitz game\"]\n\n1. e4 d5 2. exd5 Qxd5 3. Nc3 Qa5 …",
                  "review": {
                    "accuracyWhite": 84.2,
                    "accuracyBlack": 79.1,
                    "accuracyForUser": 84.2,
                    "depth": 18,
                    "engineVersion": "sf16",
                    "analysedAt": "2026-07-19T09:00:00Z",
                    "classificationCounts": {
                      "book": 6,
                      "brilliant": 0,
                      "best": 18,
                      "great": 1,
                      "good": 9,
                      "inaccuracy": 3,
                      "mistake": 1,
                      "miss": 0,
                      "blunder": 1
                    },
                    "criticalMoments": [
                      {
                        "ply": 42,
                        "move": "Qxf2",
                        "type": "blunder",
                        "evalSwing": 320,
                        "description": "Drops the queen to a fork."
                      }
                    ]
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "404": {
            "description": "No game with that id belongs to the token owner (a non-owned id and an unknown id are indistinguishable).",
            "content": {
              "application/json": {
                "example": {
                  "error": "Game not found."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "operationId": "attachPgn",
        "summary": "Attach a PGN",
        "description": "Attach one complete PGN to an exact game row owned by the token holder. The id must come from listGames; Chessfolio does not guess or fuzzy-match the destination. The PGN is validated as one parseable game with at least one move, stored content-addressably, and linked by changing only the attachment pointer — results, ratings and game metadata are never edited. This makes the game reviewable but does not start engine analysis. Repeating the request is idempotent: an existing attachment is preserved and returned with alreadyAttached=true. A separate mutation limit allows 20 attachments per hour.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Game id, exactly as returned by listGames.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "pgn"
                ],
                "properties": {
                  "pgn": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 100000,
                    "description": "One complete PGN with movetext."
                  }
                },
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The PGN is attached, or the game's existing attachment was preserved.",
            "content": {
              "application/json": {
                "example": {
                  "attached": true,
                  "id": "6a3b0c9e-…",
                  "refId": "b7e4a1d2-…",
                  "alreadyAttached": false,
                  "note": "PGN attached. Engine analysis was not started."
                }
              }
            }
          },
          "400": {
            "description": "The JSON is not exactly `{ pgn: string }`, or the PGN is multi-game, moveless, unparseable or contains unsupported controls."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "404": {
            "description": "No game with that id belongs to the token owner."
          },
          "409": {
            "description": "A concurrent attachment changed the row; fetch it again before retrying."
          },
          "413": {
            "description": "The actual or declared request body exceeds the bounded JSON envelope."
          },
          "415": {
            "description": "Content-Type is not application/json."
          },
          "429": {
            "description": "The shared 120/minute API limit or stricter 20/hour PGN mutation limit was exceeded."
          }
        }
      }
    },
    "/api/v1/me/games/{id}/analysis": {
      "get": {
        "operationId": "getGameAnalysis",
        "summary": "Per-ply game analysis",
        "description": "Move-by-move engine analysis of one reviewed game owned by the token holder — the deterministic detail behind the trimmed `review` on getGame. Each ply carries the position before and after (FEN), the played move and the engine's best move in both SAN and UCI, evaluations before and after, centipawn loss, classification, the principal variation, whether the position was forced, the only-move margin, and clock/think-time where the game carries clock readings. Every move is replayed and checked for legality before it is returned; a stored line that will not fully replay is truncated at its last legal move and `principalVariationTruncated` says so. Paginated over plies — `limit` defaults to 40 and is capped at 120, `pagination.nextFromPly` continues, and `side=user` filters to the token owner's own moves. Each ply also carries `phase` (opening, middlegame or endgame, lichess-divider-v1) and `concepts`: deterministic v1 tags (hanging_piece, missed_capture, missed_mate, allowed_mate, fork, pin, back_rank, forcing_move_missed, opening_principle, time_trouble) with evidence, derived from the stored analysis (the ply's classification included), the game's clock readings and legal-move replay, and never from a language model. `availability` names the fields this deployment cannot populate and why: human difficulty is not modelled, threat detection is partial (only the concepts listed), and clocks are absent for games stored before clock capture and for sources that publish none. Read-only — it serves the stored review, starts no engine work and spends no review quota. An owned game with no review returns 200 with `analysed: false` and the next step, because \"not reviewed yet\" is a state of a real game and must not look like a wrong id.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Game id, exactly as returned in a /api/v1/me/games row's `id`.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "fromPly",
            "in": "query",
            "required": false,
            "description": "First ply to return, 1-based (ply 1 is White's first move). Use `pagination.nextFromPly` from a previous response to page. Out-of-range values degrade to 1 rather than erroring; `pagination` echoes what was applied.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 1
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "How many plies to return. Clamped to 1-120.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 120,
              "default": 40
            }
          },
          {
            "name": "side",
            "in": "query",
            "required": false,
            "description": "`user` returns only the token owner's own moves. Ignored when the game row records no colour for them.",
            "schema": {
              "type": "string",
              "enum": [
                "both",
                "user"
              ],
              "default": "both"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One page of per-ply analysis, or `analysed: false` when the game has no review.",
            "content": {
              "application/json": {
                "example": {
                  "analysed": true,
                  "gameId": "6a3b0c9e-…",
                  "provenance": {
                    "source": "chessfolio-engine",
                    "engineVersion": "stockfish-18",
                    "depth": 18,
                    "analysedAt": "2026-08-10T12:22:15.510Z",
                    "generatedBy": "deterministic"
                  },
                  "conventions": {
                    "evalFrame": "side-to-move",
                    "moverFrame": "mover",
                    "capCentipawns": 500,
                    "notes": {
                      "evalFrame": "evalBefore and evalAfter are raw engine output, side-to-move relative. After a move the side to move is the OPPONENT, so evalAfter is in their frame and a raw evalBefore - evalAfter is not the loss. evalBeforeMover and evalAfterMover restate both in the mover's frame, where the subtraction holds.",
                      "capCentipawns": "centipawnLoss and evalSwingCentipawns are computed on evaluations clamped to ±500 centipawns, so on a ply where either raw evaluation exceeds that, the published loss is smaller than the mover-frame difference. capApplied on each ply says whether this affects it.",
                      "lossReconciles": "Do not assume centipawnLoss equals evalBeforeMover - evalAfterMover. It does on most plies and does not when the cap bit, when the played move was the engine's own first choice (the loss is forced to zero rather than reporting search noise), when the raw arithmetic went negative and was clamped to zero, or when either evaluation is a mate score rather than centipawns. Each ply carries lossReconciles, measured against the published numbers rather than inferred from that list — trust the field, not the reasons."
                    }
                  },
                  "accuracyWhite": 93.47,
                  "accuracyBlack": 89.76,
                  "userColour": "white",
                  "timeControl": {
                    "raw": "180+2",
                    "baseSeconds": 180,
                    "incrementSeconds": 2
                  },
                  "plyCount": 97,
                  "availability": {
                    "clocks": true,
                    "timeSpent": true,
                    "alternatives": true,
                    "concepts": true,
                    "humanDifficulty": false,
                    "threats": false,
                    "conceptDetectors": {
                      "detectors": [
                        "hanging_piece",
                        "missed_capture",
                        "missed_mate",
                        "allowed_mate",
                        "fork",
                        "pin",
                        "back_rank",
                        "forcing_move_missed",
                        "opening_principle",
                        "time_trouble"
                      ],
                      "version": "v1"
                    },
                    "notes": {
                      "concepts": "Deterministic concept tags, detector set v1: hanging_piece, missed_capture, missed_mate, allowed_mate, fork, pin, back_rank, forcing_move_missed, opening_principle, time_trouble. …"
                    }
                  },
                  "pagination": {
                    "fromPly": 21,
                    "limit": 40,
                    "side": "both",
                    "returned": 40,
                    "matching": 77,
                    "totalPlies": 97,
                    "nextFromPly": 61
                  },
                  "plies": [
                    {
                      "ply": 21,
                      "moveNumber": 11,
                      "colour": "white",
                      "isUserMove": true,
                      "fenBefore": "r2q1rk1/pp1nbpp1/2ppb2p/4p3/2P1n3/2NPP1P1/PP3PBP/R1BQ1RK1 w - - 0 11",
                      "fenAfter": "r2q1rk1/pp1nbpp1/2ppb2p/4p3/2P1N3/3PP1P1/PP3PBP/R1BQ1RK1 b - - 0 11",
                      "playedMove": {
                        "san": "Nxe4",
                        "uci": "c3e4"
                      },
                      "playedMoveLegal": true,
                      "bestMove": {
                        "san": "Bxe4",
                        "uci": "g2e4"
                      },
                      "isBestMove": false,
                      "alternatives": [
                        {
                          "move": {
                            "san": "Nxe4",
                            "uci": "c3e4"
                          },
                          "eval": {
                            "type": "cp",
                            "value": 75,
                            "pawns": 0.75,
                            "mateIn": null
                          },
                          "line": [
                            "Nxe4",
                            "d5"
                          ],
                          "lineUci": [
                            "c3e4",
                            "d6d5"
                          ]
                        }
                      ],
                      "evalBefore": {
                        "type": "cp",
                        "value": -81,
                        "pawns": -0.81,
                        "mateIn": null
                      },
                      "evalAfter": {
                        "type": "cp",
                        "value": 80,
                        "pawns": 0.8,
                        "mateIn": null
                      },
                      "evalBeforeMover": {
                        "type": "cp",
                        "value": -81,
                        "pawns": -0.81,
                        "mateIn": null
                      },
                      "evalAfterMover": {
                        "type": "cp",
                        "value": -80,
                        "pawns": -0.8,
                        "mateIn": null
                      },
                      "centipawnLoss": 0,
                      "capApplied": false,
                      "lossReconciles": false,
                      "classification": "good",
                      "principalVariation": [
                        "Bxe4",
                        "Nf6",
                        "Bg2",
                        "d5"
                      ],
                      "principalVariationUci": [
                        "g2e4",
                        "d7f6",
                        "e4g2",
                        "d6d5"
                      ],
                      "principalVariationTruncated": false,
                      "onlyMoveMargin": 0.0054,
                      "forcedMove": false,
                      "clockSecondsBefore": 154.2,
                      "clockSecondsAfter": 148.9,
                      "timeSpentSeconds": 7.3,
                      "phase": "middlegame",
                      "concepts": [],
                      "humanDifficulty": null
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "404": {
            "description": "No game with that id belongs to the token owner (a non-owned id and an unknown id are indistinguishable).",
            "content": {
              "application/json": {
                "example": {
                  "error": "Game not found."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/games/{id}/critical-moments": {
      "get": {
        "operationId": "getCriticalMoments",
        "summary": "Coach-ready critical moments",
        "description": "The teachable positions from one reviewed game owned by the token holder, so a coach does not have to read every ply to find what is worth discussing. Each moment carries the position (FEN) and side to move, the played and best moves in SAN and UCI, the continuation the engine wanted, and a `refutation` — the engine's own best line from the position the played move actually produced, which is how it should have been punished. Also severity, the evaluation swing, a summary templated from those numbers (`summarySource: \"template\"` — no language model is involved anywhere in this response), progressive hints that narrow without naming the move, a training question, and `acceptableAnswers` (the engine's first choice plus any stored alternative within 0.25 pawns of it). Each moment also carries `phase` (lichess-divider-v1) and `concepts`, the same deterministic v1 detector set as the analysis endpoint (hanging_piece, missed_capture, missed_mate, allowed_mate, fork, pin, back_rank, forcing_move_missed, opening_principle, time_trouble), each with its evidence; a moment whose ply is missing from the stored per-ply analysis reports `phase: null` and an empty concepts list, there being no position to divide or replay. Every move returned is legality-checked first. `matchesKnownWeakness` is always null rather than false: cross-game recurring-weakness detection is not built, and an absent match must not be read as a checked-and-clear one. Read-only; an owned game with no review returns 200 with `analysed: false`.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Game id, exactly as returned in a /api/v1/me/games row's `id`.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "side",
            "in": "query",
            "required": false,
            "description": "`user` returns only the token owner's own moments.",
            "schema": {
              "type": "string",
              "enum": [
                "both",
                "user"
              ],
              "default": "both"
            }
          },
          {
            "name": "minSeverity",
            "in": "query",
            "required": false,
            "description": "Drop moments below this severity. Brilliancies are exempt, being the opposite of a mistake rather than a milder one.",
            "schema": {
              "type": "string",
              "enum": [
                "moderate",
                "major",
                "critical"
              ]
            }
          },
          {
            "name": "minCentipawnLoss",
            "in": "query",
            "required": false,
            "description": "Also surface any ply conceding at least this many centipawns, even where the engine recorded no moment. The engine's gate is a 15% win-probability loss, which in a decided or quiet position can pass over a 1.5-pawn error, so this ADDS moments rather than filtering them; each is marked `source: \"derived\"` with a null `type`.",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          },
          {
            "name": "maxMoments",
            "in": "query",
            "required": false,
            "description": "Cap on moments returned. When more match than fit, the most severe are kept and returned in ply order; `matching` and `truncated` report what was left out.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 40,
              "default": 40
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The game's critical moments, or `analysed: false` when it has no review.",
            "content": {
              "application/json": {
                "example": {
                  "analysed": true,
                  "gameId": "6a3b0c9e-…",
                  "provenance": {
                    "source": "chessfolio-engine",
                    "engineVersion": "stockfish-18",
                    "depth": 18,
                    "analysedAt": "2026-08-10T16:26:56.957Z",
                    "generatedBy": "deterministic"
                  },
                  "conventions": {
                    "evalFrame": "side-to-move",
                    "moverFrame": "mover",
                    "capCentipawns": 500,
                    "notes": {
                      "evalFrame": "evalBefore and evalAfter are raw engine output, side-to-move relative. After a move the side to move is the OPPONENT, so evalAfter is in their frame and a raw evalBefore - evalAfter is not the loss. evalBeforeMover and evalAfterMover restate both in the mover's frame, where the subtraction holds.",
                      "capCentipawns": "centipawnLoss and evalSwingCentipawns are computed on evaluations clamped to ±500 centipawns, so on a ply where either raw evaluation exceeds that, the published loss is smaller than the mover-frame difference. capApplied on each ply says whether this affects it.",
                      "lossReconciles": "Do not assume centipawnLoss equals evalBeforeMover - evalAfterMover. It does on most plies and does not when the cap bit, when the played move was the engine's own first choice (the loss is forced to zero rather than reporting search noise), when the raw arithmetic went negative and was clamped to zero, or when either evaluation is a mate score rather than centipawns. Each ply carries lossReconciles, measured against the published numbers rather than inferred from that list — trust the field, not the reasons."
                    }
                  },
                  "userColour": "black",
                  "side": "both",
                  "availability": {
                    "clocks": true,
                    "timeSpent": true,
                    "alternatives": false,
                    "concepts": true,
                    "humanDifficulty": false,
                    "threats": false,
                    "matchesKnownWeakness": false,
                    "conceptDetectors": {
                      "detectors": [
                        "hanging_piece",
                        "missed_capture",
                        "missed_mate",
                        "allowed_mate",
                        "fork",
                        "pin",
                        "back_rank",
                        "forcing_move_missed",
                        "opening_principle",
                        "time_trouble"
                      ],
                      "version": "v1"
                    },
                    "notes": {
                      "concepts": "Deterministic concept tags, detector set v1: hanging_piece, missed_capture, missed_mate, allowed_mate, fork, pin, back_rank, forcing_move_missed, opening_principle, time_trouble. …",
                      "matchesKnownWeakness": "Cross-game recurring-weakness detection is not built. …"
                    }
                  },
                  "filters": {
                    "side": "both",
                    "minSeverity": null,
                    "minCentipawnLoss": null,
                    "maxMoments": 40
                  },
                  "count": 2,
                  "selection": "most-severe-first",
                  "matching": 2,
                  "matchingBySource": {
                    "engine": 2,
                    "derived": 0
                  },
                  "truncated": false,
                  "limit": 40,
                  "maxLimit": 40,
                  "moments": [
                    {
                      "ply": 32,
                      "moveNumber": 16,
                      "fen": "r4rk1/1pp2ppp/2n5/pB1p1b2/3PnB2/1Q2PN2/PP3PPP/R4RK1 b - - 0 16",
                      "sideToMove": "black",
                      "isUserMove": true,
                      "type": "blunder",
                      "source": "engine",
                      "severity": "critical",
                      "classification": "blunder",
                      "evalSwingCentipawns": 417,
                      "centipawnLoss": 417,
                      "evalBefore": {
                        "type": "cp",
                        "value": 12,
                        "pawns": 0.12,
                        "mateIn": null
                      },
                      "evalAfter": {
                        "type": "cp",
                        "value": 405,
                        "pawns": 4.05,
                        "mateIn": null
                      },
                      "evalBeforeMover": {
                        "type": "cp",
                        "value": 12,
                        "pawns": 0.12,
                        "mateIn": null
                      },
                      "evalAfterMover": {
                        "type": "cp",
                        "value": -405,
                        "pawns": -4.05,
                        "mateIn": null
                      },
                      "capApplied": false,
                      "lossReconciles": true,
                      "playedMove": {
                        "san": "Rfe8",
                        "uci": "f8e8"
                      },
                      "playedMoveLegal": true,
                      "bestMove": {
                        "san": "Nxf2",
                        "uci": "e4f2"
                      },
                      "continuation": {
                        "line": [
                          "Nxf2",
                          "Rxf2",
                          "Bc2"
                        ],
                        "lineUci": [
                          "e4f2",
                          "f1f2",
                          "f5c2"
                        ],
                        "truncated": false
                      },
                      "refutation": {
                        "line": [
                          "Bxc6",
                          "bxc6",
                          "Ne5"
                        ],
                        "lineUci": [
                          "b5c6",
                          "b7c6",
                          "f3e5"
                        ],
                        "eval": {
                          "type": "cp",
                          "value": 405,
                          "pawns": 4.05,
                          "mateIn": null
                        },
                        "evalFrame": "opponent",
                        "note": "The engine's best continuation from the position the played move produced. The evaluation is from the opponent's point of view, since it is their move."
                      },
                      "summary": "Black played Rfe8, losing 4.17 pawns of evaluation. From Black's point of view the evaluation moved from +0.12 to -4.05. The engine preferred Nxf2.",
                      "engineDescription": "Black played Rfe8, a 33% win probability loss",
                      "summarySource": "template",
                      "hints": [
                        "Black to play. There is something better than the move played here.",
                        "Look at the kingside.",
                        "The move to find is a knight move."
                      ],
                      "trainingQuestion": "Black to play. Find the strongest move.",
                      "acceptableAnswers": [
                        {
                          "san": "Nxf2",
                          "uci": "e4f2",
                          "note": "engine's first choice"
                        }
                      ],
                      "clockSecondsBefore": 88.4,
                      "timeSpentSeconds": 6.1,
                      "phase": "middlegame",
                      "concepts": [
                        {
                          "id": "forcing_move_missed",
                          "evidence": {
                            "bestMove": "Nxf2",
                            "kind": "capture"
                          },
                          "detector": "v1"
                        }
                      ],
                      "humanDifficulty": null,
                      "matchesKnownWeakness": null
                    },
                    {
                      "ply": 44,
                      "moveNumber": 22,
                      "fen": null,
                      "sideToMove": "black",
                      "isUserMove": true,
                      "type": "blunder",
                      "source": "engine",
                      "severity": "critical",
                      "classification": null,
                      "evalSwingCentipawns": 260,
                      "centipawnLoss": null,
                      "evalBefore": null,
                      "evalAfter": null,
                      "evalBeforeMover": null,
                      "evalAfterMover": null,
                      "capApplied": false,
                      "lossReconciles": false,
                      "playedMove": {
                        "san": "Rd8",
                        "uci": null
                      },
                      "playedMoveLegal": false,
                      "bestMove": null,
                      "continuation": null,
                      "refutation": null,
                      "summary": "Black played Rd8, losing 2.60 pawns of evaluation.",
                      "engineDescription": "Black played Rd8, a 21% win probability loss",
                      "summarySource": "template",
                      "hints": [],
                      "trainingQuestion": null,
                      "acceptableAnswers": [],
                      "clockSecondsBefore": null,
                      "timeSpentSeconds": null,
                      "phase": null,
                      "concepts": [],
                      "humanDifficulty": null,
                      "matchesKnownWeakness": null
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "404": {
            "description": "No game with that id belongs to the token owner.",
            "content": {
              "application/json": {
                "example": {
                  "error": "Game not found."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/games/{id}/review": {
      "post": {
        "operationId": "requestGameReview",
        "summary": "Request a game review",
        "description": "Ask Chessfolio to run its engine review over one exact game owned by the token holder, using an id from listGames. The game must already have moves — attach one first with the PGN endpoint if it does not. Analysis is queued and usually takes 20-40 seconds: the default response is 202 with status='queued', and the caller repeats the request with the same id to collect the finished review. An optional `wait` (seconds, 0-45) makes the server wait for completion instead, returning 200 in one call once it finishes (still 202 if the wait elapses first). A game that has already been reviewed returns its existing review immediately as 200 with alreadyReviewed=true and spends nothing against the hourly limit — repeat calls are safe and free, and keep working once the limit is exhausted, because the limit gates newly queued analysis only. Maximum 20 newly queued reviews per hour; re-analysing an already-reviewed game is not offered.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Game id, exactly as returned by listGames.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "wait": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 45,
                    "description": "Seconds to wait for a queued analysis before giving up and returning 202. Whole seconds from 0 to 45; anything outside that range is rejected with a 400, not clamped. Default 0 (return immediately)."
                  }
                },
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The review is complete — already reviewed, or finished within the requested wait.",
            "content": {
              "application/json": {
                "example": {
                  "status": "complete",
                  "id": "6a3b0c9e-…",
                  "alreadyReviewed": true,
                  "review": {
                    "accuracyWhite": 84.2,
                    "accuracyBlack": 79.1,
                    "accuracyForUser": 84.2
                  },
                  "note": "This game was already reviewed; the existing analysis was returned."
                }
              }
            }
          },
          "202": {
            "description": "Analysis was queued (or the wait elapsed before it finished). Repeat the request with the same id to collect the review.",
            "content": {
              "application/json": {
                "example": {
                  "status": "queued",
                  "id": "6a3b0c9e-…",
                  "note": "Analysis queued (usually 20-40s). Call again with this id to collect it."
                }
              }
            }
          },
          "400": {
            "description": "The JSON is not exactly `{ wait?: integer }`, `wait` is outside 0-45, or the game has no moves yet (attach a PGN first)."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "404": {
            "description": "No game with that id belongs to the token owner."
          },
          "413": {
            "description": "The declared or actual request body exceeds the bounded JSON envelope."
          },
          "415": {
            "description": "Content-Type is not application/json."
          },
          "429": {
            "description": "The shared 120/minute API limit or the separate 20/hour newly-queued-review limit was exceeded."
          },
          "502": {
            "description": "Could not reach the analysis service. Retryable."
          },
          "503": {
            "description": "The hourly review ceiling could not be evaluated, so no analysis was queued. This limit fails closed because it is the only ceiling over shared engine time. Retryable."
          }
        }
      }
    },
    "/api/v1/me/problem-lines": {
      "get": {
        "operationId": "getProblemLines",
        "summary": "Problem opening lines",
        "description": "The 'lines that keep hurting': per-colour opening lines (6–24 plies) where the user's score sits at least 8 percentage points (`thresholds.minDropPct`) below their own colour baseline over at least 5 games (`thresholds.minGames`), ranked by a struggle index of (drop × log2 of games) and capped at 8 lines across both colours. Each line carries its SAN prefix as `lineMoves` for drill-through into /api/v1/me/games, plus a ready-made `gamesUrl`. Honesty rules: OTB (ECF) games carry no move lists and are excluded from line analysis, and `hasLineData` says whether ANY analysable games exist in the window — an empty `lines` array with `hasLineData: true` genuinely means no line clears the thresholds.",
        "parameters": [
          {
            "name": "range",
            "in": "query",
            "required": false,
            "description": "Time window: `30d`, `90d`, `1y` (default) or `all`.",
            "schema": {
              "type": "string",
              "enum": [
                "30d",
                "90d",
                "1y",
                "all"
              ]
            }
          },
          {
            "name": "provider",
            "in": "query",
            "required": false,
            "description": "Comma-separated sources to include: `chesscom`, `lichess`, `ecf` (OTB). Omit for all.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "timeClass",
            "in": "query",
            "required": false,
            "description": "Comma-separated time-control buckets: `bullet`, `blitz`, `rapid`, `standard`. Omit for all.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Qualifying problem lines, worst first (may be empty).",
            "content": {
              "application/json": {
                "example": {
                  "filters": {
                    "range": "1y",
                    "providers": null,
                    "timeClasses": null,
                    "opening": null
                  },
                  "hasLineData": true,
                  "thresholds": {
                    "minGames": 5,
                    "minDropPct": 8,
                    "cap": 8
                  },
                  "note": "OTB (ECF) games carry no move lists, so they are excluded from line analysis.",
                  "lines": [
                    {
                      "colour": "black",
                      "label": "Scandinavian Defense",
                      "moves": "1.e4 d5 2.exd5 Qxd5 3.Nc3 Qa5",
                      "lineMoves": "e4 d5 exd5 Qxd5 Nc3 Qa5",
                      "games": 12,
                      "wins": 3,
                      "draws": 2,
                      "losses": 7,
                      "scorePct": 33.3,
                      "colourBaselinePct": 48.9,
                      "struggleIndex": 55.9,
                      "gamesUrl": "https://chessfolio.io/games?lineMoves=e4%20d5%20exd5%20Qxd5%20Nc3%20Qa5&colour=black"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/report/latest": {
      "get": {
        "operationId": "getLatestReport",
        "summary": "Latest weekly report",
        "description": "The user's most recent Friday weekly report as its FROZEN payload: per-source sections (chess.com / Lichess / OTB) with games, rating movement, best win and toughest defeat, plus puzzles — exactly what the email and the public share page render, never recomputed after sending. `shareUrl` links the public share page (`/r/<slug>`), shareable without a token. Responds 404 when no report exists yet: reports generate on Friday mornings, and only for weeks with activity.",
        "responses": {
          "200": {
            "description": "The latest report wrapper: week window, sent time, share URL and the frozen payload.",
            "content": {
              "application/json": {
                "example": {
                  "weekStart": "2026-07-13",
                  "weekEnd": "2026-07-19",
                  "sentAt": "2026-07-17T09:00:00Z",
                  "shareUrl": "https://chessfolio.io/r/abc123def456",
                  "report": {
                    "note": "frozen weekly-report payload, exactly as emailed and rendered at shareUrl"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "404": {
            "description": "No weekly report exists for this user yet.",
            "content": {
              "application/json": {
                "example": {
                  "error": "No weekly report yet — reports generate on Fridays for weeks with activity."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/puzzles": {
      "get": {
        "operationId": "getPuzzleStats",
        "summary": "Puzzle statistics",
        "description": "Cross-source puzzle activity and ratings: a 12-week solve summary with overall win rate, solve volume bucketed to suit the window (day for `30d`/`90d`, week for `1y`, month for `all` — `volume.bucket` says which), and rating series for Lichess puzzles and chess.com tactics. Rating series longer than 120 rows are evenly downsampled (first and last rows always kept) and flagged with `ratings.dataDownsampled: true`. Honesty rule: the chess.com tactics line is a PEAK-only rating — their public API exposes no current value — and its series label says so. Puzzle data responds to the `range` window only; game-source filters do not apply here.",
        "parameters": [
          {
            "name": "range",
            "in": "query",
            "required": false,
            "description": "Time window: `30d`, `90d`, `1y` (default) or `all`.",
            "schema": {
              "type": "string",
              "enum": [
                "30d",
                "90d",
                "1y",
                "all"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Activity summary, bucketed volume and puzzle-rating series.",
            "content": {
              "application/json": {
                "example": {
                  "range": "1y",
                  "activity": {
                    "solved": 412,
                    "winRate": 74.3,
                    "weekly": [
                      {
                        "weekStart": "2026-07-13",
                        "solved": 18
                      }
                    ]
                  },
                  "volume": {
                    "bucket": "week",
                    "points": [
                      {
                        "bucketStart": "2026-07-16",
                        "solved": 18
                      }
                    ]
                  },
                  "ratings": {
                    "data": [
                      {
                        "dateTs": 1752710400000,
                        "lichess": 1873,
                        "chesscom": 2011
                      }
                    ],
                    "series": [
                      {
                        "key": "lichess",
                        "label": "Lichess puzzles",
                        "colour": "#21e6c1"
                      },
                      {
                        "key": "chesscom",
                        "label": "Chess.com tactics (peak)",
                        "colour": "#9d4eff"
                      }
                    ],
                    "omitted": 0,
                    "dataDownsampled": false
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/study": {
      "get": {
        "operationId": "listStudyGames",
        "summary": "List study games",
        "description": "The token owner's personal study collection — every game saved via uploadStudyGame, newest first, up to 200 rows. This is a separate collection from the user's own played games (listGames): study games are things the user wants to STUDY (a classic pasted in, a friend's game), not games the user played, so rows carry no colour or accuracy-for-the-user field. Rows are returned AS STORED — snake_case, the same shape the study page itself renders — rather than through a renamed payload: `id`, `source`, `white_name`, `black_name`, `event`, `year`, `result`, `created_at`. Call requestStudyAnalysis with an `id` to have Chessfolio engine-review one. Read-only.",
        "responses": {
          "200": {
            "description": "The owner's study collection.",
            "content": {
              "application/json": {
                "example": {
                  "games": [
                    {
                      "id": "b1c2d3e4-…",
                      "source": "upload",
                      "white_name": "Paul Morphy",
                      "black_name": "Duke Karl / Count Isouard",
                      "event": "Paris Opera",
                      "year": 1858,
                      "result": "1-0",
                      "created_at": "2026-08-14T09:00:00Z"
                    }
                  ],
                  "count": 1
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "operationId": "uploadStudyGame",
        "summary": "Upload a study game",
        "description": "Paste one complete PGN into the token owner's personal study collection — for studying somebody else's game (a classic, a friend's game, one found online), not the user's own played games (use attachPgn against an existing listGames row for those). Validated like every PGN door on this api: exactly one parseable game, at least one move, legal throughout, no set-position (FEN/SetUp) games. Any prose in `{...}` comments is extracted and kept as editable per-ply study notes in the web study viewer at /study — unlike attachPgn, comments here are not simply discarded — though only the canonical, comment-free mainline is ever analysed, and this endpoint has no way to read or write those notes itself. The collection is deduplicated on the MOVES, not the file: re-uploading a game whose headers, comments or clock tags differ from one already in the collection lands on that same row (`alreadyInCollection: true`) rather than creating a duplicate — its analysis is kept, and any new comments fill plies that don't already have a note; the old upload's own clock tags and other comment-only data are not merged in and are not stored anywhere. Separately, and independently of collection membership, Chessfolio's backing store reuses analysis whenever the full canonical text (headers and clocks included) byte-matches a game already analysed anywhere in Chessfolio's store (for example one copied from getLibraryGame), so requestStudyAnalysis can return that analysis for free — no new engine time. A separate mutation limit allows 20 uploads per hour, enforced atomically and failing closed.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "pgn"
                ],
                "properties": {
                  "pgn": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 100000,
                    "description": "One complete PGN with movetext."
                  }
                },
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The PGN is saved, or the existing row was returned unchanged.",
            "content": {
              "application/json": {
                "example": {
                  "id": "b1c2d3e4-…",
                  "alreadyInCollection": false,
                  "note": "Saved to your study collection. Call request_study_analysis with this id next to have Chessfolio analyse it. Your collection is deduplicated on the moves themselves: if this PGN's moves already match one of your existing study games, this upload lands on that same entry instead of creating a new one — its analysis is kept, and any new comments fill plies that don't already have a note. Separately, if this PGN's full canonical text (not just the moves) matches a game analysed anywhere in Chessfolio's store — for example one pasted from get_library_game — that analysis comes back free."
                }
              }
            }
          },
          "400": {
            "description": "The JSON is not exactly `{ pgn: string }`, or the PGN is multi-game, moveless, unparseable, a set-position (FEN/SetUp) game, or contains unsupported controls."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "413": {
            "description": "The actual or declared request body exceeds the bounded JSON envelope (~401KB — the 100,000-character cap's worst-case UTF-8 size plus a 1KB allowance)."
          },
          "415": {
            "description": "Content-Type is not application/json."
          },
          "429": {
            "description": "The shared 120/minute API limit or the stricter 20/hour study-upload limit was exceeded."
          },
          "503": {
            "description": "The upload rate limiter could not be evaluated, so nothing was stored. This limit fails closed. Retryable."
          }
        }
      }
    },
    "/api/v1/me/study/{id}": {
      "delete": {
        "operationId": "deleteStudyGame",
        "summary": "Delete a study game",
        "description": "Delete one study game owned by the token holder, using an id from listStudyGames or uploadStudyGame. The row's per-ply study notes are deleted with it (they cascade), and this cannot be undone — there is no recycle bin, and re-uploading the PGN later creates a fresh entry with a new id. What that means for notes: any note typed or edited directly in Chessfolio's study viewer is gone for good — it lived only on the deleted row. But a note that came from a `{...}` comment embedded in the PGN itself is not gone in the same sense: it lives in the PGN text, not the row, so re-uploading that same PGN re-seeds it as a fresh note on the new entry. Scope is deliberately narrow: only the study-collection row goes, so the underlying analysed game record in Chessfolio's backing store is untouched (a library game or another copy of the same game keeps its analysis) and no other user's data can be affected. Honesty rule on the 404: an unknown id, an id belonging to somebody else and a malformed id are indistinguishable — all three answer with the same not-found, so the response gives no oracle over the id space. Repeating a successful delete returns that same 404. Games the user PLAYED (listGames rows) cannot be deleted anywhere on this surface — only study-collection rows can.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Study game id, exactly as returned by listStudyGames or uploadStudyGame.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The study game and its notes were deleted.",
            "content": {
              "application/json": {
                "example": {
                  "deleted": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "404": {
            "description": "No study game with that id belongs to the token owner — an unknown, foreign and malformed id are indistinguishable.",
            "content": {
              "application/json": {
                "example": {
                  "error": "Study game not found"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/study/{id}/review": {
      "post": {
        "operationId": "requestStudyAnalysis",
        "summary": "Request study-game analysis",
        "description": "Ask Chessfolio to run its engine review over one study game owned by the token holder, using an id from listStudyGames or uploadStudyGame. Spends from the SAME shared request_review allowance as requestGameReview — 20 newly queued analyses per hour, atomic, shared across both the games surface and the study collection, because both doors ultimately queue work on one concurrency-1 engine rather than owning a ceiling each. A study game whose analysis already exists — including one that content-addressed onto an already-analysed api game — returns complete immediately at zero cost against the limit; repeat calls are safe and free. Analysis is queued and usually takes 20-40 seconds: the default response is 202 with status='queued', and the caller repeats the request with the same id to collect the finished review. An optional `wait` (seconds, 0-45) makes the server wait for completion instead, returning 200 in one call once it finishes (still 202 if the wait elapses first). There is no accuracy 'for the user' here: a study game is somebody else's game, so `review.accuracyForUser` is always null — only white and black accuracies are ever reported.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Study game id, exactly as returned by listStudyGames or uploadStudyGame.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "wait": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 45,
                    "description": "Seconds to wait for a queued analysis before giving up and returning 202. Whole seconds from 0 to 45; anything outside that range is rejected with a 400, not clamped. Default 0 (return immediately)."
                  }
                },
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The review is complete — already reviewed, or finished within the requested wait.",
            "content": {
              "application/json": {
                "example": {
                  "status": "complete",
                  "id": "b1c2d3e4-…",
                  "alreadyReviewed": true,
                  "review": {
                    "accuracyWhite": 91.5,
                    "accuracyBlack": 80,
                    "accuracyForUser": null
                  },
                  "note": "This game was already reviewed; the existing analysis was returned."
                }
              }
            }
          },
          "202": {
            "description": "Analysis was queued (or the wait elapsed before it finished). Repeat the request with the same id to collect the review.",
            "content": {
              "application/json": {
                "example": {
                  "status": "queued",
                  "id": "b1c2d3e4-…",
                  "note": "Analysis queued (usually 20-40s). It will appear here when it finishes."
                }
              }
            }
          },
          "400": {
            "description": "The JSON is not exactly `{ wait?: integer }`, or `wait` is outside 0-45."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "404": {
            "description": "No study game with that id belongs to the token owner."
          },
          "413": {
            "description": "The declared or actual request body exceeds the bounded JSON envelope."
          },
          "415": {
            "description": "Content-Type is not application/json."
          },
          "429": {
            "description": "The shared 120/minute API limit or the separate 20/hour newly-queued-review allowance (shared with request_game_review) was exceeded."
          },
          "502": {
            "description": "Could not reach the analysis service. Retryable."
          },
          "503": {
            "description": "The hourly review ceiling could not be evaluated, so no analysis was queued. This limit fails closed because it is the only ceiling over shared engine time. Retryable."
          }
        }
      }
    },
    "/api/v1/tournaments/{tnr}/state": {
      "get": {
        "operationId": "getTournamentState",
        "summary": "Tournament state (public, no token)",
        "description": "PUBLIC, read-only snapshot of a Chess-Results tournament — no token required (source: chess-results.com). This is scraped public data with no personal scope, so unlike every /api/v1/me endpoint it carries no auth. Returns the seeded player list, published round pairings with results, current standings and any not-paired / requested-bye / withdrawal notes, plus a `snapshotAt` timestamp. Honesty rules: `snapshotAt` is the fetch time and the data MAY BE STALE (short-TTL cached upstream, not a live feed) — always read it; and if Chess-Results changes its page layout the endpoint fails loudly with 502 rather than returning half-parsed rows.",
        "security": [],
        "parameters": [
          {
            "name": "tnr",
            "in": "path",
            "required": true,
            "description": "Chess-Results tournament number — digits only, e.g. `651260`.",
            "schema": {
              "type": "string",
              "pattern": "^[0-9]+$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The tournament snapshot.",
            "content": {
              "application/json": {
                "example": {
                  "tnr": "651260",
                  "source": "chess-results.com",
                  "snapshotAt": "2026-07-21T10:00:00.000Z",
                  "name": "2022 Solihull Junior Open Under 11 Group A",
                  "seeds": [
                    {
                      "seedNo": 1,
                      "name": "He Tom Junde",
                      "rating": 1707,
                      "club": "St Mary's Harborne"
                    }
                  ],
                  "roundsPublished": 1,
                  "pairings": {
                    "1": [
                      {
                        "board": 1,
                        "white": {
                          "seedNo": 7,
                          "name": "Sagyaman Vassily M",
                          "rating": 1403
                        },
                        "black": {
                          "seedNo": 1,
                          "name": "He Tom Junde",
                          "rating": 1707
                        },
                        "result": "0 - 1"
                      }
                    ]
                  },
                  "standings": [
                    {
                      "rank": 1,
                      "seedNo": 2,
                      "name": "…",
                      "rating": 1661,
                      "club": "…",
                      "points": 4.5
                    }
                  ],
                  "notPaired": []
                }
              }
            }
          },
          "400": {
            "description": "`tnr` is not a bare number.",
            "content": {
              "application/json": {
                "example": {
                  "error": "Tournament number must be digits, e.g. 651260."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TournamentRateLimited"
          },
          "502": {
            "$ref": "#/components/responses/TournamentUpstream"
          },
          "503": {
            "$ref": "#/components/responses/TournamentBusy"
          }
        }
      }
    },
    "/api/v1/tournaments/{tnr}/round1": {
      "get": {
        "operationId": "estimateTournamentRound1",
        "summary": "Estimate Round-1 pairings (public, no token)",
        "description": "PUBLIC, read-only ESTIMATE of Round-1 pairings for a Chess-Results tournament — no token required (source: chess-results.com). Honesty rules, which are contract not decoration: this is an ESTIMATE derived from the seed list, NOT the official pairing (the arbiter's real draw can differ), so `isEstimate` is always true; the opponent estimate is more reliable than colour, because Round-1 colours hinge on the initial-colour draw (see `colourNote`), so treat the colour as a coin-flip; and `staleWarning` is set when the underlying snapshot is more than a day old. Optional `target` returns just one player's board in `targetPairing`.",
        "security": [],
        "parameters": [
          {
            "name": "tnr",
            "in": "path",
            "required": true,
            "description": "Chess-Results tournament number — digits only, e.g. `651260`.",
            "schema": {
              "type": "string",
              "pattern": "^[0-9]+$"
            }
          },
          {
            "name": "target",
            "in": "query",
            "required": false,
            "description": "Player name (case-insensitive) to single out — their board is returned in `targetPairing`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The Round-1 estimate.",
            "content": {
              "application/json": {
                "example": {
                  "section": null,
                  "listDate": null,
                  "generatedAtUtc": "2026-07-21T10:00:00.000Z",
                  "snapshotAgeDays": 0,
                  "staleWarning": null,
                  "totalPlayers": 12,
                  "requestedByes": [],
                  "forcedBye": null,
                  "activePairedCount": 12,
                  "pairings": [
                    {
                      "top": {
                        "seedNo": 1,
                        "name": "He Tom Junde",
                        "rating": 1707,
                        "club": "St Mary's Harborne"
                      },
                      "bottom": {
                        "seedNo": 7,
                        "name": "Sagyaman Vassily M",
                        "rating": 1403,
                        "club": "…"
                      }
                    }
                  ],
                  "targetPairing": null,
                  "colourNote": "Round 1 colours depend on the initial-colour draw; the opponent estimate is more reliable than colour.",
                  "isEstimate": true
                }
              }
            }
          },
          "400": {
            "description": "`tnr` is not a bare number.",
            "content": {
              "application/json": {
                "example": {
                  "error": "Tournament number must be digits, e.g. 651260."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TournamentRateLimited"
          },
          "502": {
            "$ref": "#/components/responses/TournamentUpstream"
          },
          "503": {
            "$ref": "#/components/responses/TournamentBusy"
          }
        }
      }
    },
    "/api/v1/tournaments/{tnr}/pairings": {
      "get": {
        "operationId": "estimateTournamentPairings",
        "summary": "Estimate next-round Swiss pairings (public, no token)",
        "description": "PUBLIC, read-only ESTIMATE of the next round's pairings for a Chess-Results Swiss tournament — no token required (source: chess-results.com). Honesty rules, which are contract not decoration: it runs the REAL FIDE Dutch pairing engine (bbpPairings) over the live standings, but it is an ESTIMATE, NOT the official pairing — `isEstimate` is always true — because the arbiter's Swiss-Manager draw can legitimately differ (accelerated pairings, custom settings, manual corrections). The older manual seeded-Swiss method is a teaching aid, not the target. Colours follow each player's prior-round colour history (see `colourNote`). `confidence` is a `high`/`medium`/`low` tier for how firm the estimate is and `assumptions` lists what the engine took as given. `round` picks which round to estimate (defaults to the next unplayed round); `target` spotlights one player by case-insensitive name, returning their board in `targetBoard` plus a what-if `scenarios` table. The underlying snapshot may be stale — read `snapshotAt`.",
        "security": [],
        "parameters": [
          {
            "name": "tnr",
            "in": "path",
            "required": true,
            "description": "Chess-Results tournament number — digits only, e.g. `651260`.",
            "schema": {
              "type": "string",
              "pattern": "^[0-9]+$"
            }
          },
          {
            "name": "round",
            "in": "query",
            "required": false,
            "description": "Which round to estimate. Defaults to the next unplayed round.",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          },
          {
            "name": "target",
            "in": "query",
            "required": false,
            "description": "Player name (case-insensitive) to single out — their board is returned in `targetBoard`, with a what-if `scenarios` table.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The next-round pairing estimate.",
            "content": {
              "application/json": {
                "example": {
                  "source": "chess-results.com",
                  "tnr": "651260",
                  "snapshotAt": "2026-07-21T10:00:00.000Z",
                  "roundToPair": 4,
                  "isEstimate": true,
                  "boards": [
                    {
                      "board": 1,
                      "white": {
                        "seedNo": 2,
                        "name": "Wesson Alexander",
                        "rating": 1661,
                        "club": "Camberley"
                      },
                      "black": {
                        "seedNo": 5,
                        "name": "He Tom Junde",
                        "rating": 1707,
                        "club": "St Mary's Harborne"
                      },
                      "pairingReason": "Both on 3/3; the higher half is due White on colour history."
                    }
                  ],
                  "targetBoard": {
                    "board": 1,
                    "white": {
                      "seedNo": 2,
                      "name": "Wesson Alexander",
                      "rating": 1661,
                      "club": "Camberley"
                    },
                    "black": {
                      "seedNo": 5,
                      "name": "He Tom Junde",
                      "rating": 1707,
                      "club": "St Mary's Harborne"
                    },
                    "pairingReason": "Both on 3/3; the higher half is due White on colour history."
                  },
                  "assumptions": [
                    "Standings read from the latest published round (3 of 5).",
                    "No accelerated pairings; default FIDE Dutch settings."
                  ],
                  "confidence": "medium",
                  "colourNote": "Colours follow prior-round history; an odd colour balance in a score group can still flip a board.",
                  "scenarios": {
                    "target": "He Tom Junde",
                    "rows": [
                      {
                        "scenario": "Wins on board 1",
                        "predictedOpponent": "Sagyaman Vassily M",
                        "colour": "white"
                      },
                      {
                        "scenario": "Draws",
                        "predictedOpponent": "Patel Rian",
                        "colour": "black"
                      }
                    ],
                    "paritySensitive": true
                  }
                }
              }
            }
          },
          "400": {
            "description": "`tnr` is not a bare number.",
            "content": {
              "application/json": {
                "example": {
                  "error": "Tournament number must be digits, e.g. 651260."
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/TournamentNoPairing"
          },
          "429": {
            "$ref": "#/components/responses/TournamentRateLimited"
          },
          "502": {
            "$ref": "#/components/responses/TournamentUpstream"
          },
          "503": {
            "$ref": "#/components/responses/TournamentBusy"
          }
        }
      }
    },
    "/api/v1/ecf/rating-change": {
      "get": {
        "operationId": "calculateEcfRatingChange",
        "summary": "Calculate an ECF rating change (public, no token)",
        "description": "PUBLIC, read-only ECF rating calculator — no token required. Applies the English Chess Federation's published K Rating algorithm (V4, August 2020) to a set of results and returns the new rating with a per-game audit trail: rating difference `D`, the Elo difference-table offset, the score offset and the resulting increment, so every number can be checked by hand against the published tables. Honesty rules, which are contract not decoration: this is DETERMINISTIC ARITHMETIC, NOT AN OFFICIAL ECF FIGURE — the ECF rates a whole monthly cycle against one Old Rating carried in from the previous cycle, which is not always the rating published as effective for the month the games were played, and opponents count at the ratings held for that cycle; only the K Rating algorithm is implemented, so the answer does not apply to new or partially-rated players (fewer than 10 rated games), who are rated by the P (performance) algorithm; and `Adjustment`, an ECF-wide drift correction that is zero in almost every year, is treated as zero. The response's `notes` array repeats whichever of these apply to the request.",
        "security": [],
        "parameters": [
          {
            "name": "currentRating",
            "in": "query",
            "required": true,
            "description": "The player's ECF rating before these games, e.g. `1650`. Four-digit scale (2020 onwards); 100–3500.",
            "schema": {
              "type": "integer",
              "minimum": 100,
              "maximum": 3500
            }
          },
          {
            "name": "games",
            "in": "query",
            "required": true,
            "description": "The games, as opponent rating then result, comma-separated: `1750 win, 1700 draw, 1600 loss`. `w`/`d`/`l`, `1`/`=`/`0` and `+`/`-` also work, as do `1750=w` and `1750:d`. Semicolons and newlines separate too. Up to 100 games; pass a whole month together, because the 700-point cap is a per-month rule.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "age",
            "in": "query",
            "required": false,
            "description": "The player's age in years. Only the under-18 boundary matters: a junior who is GAINING rating moves at K = 40 rather than 20. Omitted means treat as an adult.",
            "schema": {
              "type": "integer",
              "minimum": 3,
              "maximum": 120
            }
          },
          {
            "name": "gamesThisMonth",
            "in": "query",
            "required": false,
            "description": "Every rated game the player played in the rating month, when that is more than the games listed. The ECF caps a month's movement at 700 points by scaling K, and the cap divides by this number. Cannot be fewer than the games supplied.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The rating change, with the per-game working.",
            "content": {
              "application/json": {
                "example": {
                  "algorithm": "ECF K Rating (V4, August 2020)",
                  "effectiveFrom": "2020-07-01 for over-the-board ratings, 2021-09-01 for online ratings.",
                  "source": "https://rating.englishchess.org.uk/help/rating",
                  "currentRating": 1650,
                  "ageBand": "adult",
                  "ageSupplied": false,
                  "gamesThisMonth": 3,
                  "gamesSupplied": 3,
                  "playerK": 20,
                  "playerKBasis": "No age supplied, so treated as an adult (18+): K = 20.",
                  "direction": "gaining",
                  "games": [
                    {
                      "index": 1,
                      "opponentRating": 1750,
                      "result": "win",
                      "ratingDifference": 100,
                      "dOffset": 2.8,
                      "scoreOffset": 10,
                      "increment": 12.8,
                      "runningRating": 1662.8
                    }
                  ],
                  "totalIncrement": 2.8,
                  "newRating": 1653,
                  "newRatingExact": 1652.8,
                  "change": 3,
                  "changeExact": 2.8,
                  "score": {
                    "games": 3,
                    "wins": 1,
                    "draws": 1,
                    "losses": 1,
                    "points": 1.5
                  },
                  "monthlyCapApplied": false,
                  "floorApplied": false,
                  "notes": [
                    "Deterministic arithmetic, not an official ECF figure. …"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "The input could not be used — a missing or out-of-range `currentRating`, an unreadable `games` entry, more than 100 games, or a `gamesThisMonth` smaller than the games supplied. The message names the problem.",
            "content": {
              "application/json": {
                "example": {
                  "error": "Could not read the result \"banana\" in \"1750 banana\". Use win/draw/loss (w, d, l, 1, =, 0 also work)."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PublicRateLimited"
          }
        }
      }
    },
    "/api/v1/library": {
      "get": {
        "operationId": "listLibraryGames",
        "summary": "Curated classic-games library (public, no token)",
        "description": "PUBLIC, read-only listing of Chessfolio's curated classic-games library — around 50 published historical and instructive games, each with editorial commentary, a source citation and full engine analysis behind it. No token required, no personal scope. Returns one card per game (slug, title, white, black, event, year, result, ECO) so an agent can browse and pick one; call getLibraryGame with a slug from this list for the full entry, including its editorial essay and canonical PGN. There is deliberately no per-ply data anywhere on this surface — see getLibraryGame's description for why.",
        "security": [],
        "responses": {
          "200": {
            "description": "One card per published library game.",
            "content": {
              "application/json": {
                "example": {
                  "games": [
                    {
                      "slug": "opera-game-morphy-1858",
                      "title": "The Opera Game",
                      "white": "Paul Morphy",
                      "black": "Duke Karl / Count Isouard",
                      "event": "Paris Opera",
                      "year": 1858,
                      "result": "1-0",
                      "eco": "C41"
                    }
                  ],
                  "count": 1
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PublicRateLimited"
          }
        }
      }
    },
    "/api/v1/library/{slug}": {
      "get": {
        "operationId": "getLibraryGame",
        "summary": "Library game detail (public, no token)",
        "description": "PUBLIC, read-only full entry for one published library game. No token required, no personal scope. Returns the editorial essay, its source citation, the canonical PGN, and both players' overall engine accuracy. HARD EXCLUSION, by design rather than omission: no per-ply data at all — no move evaluations, no critical moments, no depth. The library surface is prose and provenance, not an analysis feed. To study the game move by move, paste its PGN into the personal study collection with uploadStudyGame (`POST /api/v1/me/study`, PAT required): because the PGN is byte-identical to the one already analysed here, the analysis returns at zero engine cost via content-addressed dedupe onto the already-analysed row. `slug` comes from listLibraryGames. An unknown, malformed and unpublished slug all answer with the same fixed 404 — the three are indistinguishable, matching the /library/[slug] web page's own posture.",
        "security": [],
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The library game's slug, as returned by listLibraryGames.",
            "schema": {
              "type": "string",
              "maxLength": 80
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The full library game entry.",
            "content": {
              "application/json": {
                "example": {
                  "slug": "opera-game-morphy-1858",
                  "title": "The Opera Game",
                  "white": "Paul Morphy",
                  "black": "Duke Karl / Count Isouard",
                  "event": "Paris Opera",
                  "site": "Paris",
                  "year": 1858,
                  "result": "1-0",
                  "eco": "C41",
                  "editorial": "Morphy, seated with his back to the stage, produced the most famous miniature ever played.",
                  "citations": {
                    "pgnSource": "Sergeant, Morphy's Games of Chess (1916)"
                  },
                  "pgn": "[Event \"Paris Opera\"]\n\n1. e4 e5 2. Nf3 …",
                  "accuracies": {
                    "white": 98.6,
                    "black": 89.4
                  },
                  "url": "https://chessfolio.io/library/opera-game-morphy-1858"
                }
              }
            }
          },
          "404": {
            "description": "No published library game with that slug — an unknown, malformed and unpublished slug are all indistinguishable.",
            "content": {
              "application/json": {
                "example": {
                  "error": "No published library game with that slug."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PublicRateLimited"
          }
        }
      }
    }
  }
}