{
  "openapi": "3.1.0",
  "info": {
    "title": "Chessfolio API",
    "version": "1.6.1",
    "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, a cross-game weakness profile (ranked by phase, clock pressure, piece, move kind, concept tag and conversion, with a five-game floor and evidence game ids), 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. Alongside those, durable coaching state: training focuses with a measured trend, an append-only coaching log, and saved positions to quiz from, which a coaching agent reads first and writes to as a session goes.\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. Nine write operations exist. Five of them act on games and the study collection. 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. Of those five, 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 them can edit a game's result, rating or metadata, or touch another user's data. The other four are the coaching writes, acting only on the token holder's own coaching rows: `POST /api/v1/me/coaching/focus` creates, retitles or resolves one training focus (never more than three active); `POST /api/v1/me/coaching/entries` appends one entry to an append-only log, with every game and position reference proved owned first; `POST /api/v1/me/coaching/positions` saves one position, copied from the owner's own review when a game is named; and `DELETE /api/v1/me/coaching/positions/{id}` removes one saved position, the only coaching row an agent can delete. None of the four can edit or delete a coaching entry's text, and two edits to an existing row are possible, both of them `POST /api/v1/me/coaching/entries`: it flips one owned, OPEN assignment entry to completed or skipped when the body names that assignment and an `assignmentStatus`, and a `result` entry naming a saved `positionId` also stamps that position's `lastQuizzedAt` and appends to its `quizResults`, which is how a quiz is recorded. Across all nine writes none accepts a URL, a path or an uploaded file. The user can delete any coaching row from their dashboard.\n\n**Rate limit:** 120 requests/minute per user, shared with the MCP server at /api/mcp. Six 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; 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 weakness-profile read 10/minute, because one call aggregates every reviewed game in the window and, unless `compareToPrevious=false`, in the window before it; and the four coaching writes together, 60 per hour in one bucket shared by all four, because each of them stores a durable row. The review, study-upload, analysis, weakness-profile and coaching-write buckets are all enforced atomically and fail closed: if any of those five limiters is unavailable the endpoint returns 503 rather than proceeding unbounded.\n\n**MCP:** these twenty-three 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 — 29 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, more than 30 in a minute on the two per-ply analysis reads, more than 10 in a minute on the weakness-profile read, or more than 60 coaching writes in an hour.",
        "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 with the win-probability pair it was judged on (`winProbabilityBefore`/`winProbabilityLoss`), 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), clocks are absent for games stored before clock capture and for sources that publish none, and win probability is absent on analyses stored before the api recorded it (never recomputed from the published evaluations to fill the gap). Every boolean there describes this deployment's coverage rather than the game, which `availability.legend` states in the payload. `verbosity=compact` shrinks each ply by omitting `fenBefore`, `fenAfter`, `principalVariationUci` and `alternatives`, and the response's `verbosity.omittedPlyFields` names exactly what it withheld. 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"
            }
          },
          {
            "name": "verbosity",
            "in": "query",
            "required": false,
            "description": "`compact` omits the four ply fields that exist to rebuild a position or an engine line (`fenBefore`, `fenAfter`, `principalVariationUci`, `alternatives`), keeping every judgement, number, phase and concept tag at about a third fewer bytes per ply — the mode for reading a whole game. Unrecognised values degrade to `full`; `verbosity.omittedPlyFields` on the response lists exactly what was withheld, so an absent key never reads as a field the analysis lacks.",
            "schema": {
              "type": "string",
              "enum": [
                "full",
                "compact"
              ],
              "default": "full"
            }
          }
        ],
        "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,
                    "classificationBasis": "win-probability",
                    "notes": {
                      "classification": "classification is a WIN-PROBABILITY verdict, not a centipawn one, and its mistake/blunder thresholds widen as the position becomes decided. A move can therefore carry a large centipawnLoss and still classify as `good`: at -382 for the mover, conceding another 118 centipawns costs about six points of win probability, under the seven-point threshold an inaccuracy starts at, because the game was already decided. Read centipawnLoss as damage on the material scale and classification as how much that damage changed the likely result. winProbabilityLoss is the number the classifier itself used and is published on every ply the analysis recorded it for, so the two never have to be reconciled by guesswork. To rank moves by raw damage regardless of how decided the position was, filter on centipawnLoss — get_critical_moments takes minCentipawnLoss for exactly that.",
                      "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,
                    "winProbability": true,
                    "conceptDetectors": {
                      "detectors": [
                        "hanging_piece",
                        "missed_capture",
                        "missed_mate",
                        "allowed_mate",
                        "fork",
                        "pin",
                        "back_rank",
                        "forcing_move_missed",
                        "opening_principle",
                        "time_trouble"
                      ],
                      "version": "v1"
                    },
                    "legend": "Each boolean here is a statement about COVERAGE, never a finding about this game: true means the field is populated wherever the data exists, false means it is not populated and the field itself reports null rather than false. …",
                    "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
                  },
                  "verbosity": {
                    "mode": "full",
                    "omittedPlyFields": [],
                    "note": "Every recorded field is returned. Pass verbosity=compact to drop the position and engine-line reconstruction fields when sweeping a whole game."
                  },
                  "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",
                      "winProbabilityBefore": 0.426,
                      "winProbabilityLoss": 0,
                      "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` lists which of the token owner's current top weaknesses (getWeaknessProfile, default 12-month window) each of their OWN moments exhibits, matched on phase, clock bucket, piece moved, move kind and concept tags. It has three states and collapsing them into two is the error to avoid: null means no profile was supplied for this request (the usual cause being no reviewed game with stored features in the window, though a profile read that failed degrades to the same value), so nothing was checked at all; an empty array on one of their own moments means it was checked and nothing matched; a populated array is a match. With a profile loaded, an opponent's moment always reports an empty array, and there it means not-applicable rather than checked-and-clear, as does a moment whose ply is missing from the per-ply analysis. The `availability.matchesKnownWeakness` flag beside it says only whether a profile was loaded, which makes it a statement about THIS USER and the odd one out in a block of coverage flags; `availability.legend` sorts the rest into the ones this deployment fixes and the ones that turn on what this game carries. `winProbabilityBefore`/`winProbabilityLoss` carry the pair `classification` was judged on, which is why a mild label can sit beside a large `centipawnLoss` in an already-decided position. 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,
                    "classificationBasis": "win-probability",
                    "notes": {
                      "classification": "classification is a WIN-PROBABILITY verdict, not a centipawn one, and its mistake/blunder thresholds widen as the position becomes decided. A move can therefore carry a large centipawnLoss and still classify as `good`: at -382 for the mover, conceding another 118 centipawns costs about six points of win probability, under the seven-point threshold an inaccuracy starts at, because the game was already decided. Read centipawnLoss as damage on the material scale and classification as how much that damage changed the likely result. winProbabilityLoss is the number the classifier itself used and is published on every ply the analysis recorded it for, so the two never have to be reconciled by guesswork. To rank moves by raw damage regardless of how decided the position was, filter on centipawnLoss — get_critical_moments takes minCentipawnLoss for exactly that.",
                      "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,
                    "winProbability": true,
                    "matchesKnownWeakness": true,
                    "conceptDetectors": {
                      "detectors": [
                        "hanging_piece",
                        "missed_capture",
                        "missed_mate",
                        "allowed_mate",
                        "fork",
                        "pin",
                        "back_rank",
                        "forcing_move_missed",
                        "opening_principle",
                        "time_trouble"
                      ],
                      "version": "v1"
                    },
                    "legend": "Each boolean here is a statement about COVERAGE, never a finding about this game: true means the field is populated wherever the data exists, false means it is not populated and the field itself reports null rather than false. …",
                    "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": "Each of the user's own moments lists which of their current top weaknesses it exhibits (get_weakness_profile, default window; currently concept:forcing_move_missed, phase:middlegame, kind:quiet, clock:scramble, conversion:winning), matched on phase, clock bucket, piece moved, move kind and concept tags. …"
                    }
                  },
                  "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,
                      "winProbabilityBefore": 0.511,
                      "winProbabilityLoss": 0.3273,
                      "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": [
                        "concept:forcing_move_missed",
                        "phase:middlegame",
                        "kind:quiet"
                      ]
                    },
                    {
                      "ply": 44,
                      "moveNumber": 22,
                      "fen": null,
                      "sideToMove": "black",
                      "isUserMove": true,
                      "type": "blunder",
                      "source": "engine",
                      "severity": "critical",
                      "classification": null,
                      "evalSwingCentipawns": 260,
                      "centipawnLoss": null,
                      "winProbabilityBefore": null,
                      "winProbabilityLoss": 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": []
                    }
                  ]
                }
              }
            }
          },
          "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/weakness-profile": {
      "get": {
        "operationId": "getWeaknessProfile",
        "summary": "Cross-game weakness profile",
        "description": "A ranked list of what is actually going wrong across the token owner's reviewed games in a window (`range`, default `1y`): the cross-game diagnosis a coach needs before picking a game to work on. Every reviewed game's OWN moves (never the opponent's) are bucketed by phase (opening, middlegame, endgame), clock pressure (scramble at 20 seconds or less left after the move, pressed at 60 or less, comfortable above, unknown when the game carries no clocks), piece moved (SAN letters K, Q, R, B, N, and P for pawn moves), move kind (capture, check, quiet, pawn_push, castle), concept tag (the v1 detector ids /api/v1/me/games/{id}/critical-moments reports) and colour, plus two per-game conversion buckets: `conversion:winning` (win probability reached 80% or more) and `conversion:lost` (fell to 20% or less). Each move bucket reports moves, the count of inaccuracies, mistakes, misses and blunders, and an error rate per 100 moves. Each conversion bucket reports a SUCCESS rate over the games that had the situation AND a stored result, so `games` is not 'games I was winning': a game whose result Chessfolio does not hold is in neither `games` nor `won`. Buckets clearing five reviewed games are scored by one of THREE formulas, because they are three different kinds of thing. Phase, clock, piece, kind and colour partition the user's moves, every move sitting in exactly one bucket of each, so each is scored by (bucket rate minus the user's overall rate) times log2 of the bucket's moves. Concept tags are not a partition: a move carries none, one or several, and most tags only ever attach to a move that was already a mistake, which makes a rate measured within the tag 100 by construction and tells you nothing. A concept is therefore measured over the WHOLE window. Its `moves` is every user ply in the window, its `ratePer100` reads as 'this habit costs me N errors per 100 moves', a tag that never fired reports errors 0 and rate 0 over that same full move count, and it scores that rate times log2 of the window's moves, but only once the tag accounts for at least a QUARTER of every error in the window; below that share it scores 0. A concept's five-game floor likewise counts the games where the tag landed on an error, not the games it fired in. Conversion buckets score (1 minus the conversion rate) times log2 of games. The top `top` come back as `weaknesses`, each with a stable id (e.g. `concept:hanging_piece`, `clock:scramble`), a templated label, the numbers, the change against the previous window of equal length, and up to three `evidence` entries carrying a `gameId` exactly as /api/v1/me/games returns it, ready for /api/v1/me/games/{id}/critical-moments. Read `delta` against its own bucket: on a move bucket it is a change in an ERROR rate, so positive means worse; on a conversion bucket a change in a SUCCESS rate, so positive means better. `avgCollapsePly` averages every collapse in the bucket, including games the user went on to win: it says where the game turned, never which move lost it. Buckets under the five-game floor are returned under `lowSample` with counts and no rank, so 'not enough data' never reads as 'not a problem'; `byDimension` carries every known bucket's counts either way. Honesty rules: nothing is claimed on fewer than five reviewed games, in this window or the previous one, so a delta against three games comes back null; a partition bucket at or below the overall rate is never ranked, nor is a concept tag holding less than a quarter of the errors, so a player whose errors are evenly spread across their tags gets no concept in the list at all — read a short list of low-scoring partition buckets as 'nothing stands out', because over a few dozen games some bucket always sits a point or two above the overall rate by chance; `clock:unknown` and `concept:time_trouble` are counted in `byDimension` but reach neither `weaknesses` nor `lowSample`, missing clock data being a gap in the record rather than a habit, and the time-trouble tag being exactly the `clock:pressed` and `clock:scramble` errors under a second name, so ranking it would count the same errors twice; over-the-board (ECF) games carry no move data and are excluded; and only games with a Chessfolio review and stored features count, so `hasReviewData: false` means none fall in the window: call requestGameReview on some games first. Two query quirks to know before trusting a reply. `top` is CLAMPED into 1..10 rather than rejected (`?top=99` answers with 10, `?top=nonsense` with 5) and the payload carries no field echoing back what was applied, so count `weaknesses` if it matters; the same value sent to the get_weakness_profile MCP tool is rejected outright, which is the one place these two doors differ on purpose. And `opening`, which the shared games filter accepts, is parsed here and silently IGNORED, the stored feature rows carrying no opening column, even though /api/v1/me/problem-lines does honour it on the same filter set: narrow this path with range, provider and timeClass only. No engine calls; every string is templated from stored facts (`summarySource: \"template\"`), no language model involved. Read-only, but not priced as an ordinary query: one call aggregates every reviewed game in the window and, unless `compareToPrevious=false`, in the window before it, so it spends its own ceiling of 10 a minute on top of the shared 120/minute one. That bucket is shared with the `get_weakness_profile` MCP tool, enforced atomically, and fails closed, so a 503 here means the limiter could not be read and nothing was aggregated.",
        "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": "compareToPrevious",
            "in": "query",
            "required": false,
            "description": "Also compute the same-length window immediately before this one and report deltas. `false`, `0` or `no` switches it off; anything else (including omitting it) leaves it on. Ignored for `range=all`, which has no previous window.",
            "schema": {
              "type": "boolean",
              "default": true
            }
          },
          {
            "name": "top",
            "in": "query",
            "required": false,
            "description": "How many ranked weaknesses to return. Clamped into 1..10 rather than rejected; junk falls back to 5.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 10,
              "default": 5
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The ranked profile, or `hasReviewData: false` with zero counts when no reviewed game with stored features falls in the window.",
            "content": {
              "application/json": {
                "example": {
                  "range": "90d",
                  "providers": null,
                  "timeClasses": null,
                  "reviewedGames": 41,
                  "userMoves": 1620,
                  "hasReviewData": true,
                  "rules": {
                    "phase": "lichess-divider-v1",
                    "detectors": "v1"
                  },
                  "overall": {
                    "errorRatePer100": 4.1,
                    "previous": 4.6,
                    "delta": -0.5
                  },
                  "weaknesses": [
                    {
                      "id": "clock:scramble",
                      "label": "Under 20 seconds",
                      "moves": 140,
                      "errors": 14,
                      "ratePer100": 10,
                      "previousRatePer100": 8.1,
                      "delta": 1.9,
                      "score": 42.2,
                      "evidence": [
                        {
                          "gameId": "6a3b0c9e-…",
                          "ply": 61,
                          "fact": "Ply 61: blunder on a rook move in the endgame with under 20 seconds left; erred in time trouble."
                        }
                      ]
                    },
                    {
                      "id": "concept:hanging_piece",
                      "label": "Leaves a piece en prise",
                      "moves": 1620,
                      "errors": 19,
                      "ratePer100": 1.2,
                      "previousRatePer100": 0.9,
                      "delta": 0.3,
                      "score": 12.5,
                      "evidence": [
                        {
                          "gameId": "1f9d2e77-…",
                          "ply": 47,
                          "fact": "Ply 47: blunder on a knight move in the middlegame with under 20 seconds left; left a piece en prise; erred in time trouble."
                        }
                      ]
                    },
                    {
                      "id": "conversion:winning",
                      "label": "Throws winning positions",
                      "games": 17,
                      "won": 10,
                      "rate": 0.59,
                      "previousRate": 0.71,
                      "delta": -0.12,
                      "avgCollapsePly": 58,
                      "score": 1.7,
                      "evidence": [
                        {
                          "gameId": "c04b7a12-…",
                          "ply": 58,
                          "fact": "Was winning (win probability 80% or more) and fell below 50% at ply 58; the game was not won."
                        }
                      ]
                    }
                  ],
                  "lowSample": [
                    {
                      "id": "phase:endgame",
                      "games": 4,
                      "moves": 38,
                      "errors": 2
                    },
                    {
                      "id": "concept:missed_mate",
                      "games": 3,
                      "moves": 1620,
                      "errors": 3
                    },
                    {
                      "id": "concept:allowed_mate",
                      "games": 2,
                      "moves": 1620,
                      "errors": 2
                    },
                    {
                      "id": "concept:pin",
                      "games": 4,
                      "moves": 1620,
                      "errors": 4
                    },
                    {
                      "id": "concept:back_rank",
                      "games": 1,
                      "moves": 1620,
                      "errors": 1
                    },
                    {
                      "id": "concept:opening_principle",
                      "games": 4,
                      "moves": 1620,
                      "errors": 4
                    }
                  ],
                  "byDimension": {
                    "phase": {
                      "opening": {
                        "moves": 520,
                        "games": 41,
                        "errors": 12,
                        "ratePer100": 2.3
                      },
                      "middlegame": {
                        "moves": 1062,
                        "games": 41,
                        "errors": 52,
                        "ratePer100": 4.9
                      },
                      "endgame": {
                        "moves": 38,
                        "games": 4,
                        "errors": 2,
                        "ratePer100": 5.3
                      }
                    },
                    "clock": {
                      "comfortable": {
                        "moves": 1200,
                        "games": 41,
                        "errors": 40,
                        "ratePer100": 3.3
                      },
                      "pressed": {
                        "moves": 280,
                        "games": 30,
                        "errors": 12,
                        "ratePer100": 4.3
                      },
                      "scramble": {
                        "moves": 140,
                        "games": 22,
                        "errors": 14,
                        "ratePer100": 10
                      },
                      "unknown": {
                        "moves": 0,
                        "games": 0,
                        "errors": 0
                      }
                    },
                    "piece": {
                      "K": {
                        "moves": 90,
                        "games": 38,
                        "errors": 3,
                        "ratePer100": 3.3
                      },
                      "Q": {
                        "moves": 210,
                        "games": 41,
                        "errors": 9,
                        "ratePer100": 4.3
                      },
                      "R": {
                        "moves": 260,
                        "games": 40,
                        "errors": 14,
                        "ratePer100": 5.4
                      },
                      "B": {
                        "moves": 240,
                        "games": 41,
                        "errors": 9,
                        "ratePer100": 3.8
                      },
                      "N": {
                        "moves": 300,
                        "games": 41,
                        "errors": 15,
                        "ratePer100": 5
                      },
                      "P": {
                        "moves": 520,
                        "games": 41,
                        "errors": 16,
                        "ratePer100": 3.1
                      }
                    },
                    "kind": {
                      "capture": {
                        "moves": 300,
                        "games": 41,
                        "errors": 10,
                        "ratePer100": 3.3
                      },
                      "check": {
                        "moves": 60,
                        "games": 30,
                        "errors": 2,
                        "ratePer100": 3.3
                      },
                      "quiet": {
                        "moves": 780,
                        "games": 41,
                        "errors": 40,
                        "ratePer100": 5.1
                      },
                      "pawn_push": {
                        "moves": 440,
                        "games": 41,
                        "errors": 13,
                        "ratePer100": 3
                      },
                      "castle": {
                        "moves": 40,
                        "games": 40,
                        "errors": 1,
                        "ratePer100": 2.5
                      }
                    },
                    "concept": {
                      "hanging_piece": {
                        "moves": 1620,
                        "games": 17,
                        "errors": 19,
                        "ratePer100": 1.2
                      },
                      "missed_capture": {
                        "moves": 1620,
                        "games": 8,
                        "errors": 9,
                        "ratePer100": 0.6
                      },
                      "missed_mate": {
                        "moves": 1620,
                        "games": 3,
                        "errors": 3,
                        "ratePer100": 0.2
                      },
                      "allowed_mate": {
                        "moves": 1620,
                        "games": 2,
                        "errors": 2,
                        "ratePer100": 0.1
                      },
                      "fork": {
                        "moves": 1620,
                        "games": 6,
                        "errors": 7,
                        "ratePer100": 0.4
                      },
                      "pin": {
                        "moves": 1620,
                        "games": 4,
                        "errors": 4,
                        "ratePer100": 0.2
                      },
                      "back_rank": {
                        "moves": 1620,
                        "games": 1,
                        "errors": 1,
                        "ratePer100": 0.1
                      },
                      "forcing_move_missed": {
                        "moves": 1620,
                        "games": 10,
                        "errors": 11,
                        "ratePer100": 0.7
                      },
                      "opening_principle": {
                        "moves": 1620,
                        "games": 4,
                        "errors": 4,
                        "ratePer100": 0.2
                      },
                      "time_trouble": {
                        "moves": 1620,
                        "games": 20,
                        "errors": 26,
                        "ratePer100": 1.6
                      }
                    },
                    "conversion": {
                      "winning": {
                        "games": 17,
                        "errors": 7,
                        "rate": 0.59
                      },
                      "lost": {
                        "games": 9,
                        "errors": 8,
                        "rate": 0.11
                      }
                    },
                    "colour": {
                      "white": {
                        "moves": 830,
                        "games": 21,
                        "errors": 31,
                        "ratePer100": 3.7
                      },
                      "black": {
                        "moves": 790,
                        "games": 20,
                        "errors": 35,
                        "ratePer100": 4.4
                      }
                    }
                  },
                  "summarySource": "template"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "description": "The weakness-profile ceiling could not be evaluated, so nothing was aggregated. This limit fails closed. Retryable."
          }
        }
      }
    },
    "/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/me/coaching/state": {
      "get": {
        "operationId": "getCoachingState",
        "summary": "Coaching state",
        "description": "What the token owner and their coach have agreed to work on, in one read, and the call to make FIRST in any coaching session: the ACTIVE training focuses (at most three), each with the profile-bucket snapshot taken when it was set (`baseline`), the same bucket over the current 90-day window (`now`), and a `trend` of improving / flat / worse (a move of at least 10% either way) or unknown when either side is missing (arithmetic over those two numbers, nothing more); the last 20 coaching entries of every kind, newest first; the assignments still `open`; the saved-position count; and `lastSessionAt`, the time of the newest `session` entry. `openAssignments` is its own read rather than a slice of `entries`, capped at 20 as well, so an open assignment older than the last 20 entries still appears there. Resolved focuses are not returned, and a focus set without a `weaknessId` has neither `baseline` nor `now`, so its trend is always unknown. The trend carries no minimum-games floor on either side: unlike getWeaknessProfile, which claims nothing on fewer than five reviewed games, a baseline and a current reading can each rest on as little as one game, so a focus can flip to worse or improving on the strength of a single game. A profile that cannot be read at all leaves the trends unknown rather than failing the call. Every string is templated from stored facts (`summarySource: \"template\"`); no language model runs on the server. A user with no coaching rows gets empty lists, not an error. Takes no parameters. Read-only: spends only the shared 120/minute limit, never the coaching-write bucket.",
        "responses": {
          "200": {
            "description": "The coaching state.",
            "content": {
              "application/json": {
                "example": {
                  "focus": [
                    {
                      "id": "f1c2…",
                      "title": "Stop hanging pieces",
                      "weaknessId": "concept:hanging_piece",
                      "status": "active",
                      "baseline": {
                        "ratePer100": 6.1,
                        "at": "2026-08-01T00:00:00.000Z"
                      },
                      "now": {
                        "ratePer100": 4.4
                      },
                      "trend": "improving",
                      "createdAt": "2026-08-01T00:00:00.000Z",
                      "resolvedAt": null,
                      "createdBy": "agent:cfp_AbCdEfGh"
                    }
                  ],
                  "entries": [
                    {
                      "id": "e9a1…",
                      "kind": "assignment",
                      "body": "Play three rapid games this week with a 60-second floor rule.",
                      "focusId": "f1c2…",
                      "refs": {},
                      "status": "open",
                      "createdAt": "2026-09-01T09:00:00.000Z",
                      "createdBy": "agent:cfp_AbCdEfGh"
                    }
                  ],
                  "openAssignments": [
                    {
                      "id": "e9a1…",
                      "kind": "assignment",
                      "body": "Play three rapid games this week with a 60-second floor rule.",
                      "focusId": "f1c2…",
                      "refs": {},
                      "status": "open",
                      "createdAt": "2026-09-01T09:00:00.000Z",
                      "createdBy": "agent:cfp_AbCdEfGh"
                    }
                  ],
                  "savedPositions": 12,
                  "lastSessionAt": "2026-08-30T18:00:00.000Z",
                  "summarySource": "template"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/me/coaching/focus": {
      "post": {
        "operationId": "setTrainingFocus",
        "summary": "Set or resolve a training focus",
        "description": "Create, retitle or resolve ONE training focus for the token owner. With no `id` it creates one (`title` required, 1 to 120 characters); `weaknessId` (an id from getWeaknessProfile, e.g. concept:hanging_piece) snapshots that bucket's current 90-day figure as the baseline the trend is measured from; one that does not parse as a weakness id rejects the whole call with a 400 rather than creating the focus without it. A bucket with no reading in that window leaves the baseline null and the trend unknown until the focus is set again. With an `id` from getCoachingState it retitles and/or sets `status`: done or dropped resolves it (stamping `resolvedAt`), active re-opens it, and an `id` sent with neither `title` nor `status` is a 400 rather than a silent no-op. At most three focuses can be active: a fourth is a 400 naming the three, and a re-open counts against that cap the same way. Nothing is ever deleted here; the user deletes focuses from their dashboard. Scope: one coaching_focus row owned by the token holder, nothing else. Rows are attributed to the token that wrote them (`createdBy: agent:<token prefix>`), never to anything in the body. Spends the 60-per-hour coaching-write bucket shared by the four coaching writes, on top of the 120/minute door limit; both are shared with the MCP tools.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "maxLength": 64,
                    "description": "An existing focus id, to retitle or resolve. Omit to create."
                  },
                  "title": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 120,
                    "description": "Required when creating."
                  },
                  "weaknessId": {
                    "type": "string",
                    "maxLength": 64,
                    "description": "A weakness id from getWeaknessProfile; snapshots the baseline."
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "active",
                      "done",
                      "dropped"
                    ],
                    "description": "With id: resolve (done / dropped) or re-open (active)."
                  }
                },
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The created or updated focus.",
            "content": {
              "application/json": {
                "example": {
                  "id": "f1c2…",
                  "title": "Stop hanging pieces",
                  "weaknessId": "concept:hanging_piece",
                  "status": "active",
                  "baseline": {
                    "ratePer100": 6.1,
                    "at": "2026-09-04T10:00:00.000Z"
                  },
                  "now": {
                    "ratePer100": 6.1
                  },
                  "trend": "flat",
                  "createdAt": "2026-09-04T10:00:00.000Z",
                  "resolvedAt": null,
                  "createdBy": "agent:cfp_AbCdEfGh"
                }
              }
            }
          },
          "400": {
            "description": "The body has a key outside id / title / weaknessId / status, a value of the wrong type, a title over 120 characters or made only of whitespace, an id or weaknessId over 64 characters, control characters in a string, a `status` outside active / done / dropped, an `id` with neither title nor status, a `weaknessId` that is not a weakness id, or a fourth active focus (the message names the three)."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "404": {
            "description": "No focus with that id belongs to the token owner (unknown, foreign and malformed ids are indistinguishable)."
          },
          "413": {
            "description": "The declared or actual request body exceeds the bounded JSON envelope (about 10KB, sized on the longest legal coaching body)."
          },
          "415": {
            "description": "Content-Type is not application/json."
          },
          "429": {
            "description": "The shared 120/minute API limit or the 60-per-hour coaching-write limit was exceeded."
          },
          "503": {
            "description": "The coaching-write limiter could not be evaluated, so nothing was written. This limit fails closed. Retryable."
          }
        }
      }
    },
    "/api/v1/me/coaching/entries": {
      "post": {
        "operationId": "addCoachingEntry",
        "summary": "Append a coaching entry",
        "description": "Append ONE entry to the token owner's coaching log: `kind` reflection, note, assignment (starts `open` unless `status` is given), result or session, with a plain-text `body` of 1 to 2000 characters. Optional `focusId` files it under one of the owner's focuses, resolved or not. Optional `gameId` (an owned game from listGames), `ply` (1 to 1000, and only with `gameId`), `fen` (validated and normalised through the same chess parser the reviews use) and `positionId` (a saved position) pin it to evidence, and every ref is proved owned or legal BEFORE anything is stored. Those five are sent FLAT, beside `kind` and `body`, and come back nested under `refs`; a `refs` object in the body is refused like any other unknown key. A `result` with `positionId` and `correct` also stamps that position's lastQuizzedAt and appends to its quizResults (what listSavedPositions' dueForQuiz reads); `correct` is accepted nowhere else. `status` is accepted on assignments only, at creation. A `result` or `note` carrying `assignmentId` (an open assignment from getCoachingState) and `assignmentStatus` completed or skipped closes that one owned, open assignment; its text is never edited, and an assignment that is not the token owner's, is not an assignment, or is no longer open is a 400. On its own `assignmentId` is only a reference, and any of the owner's assignment entries will do whatever its status. Beyond that single status flip the log is append-only: no entry's text can be edited and no entry deleted on this surface; the user deletes entries from their dashboard. Never touches games, the study collection or another user's data. Spends the 60-per-hour coaching-write bucket, on top of the 120/minute door limit.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "kind",
                  "body"
                ],
                "properties": {
                  "kind": {
                    "type": "string",
                    "enum": [
                      "reflection",
                      "note",
                      "assignment",
                      "result",
                      "session"
                    ]
                  },
                  "body": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 2000
                  },
                  "focusId": {
                    "type": "string",
                    "maxLength": 64
                  },
                  "gameId": {
                    "type": "string",
                    "maxLength": 64,
                    "description": "An owned game id from listGames."
                  },
                  "ply": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 1000,
                    "description": "1-based ply within gameId (1 = White's first move)."
                  },
                  "fen": {
                    "type": "string",
                    "maxLength": 100,
                    "description": "A legal FEN."
                  },
                  "positionId": {
                    "type": "string",
                    "maxLength": 64,
                    "description": "A saved position id; with kind result, records a quiz on it."
                  },
                  "assignmentId": {
                    "type": "string",
                    "maxLength": 64,
                    "description": "An assignment entry id from getCoachingState's openAssignments. With assignmentStatus on a result or note entry, closes it."
                  },
                  "assignmentStatus": {
                    "type": "string",
                    "enum": [
                      "completed",
                      "skipped"
                    ],
                    "description": "With assignmentId, on a result or note entry: mark that open assignment completed or skipped. Its text is never changed."
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "open",
                      "completed",
                      "skipped"
                    ],
                    "description": "Assignments only. Default open."
                  },
                  "correct": {
                    "type": "boolean",
                    "description": "With kind result and positionId. Default false."
                  }
                },
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The stored entry.",
            "content": {
              "application/json": {
                "example": {
                  "id": "e9a1…",
                  "kind": "result",
                  "body": "Answered Nxd4; the best move was Nxf2.",
                  "focusId": null,
                  "refs": {
                    "positionId": "p7d3…"
                  },
                  "status": null,
                  "createdAt": "2026-09-04T10:00:00.000Z",
                  "createdBy": "agent:cfp_AbCdEfGh"
                }
              }
            }
          },
          "400": {
            "description": "A key outside the documented set, a value of the wrong type, an unknown kind, a body outside 1 to 2000 characters, control characters in a string, an id or fen over its published length, a ply outside 1 to 1000 or sent without a gameId, status on a non-assignment, correct on anything but a result entry naming a saved position, a ref the token owner does not own, a FEN that does not parse, assignmentStatus without assignmentId or on a kind other than result / note, or an assignment that is not open."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "413": {
            "description": "The declared or actual request body exceeds the bounded JSON envelope (about 10KB: the 2000-character body's worst-case UTF-8 size plus an allowance for the keys and quoting)."
          },
          "415": {
            "description": "Content-Type is not application/json."
          },
          "429": {
            "description": "The shared 120/minute API limit or the 60-per-hour coaching-write limit was exceeded."
          },
          "503": {
            "description": "The coaching-write limiter could not be evaluated, so nothing was written. Fails closed. Retryable."
          }
        }
      }
    },
    "/api/v1/me/coaching/positions": {
      "get": {
        "operationId": "listSavedPositions",
        "summary": "List saved positions",
        "description": "The token owner's saved positions, newest first, 25 per page. `focusId` narrows to one focus; `dueForQuiz=true` keeps only positions never quizzed or whose most recent quiz result was wrong. Each carries the FEN and side to move, label and why, `source` (the game id and ply it was copied from, or null for a bare FEN), `bestMove` and `acceptableAnswers` (SAN) where it came from a reviewed ply, and its quiz history, so a coach can quiz without playing chess: show the FEN, compare the answer to acceptableAnswers, record it with addCoachingEntry (kind result, positionId, correct). A bare-FEN position has no best move and no acceptable answers, so nothing here can mark it right or wrong. Read-only, and it spends the shared 120/minute limit only. `total` counts the rows matching the filter rather than the account, and `pageSize` is always 25. Honesty rule on the filter: a `focusId` that names no focus of the owner's (unknown, foreign or malformed) answers with an empty page rather than an error, unlike the write doors, which refuse an unknown focus; an empty `focusId` is refused outright, because an empty filter answered with every position would be the opposite of what was asked.",
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "1-based page number, 25 per page. Default 1. A decimal integer only: `1e3`, `0x10` and a padded value are refused rather than guessed at.",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          },
          {
            "name": "focusId",
            "in": "query",
            "required": false,
            "description": "Only positions filed under this focus. Must not be empty.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 64
            }
          },
          {
            "name": "dueForQuiz",
            "in": "query",
            "required": false,
            "description": "`true` keeps only positions never quizzed, or whose last quiz was wrong. Exactly `true` or `false`; `1` and `yes` are refused.",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One page of saved positions.",
            "content": {
              "application/json": {
                "example": {
                  "positions": [
                    {
                      "id": "p7d3…",
                      "fen": "r4rk1/1pp2ppp/2n5/pB1p1b2/3PnB2/1Q2PN2/PP3PPP/R4RK1 b - - 0 16",
                      "sideToMove": "black",
                      "source": {
                        "gameId": "6a3b0c9e-…",
                        "ply": 32
                      },
                      "label": "Knight fork you missed",
                      "why": "Nxf2 wins the exchange.",
                      "bestMove": "Nxf2",
                      "acceptableAnswers": [
                        "Nxf2"
                      ],
                      "focusId": "f1c2…",
                      "createdAt": "2026-09-01T09:00:00.000Z",
                      "lastQuizzedAt": null,
                      "quizResults": []
                    }
                  ],
                  "page": 1,
                  "pageSize": 25,
                  "total": 1
                }
              }
            }
          },
          "400": {
            "description": "A query key outside page / focusId / dueForQuiz, a parameter given more than once, an empty value, a `page` that is not a positive decimal integer, a `dueForQuiz` that is not exactly true or false, or a `focusId` over 64 characters."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "operationId": "savePosition",
        "summary": "Save a position",
        "description": "Save ONE position for the token owner with a `label` (1 to 80 characters) and an optional `why` (up to 500). Either `gameId` + `ply` (an owned, reviewed game from listGames: the FEN, the engine's best move and the acceptable answers, best move plus alternatives within 0.25 pawns, are COPIED from the stored review; a `fen` alongside `gameId` is rejected rather than trusted, and `ply` is required with `gameId` and bounded to 1 to 1000) or a bare `fen` (validated and normalised; no best move and no answers, because no engine runs here and none is invented). A stored `bestMove` is always one of the acceptable answers, and a game with no stored moves or no review is refused with the call to make first. Saving the same FEN from the same source again returns the existing row with `alreadySaved: true`; a bare FEN and the same FEN reached through a game are different sources. At most 500 saved positions per user: the 501st is a 400 until one is removed. Scope: one coaching_positions row owned by the token holder, attributed to the token that wrote it. Spends the 60-per-hour coaching-write bucket, on top of the 120/minute door limit.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "label"
                ],
                "properties": {
                  "fen": {
                    "type": "string",
                    "maxLength": 100,
                    "description": "The position, as a FEN. Not with gameId."
                  },
                  "gameId": {
                    "type": "string",
                    "maxLength": 64,
                    "description": "An owned, reviewed game from listGames."
                  },
                  "ply": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 1000,
                    "description": "The ply within gameId whose position (before the move) to save. Required with gameId."
                  },
                  "label": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 80
                  },
                  "why": {
                    "type": "string",
                    "maxLength": 500
                  },
                  "focusId": {
                    "type": "string",
                    "maxLength": 64
                  }
                },
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The saved position, or the existing one with `alreadySaved: true`.",
            "content": {
              "application/json": {
                "example": {
                  "id": "p7d3…",
                  "fen": "r4rk1/1pp2ppp/2n5/pB1p1b2/3PnB2/1Q2PN2/PP3PPP/R4RK1 b - - 0 16",
                  "sideToMove": "black",
                  "source": {
                    "gameId": "6a3b0c9e-…",
                    "ply": 32
                  },
                  "label": "Knight fork you missed",
                  "why": null,
                  "bestMove": "Nxf2",
                  "acceptableAnswers": [
                    "Nxf2"
                  ],
                  "focusId": null,
                  "createdAt": "2026-09-04T10:00:00.000Z",
                  "lastQuizzedAt": null,
                  "quizResults": [],
                  "alreadySaved": false
                }
              }
            }
          },
          "400": {
            "description": "A key outside the documented set, a value of the wrong type, a label outside 1 to 80 characters, a why over 500, control characters in a string, both fen and gameId, neither, a game the token owner does not own or has not reviewed, a ply missing, outside 1 to 1000, or outside the analysed range, a FEN that does not parse, a focusId that names no focus of the owner's, or the 500-position cap."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "413": {
            "description": "The declared or actual request body exceeds the bounded JSON envelope (about 10KB, sized on the longest legal coaching body)."
          },
          "415": {
            "description": "Content-Type is not application/json."
          },
          "429": {
            "description": "The shared 120/minute API limit or the 60-per-hour coaching-write limit was exceeded."
          },
          "503": {
            "description": "The coaching-write limiter could not be evaluated, so nothing was written. Fails closed. Retryable."
          }
        }
      }
    },
    "/api/v1/me/coaching/positions/{id}": {
      "delete": {
        "operationId": "removeSavedPosition",
        "summary": "Remove a saved position",
        "description": "Delete one saved position owned by the token holder, by the exact id from listSavedPositions or savePosition. This cannot be undone. Only that coaching_positions row goes: the game it came from, its review, any coaching entry that referenced it (its refs are left exactly as they were) and every other user are untouched. Honesty rule on the 404: an unknown id, an id belonging to somebody else, a malformed id and one longer than 64 characters are indistinguishable, so the response gives no oracle over the id space; repeating a successful delete returns that same 404. This is the only coaching row an agent can delete: focuses are resolved with setTrainingFocus and entries are append-only; the user deletes those from their dashboard. It is still a durable write, so it spends the 60-per-hour coaching-write bucket like the other three, on top of the 120/minute door limit.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Saved position id, exactly as returned by listSavedPositions or savePosition.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The position was removed.",
            "content": {
              "application/json": {
                "example": {
                  "removed": true,
                  "id": "p7d3…"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "404": {
            "description": "No saved position with that id belongs to the token owner (unknown, foreign and malformed ids are indistinguishable).",
            "content": {
              "application/json": {
                "example": {
                  "error": "Saved position not found. Check the id against list_saved_positions."
                }
              }
            }
          },
          "429": {
            "description": "The shared 120/minute API limit or the 60-per-hour coaching-write limit was exceeded."
          },
          "503": {
            "description": "The coaching-write limiter could not be evaluated, so nothing was deleted. Fails closed. 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"
          }
        }
      }
    }
  }
}