Tome
← Blog

Building a KOReader plugin from scratch

· 10 min read

Tome is a self-hosted ebook library. KOReader is an open-source e-reader app that runs on Kobo, Kindle, reMarkable, PocketBook, and Boox devices. This is the story of building a custom KOReader plugin from nothing — why I didn't use the existing protocol, how KOReader's plugin system actually works, the bugs that cost me days, and what it made possible.

Why not just use KOSync?

Every other self-hosted library that supports KOReader does it the same way: implement the KOSync protocol on the server, point KOReader's built-in sync plugin at it, done. Kavita, Komga, and Stump all take this path. Calibre-Web Automated ships a plugin, but it's a lightly modified clone of the stock KOSync code with different auth.

KOSync does one thing: sync your reading position. It stores where you stopped in a document, identified by the file's MD5 hash. That's it. No session recording, no time tracking, no series browsing, no downloading from the device. And the MD5 hash means that if you rename a file, re-download it, or edit its metadata, the sync link breaks silently.

Tome's stats system is built on reading sessions — start time, end time, pages turned, device. That data has to come from the e-reader. KOSync doesn't carry it. I needed my own plugin.

KOReader + TomeSyncWeb reader in browserTomeServerMy Dashboardsessions andpositionsstats, pages,libraries, etc

How KOReader plugins work

KOReader is a Lua application. Plugins are Lua modules that live in koreader/plugins/<name>.koplugin/. At minimum, you need two files:

tomesync.koplugin/
  _meta.lua    -- name, description (shown in plugin list)
  main.lua     -- all plugin logic

_meta.lua is trivial — a table with name, fullname, and description. The real work is main.lua, which must return a class that extends WidgetContainer.

local WidgetContainer = require("ui/widget/container/widgetcontainer")

local TomeSync = WidgetContainer:extend{
    name        = "tomesync",
    is_doc_only = false,  -- also runs in file manager
}

function TomeSync:init()
    self.ui.menu:registerToMainMenu(self)
end

function TomeSync:addToMainMenu(menu_items)
    menu_items.tomesync = {
        text = "TomeSync",
        sub_item_table = { ... },
    }
end

return TomeSync

is_doc_only = false is important — it tells KOReader to load the plugin in both the reader and the file manager. Without it, the menu only appears when a book is open.

Lifecycle hooks

KOReader dispatches events that plugins can listen to by defining methods with matching names. The ones that matter for sync:

There is no formal documentation for this. You learn it by reading KOReader's source and other plugins. The statistics.koplugin that ships with KOReader is the closest reference — it also tracks reading sessions, though it stores everything locally in a SQLite database on the device.

Making HTTP requests from Lua

KOReader bundles LuaSocket, so you get socket.http. It's synchronous and blocking — there's no async, no callbacks, no event loop. Every request blocks the UI thread.

local http  = require("socket.http")
local ltn12 = require("ltn12")

http.TIMEOUT = 5  -- seconds

local resp = {}
local ok, code = http.request{
    url     = "https://example.com/api/endpoint",
    method  = "PUT",
    headers = { ["Authorization"] = "Bearer " .. key },
    source  = ltn12.source.string(body),
    sink    = ltn12.sink.table(resp),
}

The 5-second timeout is critical. Without it, LuaSocket defaults to 60 seconds. If the server is unreachable (phone WiFi, no VPN), every sync attempt freezes KOReader for a full minute. Early users reported the device "hanging" — it was a 60-second HTTP timeout blocking the UI thread on every page turn that triggered a heartbeat.

JSON

KOReader bundles rapidjson. Standard encode / decode. One quirk: empty Lua tables are ambiguous (array or object?). rapidjson.encode() produces [], not . If your API expects an empty JSON object, you need rapidjson.encode(rapidjson.object()) or just send the literal string "".

Persistence

Plugins don't get their own storage API. You use KOReader's global settings:

-- Read
local map = G_reader_settings:readSetting("tomesync_book_map") or {}
-- Write
G_reader_settings:saveSetting("tomesync_book_map", map)

This writes to koreader/settings.reader.lua. It's a Lua table serialized to disk. It survives restarts and works fine for small data (the book-ID map, pending session queue). For large datasets you'd want SQLite, which KOReader also bundles.

Getting your plugin into the wrench menu seems simple but has a non-obvious step. Defining addToMainMenu makes the menu items available, but you also need to register your plugin's key in KOReader's menu ordering system. Otherwise it either doesn't show up or lands in a random position.

do
    local reader_order = require("ui/elements/reader_menu_order")
    local fm_order = require("ui/elements/filemanager_menu_order")

    local function insert_after(order_table, section, after_item, new_item)
        local list = order_table[section]
        if not list then return end
        for _, v in ipairs(list) do
            if v == new_item then return end
        end
        for i, v in ipairs(list) do
            if v == after_item then
                table.insert(list, i + 1, new_item)
                return
            end
        end
        table.insert(list, new_item)
    end

    insert_after(reader_order, "tools", "calibre", "tomesync")
    insert_after(fm_order, "tools", "calibre", "tomesync")
end

