KOReader plugin development
KOReader's frontend is plain Lua (LuaJIT) running on a small widget toolkit. A plugin is a directory named <name>.koplugin/ with a main.lua (code) and _meta.lua (display info). There is no build step, so the fastest way to be correct is to follow the proven conventions below and verify with the bundled scripts instead of guessing at APIs.
Official docs: https://koreader.rocks/doc/ (API reference, generated from source) and the source at https://github.com/koreader/koreader (plugins/ has ~40 real plugins; plugins/hello.koplugin is the minimal one). When unsure of a signature, check the module's doc page or read the source — KOReader APIs change between releases, and the docs site is the authority over memory.
Workflow
- Scaffold (new plugin):
python3 <skill>/scripts/new_plugin.py <id> "<Full Name>" [--dir <where>]creates<id>.koplugin/from the template with menu entry, Dispatcher action, settings persistence and lifecycle hooks wired up. Edit from there rather than writing from scratch. - Implement using the patterns in this file; open the reference files only when you need them:
references/plugin-anatomy.md— file layout, loader rules,is_doc_only, init order, menusorting_hintvaluesreferences/ui-widgets.md— InfoMessage, ConfirmBox, InputDialog, MultiInputDialog, Menu, ButtonDialog, SpinWidget, Notification, with working snippetsreferences/events-and-lifecycle.md—onXxxhandlers, broadcasting, scheduling, Dispatcher, reader vs file-manager contextreferences/settings-and-storage.md— LuaSettings, G_reader_settings, per-book doc settings, DataStorage pathsreferences/testing-and-verification.md— emulator (./kodev run), luacheck, busted specs, on-device install and log readingreferences/pitfalls.md— mistakes that crash KOReader or silently do nothing; skim before finishing
- Verify (always, before saying it works):
python3 <skill>/scripts/validate_plugin.py <path/to/x.koplugin>. It checks structure,_meta.lua, menu wiring, gettext usage, LuaJIT syntax (luajit -bl) andluacheckwhen those tools exist. Fix every ERROR; read every WARN. Then say honestly what was and wasn't verified — the validator cannot prove runtime behavior; only the emulator or a device can (see testing reference).
The minimum viable plugin
-- _meta.lua
local _ = require("gettext")
return {
fullname = _("My Plugin"),
description = _([[One sentence shown in Plugin management.]]),
}
-- main.lua
local Dispatcher = require("dispatcher")
local InfoMessage = require("ui/widget/infomessage")
local UIManager = require("ui/uimanager")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local _ = require("gettext")
local MyPlugin = WidgetContainer:extend{
name = "myplugin", -- unique id, matches the dir prefix
is_doc_only = false, -- true = only loaded while a book is open
}
function MyPlugin:onDispatcherRegisterActions()
Dispatcher:registerAction("myplugin_hello", {
category = "none", event = "MyPluginHello",
title = _("My Plugin: hello"), general = true,
})
end
function MyPlugin:init()
self:onDispatcherRegisterActions()
self.ui.menu:registerToMainMenu(self)
end
function MyPlugin:addToMainMenu(menu_items)
menu_items.myplugin = {
text = _("My Plugin"),
sorting_hint = "more_tools",
callback = function() self:onMyPluginHello() end,
}
end
function MyPlugin:onMyPluginHello()
UIManager:show(InfoMessage:new{ text = _("Hello from my plugin") })
return true -- event consumed
end
return MyPlugin
Rules that matter (and why)
- Wrap every user-visible string in
_()— KOReader is translated through gettext; unwrapped strings can never be localized. UseT = require("ffi/util").templatefor interpolation (T(_("Page %1"), n)), not..concatenation, because word order differs by language. - Events are methods named
on<EventName>; returntrueonly when you consumed the event, otherwise other widgets (including core KOReader) stop receiving it. - Never block the UI thread. Network calls, big file scans and
os.executefreeze a single-threaded e-ink device. UseNetworkMgr:runWhenOnline,UIManager:scheduleIn,Trapper:wrap(subprocess + progress) for long work. - Persist with
LuaSettings, flush deliberately. Settings files live inDataStorage:getSettingsDir(); call:flush()after changes (or inonFlushSettings) — a crash or battery death otherwise loses them. - Clean up timers (
UIManager:unschedule(fn)) and widgets you opened inonCloseWidget/onCloseDocument; leaked scheduled callbacks keep waking the device and drain battery. - E-ink aware UI: prefer
InfoMessagewithtimeout, avoid animations, keep refreshes minimal (UIManager:setDirty(widget, "ui")only on the widget that changed). - Use
logger(require("logger");logger.dbg/info/warn/err) rather thanprint; output goes to stdout in the emulator andcrash.logon device. - Guard optional dependencies (
self.ui.statistics,self.ui.document,Device:hasFrontlight()): the same plugin can load in the file manager or on devices without the feature.
Reporting back
State what you created/changed, the exact validator command and its result, and what remains unverified (e.g. "not run in the emulator"). If the validator or luacheck was unavailable, say so rather than implying a clean pass.