Unix daemon for Lichess correspondence chess — play, watch, and get notified about your games entirely from the terminal.
- Connects to Lichess via personal API token
- Detects new moves in correspondence games in real time via Lichess event streams
- Sends moves and draw offers to specific games
- Seeks new correspondence games and handles challenges
- Fetches random puzzles by theme, difficulty, and color
- Communicates through a local Unix socket using newline-delimited JSON
- Fires external hook commands (
--on-move,--on-game-start,--on-game-finish) when game events occur - Classic Unix daemon: double-fork, PID file, signal handling
# Install
git clone https://github.com/luisfer-cli/lichessd
cd lichessd && uv sync
# Get your API token at https://lichess.org/account/oauth/token
# (enable scopes: board:play, puzzle:read, challenge:write)
mkdir -p ~/.lichessd
echo "api_key=lip_your_token_here" > ~/.lichessd/config
# Run
uv run lichessd
# Communicate via the Unix socket
echo '{"id":1,"cmd":"games"}' | socat - UNIX-CONNECT:~/.lichessd/socket
echo '{"id":1,"cmd":"puzzle"}' | socat - UNIX-CONNECT:~/.lichessd/socketuv sync./scripts/build.sh
# Binary at dist/lichessd- Python ≥ 3.10
- uv (or pip with
httpx)
The API key is resolved in this order (first match wins):
--api-keyCLI argumentLICHESS_API_KEYenvironment variable~/.lichessd/configfile
# ~/.lichessd/config
api_key=lip_your_token_here
Or just the token on the first line:
lip_your_token_here
lichessd [OPTIONS]
Options:
--api-key KEY Lichess API token
--config PATH Config file path (default: ~/.lichessd/config)
--socket PATH Unix socket path (default: ~/.lichessd/socket)
--pid-file PATH PID file path (default: ~/.lichessd/lichessd.pid)
--on-move COMMAND Hook: run COMMAND when a new move arrives
--on-game-start COMMAND Hook: run COMMAND when a correspondence game starts
--on-game-finish COMMAND Hook: run COMMAND when a correspondence game ends
--default-angle THEME Default puzzle theme (default: mix)
--daemonize, -d Detach and run in the background
--foreground, -f Stay in foreground (default behavior)
# Run in foreground with hook scripts
lichessd --on-move ~/bin/notify-move.sh --on-game-start ~/bin/notify-game.sh
# Daemonize
lichessd -d
# Custom socket
lichessd --socket /tmp/chess.sockThe daemon listens on a Unix domain socket. Communication uses newline-delimited JSON
(one JSON object per line, terminated by \n).
{"id": 1, "cmd": "<command>", "param1": "value1", ...}| Field | Type | Required | Description |
|---|---|---|---|
id |
int | Yes | Request identifier, echoed in the response |
cmd |
string | Yes | Command name |
| (all others) | any | — | Command-specific parameters |
{"type": "response", "id": 1, "ok": true, "data": {...}}{"type": "response", "id": 1, "ok": false, "error": "description"}When you send a subscribe command, the daemon pushes events as they happen:
{"type": "move", "game_id": "abc123", "fen": "rnbq...", "last_move": "e7e5", ...}
{"type": "game_start", "game_id": "def456", "opponent": {"username": "juanito"}, ...}
{"type": "game_finish", "game_id": "ghi789"}echo '{"id":1,"cmd":"games"}' | socat - UNIX-CONNECT:~/.lichessd/socketResponse:
{
"type": "response",
"id": 1,
"ok": true,
"data": {
"games": [
{
"game_id": "abc123",
"full_id": "abc123xxxx",
"fen": "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq -",
"last_move": "e7e5",
"is_my_turn": true,
"color": "white",
"opponent": { "id": "juanito", "username": "Juanito", "rating": 1500 },
"rated": true,
"seconds_left": 1209600
}
],
"count": 1
}
}# JSON game state
echo '{"id":1,"cmd":"game","game_id":"abc123"}' | socat - UNIX-CONNECT:~/.lichessd/socket
# With PGN export
echo '{"id":1,"cmd":"game","game_id":"abc123","pgn":"true"}' | socat - UNIX-CONNECT:~/.lichessd/socket# Send a move in UCI format
echo '{"id":1,"cmd":"move","game_id":"abc123","move":"e2e4"}' | socat - UNIX-CONNECT:~/.lichessd/socket
# Offer/accept a draw
echo '{"id":1,"cmd":"move","game_id":"abc123","move":"e2e4","offering_draw":"true"}' | socat - UNIX-CONNECT:~/.lichessd/socket# Random puzzle, default theme
echo '{"id":1,"cmd":"puzzle"}' | socat - UNIX-CONNECT:~/.lichessd/socket
# Specific theme, difficulty, and color
echo '{"id":2,"cmd":"puzzle","angle":"mateIn2","difficulty":"harder","color":"white"}' | socat - UNIX-CONNECT:~/.lichessd/socketResponse includes puzzle.id, puzzle.fen, puzzle.rating, puzzle.solution (UCI moves),
puzzle.themes, and the source game with player info.
# Default: correspondence, 3 days per move, random color
echo '{"id":1,"cmd":"seek"}' | socat - UNIX-CONNECT:~/.lichessd/socket
# Correspondence, 7 days, rated
echo '{"id":1,"cmd":"seek","days":7,"rated":"true","color":"white"}' | socat - UNIX-CONNECT:~/.lichessd/socket
# Rated with rating range
echo '{"id":1,"cmd":"seek","days":2,"rating_range":"1500-2000"}' | socat - UNIX-CONNECT:~/.lichessd/socket
# Real-time clock (blitz/rapid)
echo '{"id":1,"cmd":"seek","time":5,"increment":3,"rated":"true"}' | socat - UNIX-CONNECT:~/.lichessd/socketParameters: days, time, increment, rated, variant, rating_range, color.
Defaults to correspondence (days=3) and random color when neither days nor time is specified.
# Create a challenge to a specific user (correspondence by default)
echo '{"id":1,"cmd":"challenge","action":"create","username":"juanito","days":2}' | socat - UNIX-CONNECT:~/.lichessd/socket
# Create with custom starting position
echo '{"id":1,"cmd":"challenge","action":"create","username":"juanito","fen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -"}' | socat - UNIX-CONNECT:~/.lichessd/socket
# Accept an incoming challenge
echo '{"id":1,"cmd":"challenge","action":"accept","challenge_id":"abcd1234"}' | socat - UNIX-CONNECT:~/.lichessd/socket
# Decline with reason
echo '{"id":1,"cmd":"challenge","action":"decline","challenge_id":"abcd1234","reason":"later"}' | socat - UNIX-CONNECT:~/.lichessd/socket# Connect and subscribe. Keep the connection open to receive events.
echo '{"cmd":"subscribe"}' | socat - UNIX-CONNECT:~/.lichessd/socketAfter subscribing, the daemon will push events as they arrive:
{"type":"game_start","game_id":"abc123","fen":"...","opponent":{"username":"juanito"},...}
{"type":"move","game_id":"abc123","last_move":"e7e5","is_my_turn":true,...}
{"type":"game_finish","game_id":"abc123"}Hooks execute a shell command when a game event occurs. Data is passed as positional arguments and environment variables.
command 'game_id' 'move' 'fen' 'color' 'is_my_turn'
| Variable | Value |
|---|---|
LICHESSD_EVENT |
move, game_start, or game_finish |
LICHESSD_GAME_ID |
Game ID (e.g. abc123) |
LICHESSD_FULL_ID |
Full ID with player suffix |
LICHESSD_FEN |
Current board position |
LICHESSD_LAST_MOVE |
Last move in UCI format |
LICHESSD_IS_MY_TURN |
True or False |
LICHESSD_COLOR |
white or black |
LICHESSD_OPPONENT_USERNAME |
Opponent's Lichess username |
LICHESSD_OPPONENT_ID |
Opponent's Lichess user ID |
LICHESSD_SECONDS_LEFT |
Remaining clock in seconds |
#!/bin/bash
# ~/bin/chess-notify.sh — desktop notification on new games or moves
case "$LICHESSD_EVENT" in
game_start)
notify-send "Chess" "New game vs $LICHESSD_OPPONENT_USERNAME" --icon=chess
;;
move)
if [ "$LICHESSD_IS_MY_TURN" = "True" ]; then
notify-send "Your turn!" "Game $LICHESSD_GAME_ID — $LICHESSD_LAST_MOVE" --icon=chess
fi
;;
game_finish)
notify-send "Chess" "Game $LICHESSD_GAME_ID finished" --icon=chess
;;
esaclichessd --on-move ~/bin/chess-notify.sh \
--on-game-start ~/bin/chess-notify.sh \
--on-game-finish ~/bin/chess-notify.shflowchart LR
A["nc/socat<br/>scripts & apps"] --> B["Unix socket<br/>(JSON lines)"]
B --> C["LichessAPI<br/>(httpx client)"]
C --> D[lichess.org]
D --> C
C --> B
B --> A
B --> E["GameStore<br/>(event streams)"]
- Initial sync (
GET /api/account/playingonce at startup): discovers correspondence games already in progress and starts a stream for each. - Global event stream (
GET /api/stream/event): real-time connection for new game starts, challenges, and game completions. - Per-game event streams (
GET /api/board/game/stream/{id}): real-time move detection — eachgameStateevent carries the full move list, so no polling is needed. - Unix socket server: accepts client connections, dispatches commands, broadcasts events to all connected clients.
- Hooks: external shell scripts run asynchronously on game events.
./scripts/build.sh
# Output: dist/lichessd (ELF 64-bit, ~12 MB)The script uses PyInstaller to bundle Python + httpx into a single binary. Requires the
project venv with PyInstaller installed (uv add --dev pyinstaller).
uv run python scripts/test_integration.pyThe test suite starts the daemon with a fake API key, connects to the socket, and verifies all 7 commands, error handling, ID matching, persistent connections, and hook registration.
Get your token at https://lichess.org/account/oauth/token.
Required scopes:
board:play— play games, stream events, create seekspuzzle:read— fetch puzzleschallenge:write— accept/decline challenges
src/lichessd/
├── __init__.py Package metadata (version 0.1.0)
├── __main__.py Entry point, CLI parsing, asyncio orchestration
├── api.py LichessAPI — all HTTP calls, retry logic, rate limiting
├── config.py Config — CLI args, env vars, config file parsing
├── daemon.py Daemonization (double-fork), PID file, signal handlers
├── games.py GameStore — event streams, game state tracking
├── notify.py Hook command execution with environment variables
├── protocol.py JSON Lines encoding/decoding for Unix socket protocol
├── puzzles.py Puzzle fetching helper
└── server.py UnixSocketServer — socket listener, command dispatch, event broadcast
MIT