This inserts TomeSync right after Calibre in the tools section. The do...end block runs once when the module loads (Lua's require caches the result). I tried sorting_hint first — it didn't work reliably across KOReader versions.

The gotchas that cost me days

0-1 vs 0-100: the progress scale mismatch

KOReader reports reading progress as a float between 0 and 1. The web reader (foliate-js) reports it as a percentage between 0 and 100. I stored both in the same progress_pct column.

KOReader 0.5
Web reader 50
Database progress_pct = ???

A book at 50% was stored as 0.5 from KOReader and 50 from the browser. The stats system thought the web reader user had read the book 50 times over. The "mark as read" threshold (progress_pct >= 0.99) never triggered from the web because 50 != 0.5. Bidirectional sync showed nonsense positions.

The fix was straightforward — normalize everything to 0-1 on the server — but diagnosing it took a while because both paths "worked" in isolation. You only see the bug when the same book is read on both devices.

XPointer vs epubcfi: the white-page crash

KOReader stores reading positions as XPointer strings: /body/DocFragment[7]/body/div/p[3]. The web reader (foliate-js) expects EPUB CFI format: epubcfi(/6/124!/4/2/4,...). They're completely different position encodings for the same concept.

When a user read a book on KOReader, then opened it in the web reader, the browser tried to navigate to an XPointer. Foliate crashed to a white page. No error message, no fallback — just blank.

The fix: detect the format (does it start with epubcfi(?) and fall back to goToFraction(progress_pct) when the CFI isn't in the right format. You lose sub-page precision but the book opens at approximately the right spot instead of crashing.

Opening a book writes progress=0

Foliate fires a relocate event as soon as the book loads — before navigating to the saved position. The save handler caught that event and happily wrote progress_pct=0 to the server, overwriting the position KOReader had synced.

You open a book you were reading on your Kobo at 73%. The web reader loads, fires relocate at page 1 (progress=0), saves it to the server, then navigates to your saved CFI at 73%. Next time you open KOReader, it pulls progress=0 from the server and jumps back to the beginning.

The fix is a readyToSave flag that stays false until initial navigation completes. Simple, but you don't see it coming until someone reports "my KOReader keeps losing my place."

The comic reader never hits 100%

Comic/manga pages are 0-indexed. Progress is calculated as page / total. On the last page of a 20-page chapter, that's 19 / 20 = 95%. The book never flips to "read" because the threshold is 99%.

Worse: the completion handler fires on the last page and marks the book as "read". But the debounced progress save fires 1.5 seconds later with status='reading' at 95% and overwrites the completion. A race condition between two save paths.

Fix: use 1-based progress ((page + 1) / total), and have the last-page save emit status='read' itself. The completion handler also cancels any pending debounced save.

WiFi isn't ready when you open a book

KOReader boots WiFi lazily. When you open a book, WiFi might not be connected yet. The resolve call (matching the local filename to a Tome book ID) fails silently. The book opens fine — you can read — but sync is broken for the entire session because book_id is nil.

The fix: retry resolution on every page turn until it succeeds. Once the book is resolved, the retry stops. Sounds wasteful but the check is a single if not self.book_id guard — zero cost when WiFi connected promptly.

Naive datetimes and timezone ghosts

The server returned ISO timestamps without a timezone suffix: 2026-05-24T14:30:00. JavaScript's new Date() parses timezone-naive strings as local time. A sync that happened 5 minutes ago displayed as "2 hours ago" for a user in CEST (UTC+2).

Appending Z to every ISO string on the wire fixes it. Trivial, but you won't catch it in development if your machine's timezone is UTC.

Position updates don't retry (fixed today)

Sessions have a retry queue — if the server is unreachable when you close the lid, the session saves to disk and flushes on next resume. Position updates don't. They fire once via pcall and if they fail, the server's progress stays stale.

A user finished a book on KOReader at 99.2%. The session recorded correctly (it was queued and flushed later). But the position PUT failed during the same network blip, so the server still showed 89.7% / "reading." I fixed this by having the session endpoint itself update reading status — the retry mechanism that sessions already have now covers progress too.

What a custom plugin unlocks

A KOSync wrapper gives you position sync. A custom plugin gives you a platform. Here's what TomeSync makes possible that no amount of server-side KOSync compatibility can provide:

Session recording

Every lid-close records a session: start time, end time, pages turned, device name. This is what powers Tome's stats — streaks, time-of-day heatmaps, reading pace, completion estimates. You can't get this from KOSync because KOSync doesn't know when you started reading, only where you stopped.

Stable book matching

KOSync identifies books by the MD5 of the file. TomeSync resolves filenames to Tome's internal book IDs via a server endpoint. Rename a file, re-download it, reorganize your library — the link survives. Reading history stays attached to the book, not to a particular file on a particular device.

Series browsing and downloading

Open the wrench menu on your Kobo, tap "Browse Series," and you see every series in your library. Tap one to download all volumes — organized into folders by type and series, with proper filenames. Or tap "Download rest of series" when you're mid-volume and only get the books you haven't read yet. No OPDS client needed, no browser, no computer.

Pre-configured auth

When you download TomeSync from Tome's settings page, the .koplugin comes with your server URL and a fresh API key baked into the Lua source. Drop it into the plugins folder, restart KOReader, it works. No URL to type on an e-ink keyboard, no account to register, no key to paste.

Bidirectional web sync

Read on your Kobo in bed, open the web reader in the morning, continue where you left off. Read a few chapters in the browser, pick up KOReader that night, it jumps ahead. Both the plugin and the web reader write to the same position record on the server. KOSync can't do this because the web reader doesn't speak KOSync — it would need its own client-side implementation of the protocol.

Was it worth it?

The plugin is ~400 lines of Lua. It took longer to debug the integration edge cases (progress scales, XPointer formats, WiFi timing, timezone suffixes) than to write the actual sync logic. The KOReader plugin system is underdocumented but reasonable once you read the source.

Was it worth it? TomeSync is the reason Tome's stats page works. Without session data from the e-reader, the stats would be limited to "which books did you mark as read" — the same thing every other library shows. With it, Tome knows how long you read, when you read, how fast you read, which device you were on. That's the difference between a file manager and a reading tracker.

If you're building a self-hosted app that talks to KOReader: don't settle for KOSync compatibility. Write a plugin. The barrier is lower than you think, and the ceiling is much higher.