litchee is an asynchronous, builder-pattern Rust client for the
Lichess API, covering every documented operation (184/184 of the official
OpenAPI spec) — from looking up players to playing games, running tournaments,
streaming live broadcasts, and "Log in with Lichess" via OAuth2 + PKCE.
It is open source (MIT) and aims at feature parity with the official API,
with an ergonomic, strongly-typed surface: every DTO is prefixed Lichess*,
every failure maps to a specific LichessError variant, and every endpoint is
reached through a small accessor on the client (client.account(),
client.broadcasts(), …).
use futures_util::StreamExt;
use litchee::LichessClient;
#[tokio::main]
async fn main() -> litchee::Result<()> {
let client = LichessClient::builder().token("lip_your_token").build()?;
// A simple JSON request.
let me = client.account().profile().await?;
println!("Logged in as {}", me.user.username);
// A streaming (NDJSON) request.
let mut games = client.games().export_user("bobby").max(5).stream().await?;
while let Some(game) = games.next().await {
println!("game {}", game?.id);
}
Ok(())
}The Lichess API is large (24 tags, ~184 operations, four hosts, JSON + NDJSON +
PGN). litchee wraps all of it behind one cohesive, async-first client so Rust
applications — bots, analysis tools, "Log in with Lichess" web apps, dataset
exporters — can talk to Lichess without hand-rolling HTTP, streaming, and OAuth.
- Complete. All 184 documented operations are implemented and covered by tests.
- Async & streaming-native. Built on
tokio+reqwest; NDJSON endpoints (event streams, board/bot game state, game exports) return aStreamyou can consume directly. - Typed end to end.
Lichess*DTOs and an exhaustive, matchable error type. - "Log in with Lichess". First-class OAuth2 Authorization Code flow with PKCE, plus plain personal access tokens.
- Organized by business concern. The module tree mirrors the API's own
structure, grouped into categories under
litchee::api.
[dependencies]
litchee = "0.1"
tokio = { version = "1", features = ["full"] }
futures-util = "0.3" # to consume streams with `.next()`The minimum supported Rust version is 1.95 (edition 2024).
New to OAuth or PKCE? The PKCE flow guide walks through the whole "Log in with Lichess" flow step by step, for beginners — with a glossary of OAuth terms at the end.
use litchee::LichessClient;
use litchee::api::auth::oauth::{AuthorizationRequest, CodeExchange, Scope};
# async fn run() -> litchee::Result<()> {
let client = LichessClient::new();
// 1. Build the authorization URL; persist `state` and `verifier` in the session.
let auth = client.oauth().authorization_url(&AuthorizationRequest {
client_id: "your.app",
redirect_uri: "https://your.app/callback",
scopes: &[Scope::PreferenceRead, Scope::PuzzleRead, Scope::StudyRead],
username_hint: None,
})?;
println!("Send the user to: {}", auth.url);
// 2. After the redirect, check `state`, then exchange the returned `code`.
let token = client.oauth().exchange_code(&CodeExchange {
code: "code_from_redirect",
code_verifier: &auth.verifier,
redirect_uri: "https://your.app/callback",
client_id: "your.app",
}).await?;
// 3. Build an authenticated client with the new token.
let user = LichessClient::builder().token(token.access_token.into_inner()).build()?;
println!("Hello, {}", user.account().profile().await?.user.username);
# Ok(())
# }use futures_util::StreamExt;
use litchee::LichessClient;
# async fn run() -> litchee::Result<()> {
let client = LichessClient::builder().token("lip_your_token").build()?;
let me = client.account().profile().await?;
// Stream this user's last 20 rated blitz games as decoded JSON.
let mut games = client
.games()
.export_user(&me.user.username)
.max(20)
.rated(true)
.perf_type("blitz")
.stream()
.await?;
while let Some(game) = games.next().await {
let game = game?;
println!("{} — winner: {:?}", game.id, game.winner);
}
# Ok(())
# }use futures_util::StreamExt;
use litchee::LichessClient;
# async fn run() -> litchee::Result<()> {
let client = LichessClient::builder().token("lip_your_token").build()?;
// Stream the authenticated user's puzzle history (needs the `puzzle:read` scope).
let mut activity = client.puzzles().activity(Some(50)).await?;
while let Some(round) = activity.next().await {
let round = round?;
let outcome = if round.win { "solved" } else { "failed" };
println!("puzzle {} — {outcome}", round.puzzle.id);
}
# Ok(())
# }use futures_util::StreamExt;
use litchee::LichessClient;
# async fn run() -> litchee::Result<()> {
let client = LichessClient::builder().token("lip_your_token").build()?;
// List a user's studies, then export the first one as PGN.
let mut studies = client.studies().list_metadata("bobby").await?;
if let Some(study) = studies.next().await {
let study = study?;
let pgn = client.studies().export_study_pgn(&study.id).await?;
println!("{} — {} bytes of PGN", study.name, pgn.len());
}
# Ok(())
# }use futures_util::StreamExt;
use litchee::LichessClient;
# async fn run() -> litchee::Result<()> {
let client = LichessClient::new();
// Browse official broadcasts, then export a round's games as PGN.
let mut official = client.broadcasts().official().await?;
if let Some(broadcast) = official.next().await {
let broadcast = broadcast?;
println!("Broadcast: {}", broadcast.tour.name);
if let Some(round) = broadcast.rounds.first() {
let pgn = client.broadcasts().round_pgn(&round.id).await?;
println!("Round '{}' — {} bytes of PGN", round.name, pgn.len());
}
}
# Ok(())
# }The examples/ directory contains runnable programs:
oauth_flow— the full "Log in with Lichess" flow end to end: PKCE authorization, opening the browser, catching the redirect on a tiny local listener, exchanging the code, then listing the signed-in user's recent games, puzzle attempts, and studies (cargo run --example oauth_flow).profile— print the authenticated user's profile (LICHESS_TOKEN=lip_xxx cargo run --example profile).tv_feed— stream the Lichess TV feed (cargo run --example tv_feed).
Every documented Lichess operation is implemented. Endpoints are reached through
an accessor method on LichessClient and live in a module under litchee::api,
grouped by category. The tables below map each concern's endpoints to its module.
Auth
client.oauth() — litchee::api::auth::oauth (4 endpoints)
GET /oauth, POST /api/token, DELETE /api/token, POST /api/token/test
Users
client.account() — litchee::api::users::account (6 endpoints)
GET /api/account, GET /api/account/email, GET /api/account/preferences, GET /api/account/kid, POST /api/account/kid, GET /api/timeline
client.users() — litchee::api::users::players (13 endpoints)
GET /api/user/{username}, POST /api/users, GET /api/users/status, GET /api/crosstable/{u1}/{u2}, GET /api/player/autocomplete, GET /api/user/{username}/rating-history, GET /api/user/{username}/perf/{perf}, GET /api/user/{username}/activity, GET /api/player, GET /api/player/top/{nb}/{perfType}, GET /api/streamer/live, GET /api/user/{username}/note, POST /api/user/{username}/note
client.fide() — litchee::api::users::fide (3 endpoints)
GET /api/fide/player/{playerId}, GET /api/fide/player/{playerId}/ratings, GET /api/fide/player
Social
client.relations() — litchee::api::social::relations (5 endpoints)
GET /api/rel/following, POST /api/rel/follow/{username}, POST /api/rel/unfollow/{username}, POST /api/rel/block/{username}, POST /api/rel/unblock/{username}
client.messaging() — litchee::api::social::messaging (1 endpoint)
POST /inbox/{username}
client.teams() — litchee::api::social::teams (14 endpoints)
GET /api/team/{teamId}, GET /api/team/all, GET /api/team/of/{username}, GET /api/team/search, GET /api/team/{teamId}/users, GET /api/team/{teamId}/requests, POST /api/team/{teamId}/request/{userId}/accept, POST /api/team/{teamId}/request/{userId}/decline, POST /api/team/{teamId}/kick/{userId}, POST /team/{teamId}/join, POST /team/{teamId}/quit, POST /team/{teamId}/pm-all, GET /api/team/{teamId}/arena, GET /api/team/{teamId}/swiss
Tournaments
client.arena() — litchee::api::tournaments::arena (13 endpoints)
GET /api/tournament, GET /api/tournament/{id}, POST /api/tournament, POST /api/tournament/{id}, POST /api/tournament/team-battle/{id}, GET /api/tournament/{id}/teams, POST /api/tournament/{id}/join, POST /api/tournament/{id}/withdraw, POST /api/tournament/{id}/terminate, GET /api/tournament/{id}/results, GET /api/tournament/{id}/games, GET /api/user/{username}/tournament/created, GET /api/user/{username}/tournament/played
client.swiss() — litchee::api::tournaments::swiss (10 endpoints)
GET /api/swiss/{id}, POST /api/swiss/new/{teamId}, POST /api/swiss/{id}/edit, POST /api/swiss/{id}/join, POST /api/swiss/{id}/withdraw, POST /api/swiss/{id}/terminate, POST /api/swiss/{id}/schedule-next-round, GET /swiss/{id}.trf, GET /api/swiss/{id}/results, GET /api/swiss/{id}/games
client.simuls() — litchee::api::tournaments::simuls (1 endpoint)
GET /api/simul
Training
client.puzzles() — litchee::api::training::puzzles (11 endpoints)
GET /api/puzzle/daily, GET /api/puzzle/{id}, GET /api/puzzle/next, GET /api/puzzle/activity, GET /api/puzzle/batch/{angle}, POST /api/puzzle/batch/{angle}, GET /api/puzzle/dashboard/{days}, GET /api/puzzle/replay/{days}/{theme}, GET /api/storm/dashboard/{username}, GET /api/racer/{id}, POST /api/racer
client.studies() — litchee::api::training::studies (9 endpoints)
GET /api/study/{studyId}/{chapterId}.pgn, GET /api/study/{studyId}.pgn, GET /api/study/by/{username}/export.pgn, GET /api/study/by/{username}, POST /api/study, POST /api/study/{studyId}/import-pgn, POST /api/study/{studyId}/{chapterId}/moves, POST /api/study/{studyId}/{chapterId}/tags, DELETE /api/study/{studyId}/{chapterId}
Broadcasting
client.broadcasts() — litchee::api::broadcasting::broadcasts (19 endpoints)
GET /api/broadcast, GET /api/broadcast/top, GET /api/broadcast/search, GET /api/broadcast/by/{username}, GET /api/broadcast/my-rounds, GET /api/broadcast/{id}, GET /api/broadcast/{tourSlug}/{roundSlug}/{roundId}, GET /api/broadcast/round/{roundId}.pgn, GET /api/broadcast/{id}.pgn, GET /api/stream/broadcast/round/{roundId}.pgn, POST /api/broadcast/round/{roundId}/push, POST /api/broadcast/round/{roundId}/reset, GET /broadcast/{id}/players, GET /broadcast/{id}/players/{playerId}, GET /broadcast/{id}/teams/standings, POST /broadcast/new, POST /broadcast/{id}/edit, POST /broadcast/{id}/new, POST /broadcast/round/{roundId}/edit
client.tv() — litchee::api::broadcasting::tv (4 endpoints)
GET /api/tv/channels, GET /api/tv/feed, GET /api/tv/{channel}, GET /api/tv/{channel}/feed
Database (separate hosts: explorer / tablebase)
client.opening_explorer() — litchee::api::database::opening_explorer (4 endpoints, explorer.lichess.org)
GET /masters, GET /lichess, GET /player, GET /masters/pgn/{gameId}
client.tablebase() — litchee::api::database::tablebase (3 endpoints, tablebase.lichess.org)
GET /standard, GET /atomic, GET /antichess
client.analysis() — litchee::api::database::analysis (1 endpoint)
GET /api/cloud-eval
Gameplay
client.board() — litchee::api::gameplay::board (13 endpoints)
GET /api/stream/event, GET /api/board/game/stream/{gameId}, POST /api/board/game/{gameId}/move/{move}, POST /api/board/game/{gameId}/abort, POST /api/board/game/{gameId}/resign, POST /api/board/game/{gameId}/draw/{accept}, POST /api/board/game/{gameId}/takeback/{accept}, POST /api/board/game/{gameId}/claim-victory, POST /api/board/game/{gameId}/claim-draw, POST /api/board/game/{gameId}/berserk, GET /api/board/game/{gameId}/chat, POST /api/board/game/{gameId}/chat, POST /api/board/seek
client.bot() — litchee::api::gameplay::bot (13 endpoints)
POST /api/bot/account/upgrade, GET /api/bot/online, GET /api/stream/event, GET /api/bot/game/stream/{gameId}, POST /api/bot/game/{gameId}/move/{move}, POST /api/bot/game/{gameId}/abort, POST /api/bot/game/{gameId}/resign, POST /api/bot/game/{gameId}/draw/{accept}, POST /api/bot/game/{gameId}/takeback/{accept}, POST /api/bot/game/{gameId}/claim-victory, POST /api/bot/game/{gameId}/claim-draw, GET /api/bot/game/{gameId}/chat, POST /api/bot/game/{gameId}/chat
client.challenges() — litchee::api::gameplay::challenges (11 endpoints)
GET /api/challenge, GET /api/challenge/{challengeId}/show, POST /api/challenge/{username}, POST /api/challenge/ai, POST /api/challenge/open, POST /api/challenge/{challengeId}/accept, POST /api/challenge/{challengeId}/decline, POST /api/challenge/{challengeId}/cancel, POST /api/challenge/{gameId}/start-clocks, POST /api/round/{gameId}/add-time/{seconds}, POST /api/token/admin-challenge
client.bulk_pairing() — litchee::api::gameplay::bulk_pairing (6 endpoints)
GET /api/bulk-pairing, POST /api/bulk-pairing, GET /api/bulk-pairing/{id}, DELETE /api/bulk-pairing/{id}, POST /api/bulk-pairing/{id}/start-clocks, GET /api/bulk-pairing/{id}/games
client.games() — litchee::api::gameplay::games (13 endpoints)
GET /game/export/{gameId}, GET /api/games/user/{username}, POST /api/games/export/_ids, GET /api/games/export/bookmarks, GET /api/games/export/imports, GET /api/account/playing, GET /api/user/{username}/current-game, GET /game/{gameId}/chat, POST /api/import, GET /api/stream/game/{id}, POST /api/stream/games-by-users, POST /api/stream/games/{streamId}, POST /api/stream/games/{streamId}/add
Engine (work endpoints on `engine.lichess.ovh`)
client.external_engine() — litchee::api::engine::external_engine (8 endpoints)
GET /api/external-engine, POST /api/external-engine, GET /api/external-engine/{id}, PUT /api/external-engine/{id}, DELETE /api/external-engine/{id}, POST /api/external-engine/{id}/analyse, POST /api/external-engine/work, POST /api/external-engine/work/{id}
The OAuth
GET /oauthendpoint is not a request the client makes — it's the URL you redirect the user's browser to.client.oauth().authorization_url(…)builds it.GET /api/stream/eventis shared by the Board and Bot APIs.
- Async-first on
tokio+reqwest(rustls). Async is required because many Lichess endpoints stream newline-delimited JSON (application/x-ndjson): event streams, board/bot game state, game/tournament exports, TV feeds. - Ergonomic streaming. Streaming endpoints return
BoxStream<'static, Result<T>>—Unpin,Send, and consumable directly withStreamExt::next(). Lines are buffered across network chunks and keep-alive blanks are skipped. - Exhaustive, matchable errors. Every failure maps to a specific
LichessErrorvariant: a structuredApiError(status → typed kind + body message +Retry-After), a typedOAuthError, transport/decode/stream failures, and PKCE validation errors. - Resilient by configuration. Connect/read timeouts are tunable on the
builder (the read timeout defaults to 5 minutes so long-lived NDJSON streams
aren't killed), the NDJSON line buffer is bounded as a DoS guard
(
max_line_bytes), and rate-limited (429) requests can be retried opt-in viaRetryPolicy— it waits the response'sRetry-Afterwhen present, otherwise exponential backoff clamped to a ceiling. Tokens are held in aSecretwrapper that redacts them fromDebugoutput. - Builder pattern. The client (
LichessClient::builder()) and every request with optional parameters (game export, challenges, tournaments, …) use builders rather than wide function signatures. - Four hosts, one client.
lichess.org,explorer.lichess.org,tablebase.lichess.org, andengine.lichess.ovhare routed internally; each is overridable on the builder (for self-hosted lila,localhost, or mocks). - Strong, forward-compatible types. Every DTO is prefixed
Lichess*and is#[non_exhaustive]. Where the API returns very large or evolving aggregates (e.g. perf stats, activity feeds, broadcast nested payloads), the documented fields are typed and the remainder is preserved losslessly inserde_jsonvalues — nothing is dropped. - Organized by business concern. The module tree mirrors the API's own
organization, grouped into categories under
src/api/(see below). Core plumbing (client,config,error,http,model,stream) lives at the crate root. - Tested deterministically. Each endpoint has an integration test that runs
against a
wiremockmock server with fixtures derived from the spec's own examples; pure logic (PKCE derivation, NDJSON splitting, error mapping, serde round-trips) has unit tests. CI runsfmt,clippy -D warnings, the test suite, the doc build, and an MSRV check. - Safety & quality gates.
#![forbid(unsafe_code)], clippypedantic,missing_docs, and a self-imposed ≤900 LOC/file and ≤20 LOC/method limit.
src/
lib.rs
client/ config/ error/ http/ model/ stream/ # core plumbing
api/
auth/ oauth
users/ account, players, fide
social/ relations, messaging, teams
tournaments/ arena, swiss, simuls
training/ puzzles, studies
broadcasting/ broadcasts, tv
database/ opening_explorer, tablebase, analysis
gameplay/ board, bot, challenges, bulk_pairing, games
engine/ external_engine
This repository includes the official Lichess OpenAPI specification as a git
submodule at reference/lichess-api/ (source:
lichess-org/api). It is the source of
truth for the client and is used during development to:
- Model DTOs faithfully — field names, optionality, and enums are taken directly from the spec's schemas.
- Drive deterministic tests — integration-test fixtures are derived from the spec's documented examples, so tests need no network or credentials.
- Verify coverage — implemented endpoints are diffed against the spec to guarantee full (184/184) coverage as the API evolves.
The submodule is development-only: it is excluded from the published crate. Clone it with:
git clone --recurse-submodules https://github.com/obazin/litchee
# or, in an existing clone:
git submodule update --init --recursivecargo build
cargo test # unit + integration tests
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt
cargo doc --no-deps --openContributions are welcome. Please keep the project conventions: one concern per
module under the right src/api/ category, Lichess*-prefixed DTOs, an
integration test per endpoint plus unit tests for pure functions, and a clean
cargo fmt / cargo clippy -D warnings. The vendored spec under reference/ is
the source of truth for endpoints and types.
Licensed under the MIT License.