Building a KOReader plugin from scratch
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.
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:
onReaderReady()— book opened, document is loadedonPageUpdate(pageno)— user turned a pageonSuspend()— device lid closedonResume()— device lid openedonCloseDocument()— book closed
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.
Menu registration
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.
0.5 50 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.