Reference for the deployed bot: how the pieces fit together, how to run and stop it, and the operational traps that have already bitten once. The bot is live as @PyCheckmate — this is not a roadmap, it's the manual.
Lichess exposes a Bot API (a subset of the Board API): a program logs in
with a personal API token, listens to an event stream over HTTPS long-polling,
accepts challenges, and posts moves in UCI coordinate notation (e2e4,
e7e8q). You never implement that protocol by hand — the official
lichess-bot bridge does all
of it and talks to your engine over the UCI protocol (stdin/stdout text).
Lichess servers <—HTTPS/JSON—> lichess-bot (Python bridge) <—UCI text—> engine/uci.py —> engine/search.py + engine/eval.py + engine/board.py
The engine side of that contract is four things, all in engine/:
| Piece | Role |
|---|---|
GameState.from_fen() / to_fen() |
positions arrive as FEN |
Move.get_uci_notation() |
moves leave as UCI strings |
Move.from_ai_tuple() |
converts search output to a full move |
| Zobrist keys + repetition tracking | so the bot neither blunders into nor misses threefold draws |
engine/uci.py implements the command set the bridge needs — uci, isready,
ucinewgame, position startpos|fen … moves …, go depth/movetime/wtime…,
quit. Drive it by hand to check it:
$ python -m engine.uci
uci
position startpos moves e2e4 e7e5
go depth 4
bestmove g1f3
quit
- Clock-aware time management —
handle_goparses the clock fields (wtime/btime/winc/binc) Lichess sends on everygoand turns them into a per-move budget viaclock_move_budget(), letting iterative deepening's timer end the search rather than a depth cap. Explicitgo depth N/go movetime MSstill win. Covered bytests/test_uci.py. - Persistent transposition table —
engine/uci.pyholds one table for a whole game (find_best_move(..., tt=...)), so each move starts warm from the previous searches; cleared onucinewgame, capped atTT_MAX_ENTRIES. - PyPy hosting — the engine is pure stdlib specifically so it can run under
PyPy (~1.3x on the search, ~2.3x on move generation; the JIT stays warm
across a game). Never import pygame from
engine/. - Dead-draw detection —
_insufficient_materialscores K vs K, lone minor, and KNN vs K as exact draws, so the bridge's draw-offer handling behaves. - Opening book — bridge-side only, zero engine work: lichess-bot reads a
polyglot
.bin(engines/komodo.bin) underengine.polyglotinconfig.yml.
The bot only needs outbound HTTPS, so any always-on machine works. This
deployment runs on a MacBook Air (M2) that travels, which drives every
choice below: the recipe is macOS-native and manually controlled. On
Windows, run the same pieces under WSL — the engine is plain UCI, but bot
and setup.sh are POSIX shell.
The real risk isn't crashes — lichess-bot reconnects through network drops — it's sleep. Screen saver, display sleep and screen lock are all harmless; background processes keep running and the connection stays up. Only full system sleep suspends the process and drops the game, which then flags or aborts.
caffeinate -s fixes it: it asserts "don't system-sleep" only while the
wrapped process runs, and only on AC power. So keep the Mac plugged in;
the lid may stay open and the screen is free to sleep. Nothing persistent
changes — unlike sudo pmset -c disablesleep 1 there is no global setting to
remember to undo.
Deployment used to be a launchd LaunchAgent (RunAtLoad + KeepAlive), and
that design fought the way this bot is actually hosted. Auto-start at login
opened the event stream on whatever Wi-Fi the laptop had joined, and
KeepAlive relaunched a crashing bot in a tight loop — each relaunch
re-opening the stream, which is exactly what the rate limiter punishes. launchd
was retired in favour of a plain script.
bot in this repo is that script. Copy it into the lichess-bot clone (it
resolves paths relative to its own location), make it executable, and optionally
put it on your PATH:
cp bot <lichess-bot clone>/bot && chmod +x <lichess-bot clone>/bot
ln -sf <lichess-bot clone>/bot ~/.local/bin/bot
Four subcommands, pidfile-based (bot.pid), all logging to bot.log:
bot up # start (wrapped in caffeinate -s), refuses if already running
bot down # stop, then sweep every process from the bot's venv
bot status # running/stopped, process count, recent rate-limit lines, log tail
bot log # tail -f bot.log
bot up wraps the bot in caffeinate -s via nohup, so it survives closing
the terminal. There is deliberately no auto-restart: a crashed bot stays
down until you say otherwise, which is what keeps the rate limiter happy.
Stopping is the whole cleanup — caffeinate dies with the bot and the Mac
resumes normal sleep.
run.sh passes -v, which puts lichess-bot at DEBUG level. At that level it
echoes every UCI info line the engine emits — one per deepening
iteration, per move, per game — plus the full move list of every position
command, all wrapped in ANSI colour codes. That verbosity is worth having:
those info lines exist because two bugs stayed invisible for weeks without
them (see docs/BUILD_LOG.md, "you cannot debug what you cannot see"). But
bot up appends and nothing else truncates, so the file reaches 129 MB
across 992,245 lines faster than the growth rate suggests.
bot up therefore rotates it: over 50 MB, bot.log moves to bot.log.1
before the bot starts, keeping exactly one generation. Two files, bounded
total, and the last run is still there to read when something goes wrong.
Turning off -v is the other available fix and the wrong one — it trades the
ability to debug a live bot for disk space that costs nothing.
sf_watch normally stamps each game with the current git rev, and a new rev
starts a new measurement set — which is right when the engine changed and wrong
when it did not. Committing docs, or anything under engine/tools/, moves HEAD
without altering a line the engine executes, so restarting the bot after one
would split a single run into two labels that cannot be averaged (see
"never average across engine_version" in CLAUDE.md).
BOT_ENGINE_VERSION=fd9ebc0 bot up
bot up writes the pin to engine_version beside the pidfile so bot down
resolves the same cut days later; unset, it removes the file and the stamp goes
back to HEAD. Only pin when git diff <pinned-rev> HEAD -- engine/ shows
nothing outside engine/tools/ — if the engine itself changed, the new label is
the correct one and a stale pin would file new games under the old engine, which
is the exact error the label exists to prevent.
The critical part is the sweep in bot down (and defensively in bot up):
it doesn't just kill the main pid, it pkills everything running the clone's
.venv python. The next section is why.
Lichess protects /api/stream/event (the connection the bot holds open for
challenges and game events) with an anti-polling rate limit. Open it too
often in a short window and Lichess returns 429s; per the
official API tips the only cure is to wait
a full minute before touching the API again. Symptoms: the bot shows online but
"accepts challenges without playing" (the stream is down exactly when a game
needs its first move), and the log fills with
RateLimitedError: /api/stream/event is rate-limited.
The mechanism took a while to diagnose, and the obvious suspect was wrong.
Restart bursts were blamed first; the actual culprit was orphaned child
processes. lichess-bot runs its event-stream watcher (watch_control_stream)
as a multiprocessing.Process. Kill the main process without a clean shutdown
and that child survives, reparented to PID 1 — invisible unless you go looking —
with its reconnect loop re-opening /api/stream/event forever. From Lichess's
side the token never went quiet, so the 429 penalty renewed no matter how long
the human "waited". Observed worst case: six orphans quietly hammering the API
for 16 hours while every fresh start hit 429s within seconds.
Diagnose with:
ps aux | grep "lichess-bot/.venv"
That is why bot down's sweep kills every process from the clone's
.venv, not just the pidfile pid — and why bot up runs the same sweep first
(then waits 60 s, honouring the official cooldown) if it finds strays.
Two smaller mitigations live in the lichess-bot clone as local patches:
watch_control_streamcatchesRateLimitedErrorand sleeps out the whole remaining cooldown instead of retrying every second — one reconnect per cooldown instead of sixty tracebacks a minute in the log.handle_challengeapplies exponential backoff (60 s → 600 s cap) to 429s on the challenge endpoint.
Day-to-day rule: batch config changes and spend exactly one
bot down / bot up cycle on them, never a burst — every stream re-open counts
against the anti-polling window. If bot status shows rate-limit lines two
minutes after a start, run bot down, confirm with bot status that zero
processes remain (that's the lesson), wait a couple of minutes, and bot up
once.
Config lives in the clone's config.yml (kept out of git; the token is read
from a .token file by run.sh rather than stored in the config). Current
shape:
-
Casual only, standard variant,
blitz/rapid/classical. -
Bullet is off on purpose: 2 s
move_overheadplus Python and API latency makes flagging a real risk. Blitz is safe —clock_move_budget()scales the per-move budget to the clock. -
Matchmaking on (
allow_matchmaking: true): the bridge pulls the live online-bot list from/api/bot/online, filters by rating and variant, and afterchallenge_timeoutidle minutes challenges one at random using a time control drawn fromchallenge_initial_time/challenge_increment— currently 300 s / 600 s with 0 s / 2 s increment.accept_bot: truelets bots challenge back.challenge_timeoutis 2, lowered from 5 on 2026-07-22. The old value left the machine idle for most of the gap between games: measured across 101 games, the median game ran 13.2 min and the median idle gap 6.2 min, a 55% duty cycle. The floor is set bysf_watch, which grades each finished game with Stockfish in that gap — game-end to analysis-done measured 0.42-1.42 min, median 0.69, andsf_watchonly checksbot_is_playing()before it picks up a game, so an analysis already in flight is not interrupted. Atchallenge_timeout: 1the next challenge would fire while Stockfish still held the CPU in 2% of games, putting a full-strength analysis inside a live rated game and corrupting the very records the run exists to produce. At 2 that is zero, with 0.58 min of headroom. lichess-bot clamps this to a minimum of 1 anyway (lib/config.py), so lower would need patching the bridge. -
ponder: false;uci_optionscommented out (the adapter declares none).
Before anything goes online:
uv run pytest tests/ -q— engine correctness (perft, Zobrist, search sanity).uv run --no-project python -m engine.tools.selfplay— spawns twoengine.uciprocesses and plays 20 full games refereed by aGameState, exiting non-zero on any illegal move or crash. This is the integration coverage perft can't give: UCI round-trip, search, make/unmake and game-end detection over complete games.lichess-botin casual-only mode against your own human account.- Only then open the challenge gate to the public and to rated games.
lichess-bot writes PGNs to game_records/ in the clone. Skim losses for
blunders and feed concrete positions back into tests/ as regression FENs via
GameState.from_fen. engine/analysis.py grades a whole game
(blunder/mistake/missed_win/…), which makes those PGNs a labelled dataset
rather than just a pile of games.