The plugin exposes a rich public API that companion plugins can use to monitor and control Syncthing, without duplicating internal logic.
After the plugin has initialised, you can access the API in two ways:
-- Via require (recommended)
local Syncthing = require("st_api_public").api
-- Via global variable
local Syncthing = _G.KOSyncthingPlusAPIBoth reference the same table.
Syncthing.version --> string, e.g. "1.1.0"Use this to gate features when writing a companion plugin that may run against different installed versions of the plugin.
The API is platform-agnostic. On Android the plugin runs in remote mode:
it talks to a separately installed Syncthing app (e.g. Syncthing-Fork or
BasicSync) over that app's REST API. Every call — apiCall and the status /
control / info helpers — is routed to the app transparently, and version
is unchanged, so companion plugins need no Android-specific code; they use
_G.KOSyncthingPlusAPI exactly as on Kindle/Kobo.
One behavioural note for remote mode: control.start() and control.stop() are
no-ops, because the Syncthing app owns the daemon. Use status.isRunning() to
check whether the app is reachable rather than starting it yourself.
Syncthing.status.isRunning() --> boolean
Syncthing.status.getConflicts() --> table of conflict paths (strings)
Syncthing.status.getFolderHealth() --> aggregate health table (see below)
Syncthing.status.getStatusHeader() --> one-line summary string
Syncthing.status.getDeviceId() --> string (this device's ID)
Syncthing.status.isPeriodicSyncEnabled() --> boolean
Syncthing.status.getPeriodicSyncInterval() --> number (minutes, 1–1440)
Syncthing.status.getNextPeriodicSyncAt() --> os.time() epoch or nilReturns nil when Syncthing is not running or the API is unreachable.
Otherwise returns a table:
| Field | Type | Description |
|---|---|---|
syncing |
number |
Count of folders currently transferring data |
errors |
number |
Count of folders with errors |
need_bytes |
number |
Total bytes outstanding across all non-paused folders |
paused |
number |
Count of paused folders |
total |
number |
Total configured folder count |
watch_errors |
table |
List of folder labels that have filesystem-watcher errors |
folder_states |
table |
Per-folder state map, keyed by folder ID (see below) |
Each entry in folder_states[folder_id]:
| Field | Type | Description |
|---|---|---|
state |
string |
Syncthing folder state: "idle", "syncing", "scanning", "error", "paused", "unknown" |
need_bytes |
number |
Bytes outstanding for this folder |
errors |
boolean |
True if the folder has reported errors |
paused |
boolean |
True if the folder is paused |
Syncthing.control.start(callback)
Syncthing.control.stop(callback)
Syncthing.control.quickSync(touchmenu_instance)
Syncthing.control.toggle(callback)
Syncthing.control.pauseAllFolders()
Syncthing.control.resumeAllFolders()
Syncthing.control.setPeriodicSyncEnabled(enabled)
Syncthing.control.setPeriodicSyncInterval(minutes)
Syncthing.control.runPeriodicSyncNow()Start or stop the Syncthing daemon.
callback(function, optional) — called with no arguments after the operation completes (or is rejected, e.g. because a start is already in progress). Wrapped inpcall; errors in the callback do not affect the plugin.- No return value (fire-and-forget; the callback carries the result signal).
If no binary is installed, start shows the first-run download dialog
instead of attempting to launch.
Convenience wrapper: calls stop if running, start if stopped.
Same callback contract as start/stop.
Run a full Quick Sync: start → scan all folders → wait for idle → show transfer summary → stop.
on_complete(function, optional) — called with no arguments after the entire flow finishes (whether by success, error, or timeout). Wrapped inpcall; errors in the callback do not affect the plugin. Passnilif you only need theSyncthingSyncCompletedevent.- Returns nothing; progress is communicated via toast notifications and
the
SyncthingSyncCompletedKOReader event.
-- With completion callback
Syncthing.control.quickSync(function()
myWidget:refresh()
end)
-- Fire and forget
Syncthing.control.quickSync()If Syncthing is already running when
quickSyncis called, it triggers a rescan only (no start/stop). Theon_completecallback still fires.
Toggle the paused state of all configured folders via the Syncthing REST API. Return nothing; errors are logged internally.
Enable or disable automatic periodic Quick Sync.
enabled(boolean) –trueto turn on,falseto turn off.- Returns
trueon success,niland an error message on failure. - Saves the setting persistently.
Set a new interval for periodic sync.
minutes(number) – interval in minutes (1–1440). Must be a whole number (no decimals).- If periodic sync is enabled, the timer restarts with the new interval.
- Returns
trueon success,niland an error message on failure.
Run one periodic sync cycle immediately, without waiting for the next scheduled time.
- Requires
periodic_sync_enabled == true. - Returns
trueon success,niland an error message on failure. - Does not alter the schedule – the next execution stays on the regular interval.
Resolve conflicts without showing any UI – useful for companion plugins that want to implement their own conflict handling or auto-resolve everything.
local result = Syncthing.control.resolveAllConflicts("keep_local")
-- result = { kept_local = 3, kept_remote = 0, merged = 0, skipped = 0, failed = 0 }All strategies return a consistent table with all five fields:
| Field | Type | Description |
|---|---|---|
kept_local |
number |
Conflicts where the local version was kept |
kept_remote |
number |
Conflicts where the remote version was applied |
merged |
number |
Conflicts merged (only for "auto_merge") |
skipped |
number |
Files intentionally left untouched |
failed |
number |
Files that could not be resolved due to I/O errors |
local stats = Syncthing.control.resolveAllConflicts("auto_merge")
-- stats = {
-- merged = 2,
-- kept_local = 1,
-- kept_remote = 1,
-- skipped = 0,
-- failed = 0
-- }local ok, err = Syncthing.control.resolveConflictByPath(
"/path/to/file.sync-conflict-...",
"use_remote"
)| Strategy | Effect |
|---|---|
"keep_local" |
Delete all conflict copies. Returns kept_local and failed. |
"use_remote" |
Replace originals with conflict copies. Returns kept_remote, skipped, and failed. |
"auto_merge" |
Keep higher reading progress for metadata; skip non-metadata. Returns all five fields. |
Note:
resolveConflictByPathaccepts only"keep_local"and"use_remote". Passing"auto_merge"returns an"Unknown strategy"error — to auto-merge by reading progress useresolveAllConflicts("auto_merge"), which keeps the higher progress and skips non-metadata files automatically.
After any conflict resolution:
- Caches are invalidated automatically
- Listeners are notified
local patterns, err = Syncthing.info.getFolderIgnore("folder-id")
for _, p in ipairs(patterns) do
print(p)
-- e.g. "(?d)Thumbs.db", "*.tmp", "// comment"
endReturns a table of strings representing the current ignore patterns.
May be empty. Returns nil and an error message on failure.
local newPatterns = {
"(?d)Thumbs.db",
"*.tmp",
"// This is a comment",
}
local ok, err = Syncthing.control.setFolderIgnore(
"folder-id",
newPatterns
)Replaces the entire ignore list for the given folder.
Returns true on success, or nil and an error message on failure.
Note: Changing ignore patterns does not trigger an automatic rescan. Use
Syncthing.control.quickSync()or let a periodic sync handle it.
The following directives are supported by Syncthing:
| Pattern | Description |
|---|---|
(?d) |
Allow deletion if it prevents directory removal |
(?i) |
Case-insensitive matching |
! |
Negate the pattern |
* |
Single-level wildcard |
** |
Multi-level wildcard |
// |
Comment (must be at the start of a line) |
Refer to the Syncthing documentation for a complete description.
Note: Changing ignore patterns does not trigger an automatic rescan. Use
Syncthing.control.quickSync()or let a periodic sync handle it.
local folders = Syncthing.info.getFolders()
for _, f in ipairs(folders) do
print(
f.id,
f.label,
f.path,
f.state,
f.needBytes,
f.paused
)
endlocal devices = Syncthing.info.getDevices()
for _, d in ipairs(devices) do
print(
d.id,
d.name,
d.connected,
d.address,
d.paused
)
endlocal pendingDevices = Syncthing.info.getPendingDevices()
local pendingFolders = Syncthing.info.getPendingFolders()local port = Syncthing.info.getGUIPort()
local profile = Syncthing.info.getResourceProfile()
local netMode = Syncthing.info.getNetworkAccess()local isLegacy = Syncthing.info.isLegacyMode()
local version = Syncthing.info.getLegacyVersion()Returns true when legacy mode is currently enabled, false otherwise.
Use this to gate calls that rely on REST endpoints that were introduced after Syncthing v1.2.2:
if not Syncthing.info.isLegacyMode()
or Syncthing.info.getLegacyVersion() ~= "v1.2.2" then
-- Safe to use POST /rest/config/devices, PATCH endpoints, etc.
Syncthing.apiCall("config/devices", "POST", body)
endReturns the active legacy version tag (e.g. "v1.27.12" or "v1.2.2")
when legacy mode is enabled, or nil when standard mode is active.
| Version | API compatibility |
|---|---|
nil (standard) |
Full modern API |
"v1.27.12" |
Full modern API |
"v1.2.2" |
/rest/system/config GET/PUT only; no PATCH, no /rest/config/* |
local details = Syncthing.info.getConflictsDetailed()
for _, c in ipairs(details) do
print(c.path, c.original_path, c.is_metadata, c.local_progress, c.remote_progress)
endReturns a table where each entry describes one sync conflict with all the information needed to build a custom resolution dialog.
| Field | Type | Description |
|---|---|---|
path |
string |
Full path to the conflict file |
original_path |
string |
Path to the original file (may be same if missing) |
is_metadata |
bool |
True if it's a KOReader metadata sidecar |
has_progress |
bool |
True if percent_finished was found |
local_progress |
number or nil |
Reading progress of the local copy (0–100) |
remote_progress |
number or nil |
Reading progress of the remote copy (0–100) |
local_mtime |
number or nil |
Unix timestamp of the local file |
remote_mtime |
number or nil |
Unix timestamp of the conflict copy |
The function internally calls findConflicts() and enriches the result
with metadata detection, reading-progress extraction, and file timestamps.
Companion plugins can use this to build their own conflict resolution UI
without duplicating the parsing logic.
Companion plugins can issue any Syncthing REST API request without ever seeing the API key. The secret stays inside the plugin.
local config = Syncthing.apiCall("config/folders")Syncthing.apiCall(
"config/devices",
"POST",
'{"deviceID":"...","name":"My Device"}'
)
Syncthing.apiCall(
"config/folders/abc123",
"PATCH",
'{"paused":true}'
)apiCall(endpoint, method, body)| Parameter | Description |
|---|---|
endpoint |
Example: "system/status" or "db/status?folder=id" |
method |
"GET" (default), "POST", "PATCH", "DELETE" |
body |
Optional JSON string |
Returns the raw parsed response from Syncthing:
- On success — a Lua table (parsed JSON). Write endpoints that return
an empty body (e.g.
POST db/scan,PATCH config/folders/{id}) returntrueinstead of a table. - On failure (network error, timeout, non-2xx status) —
nil.
This is a thin transport wrapper. It does not use the SafeClient result
envelope ({ok, error, data}) — use the status and info APIs when
you need structured error handling.
When isLegacyMode() is true and getLegacyVersion() is "v1.2.2",
the following endpoint categories are not available on the running daemon:
| Category | Example | Available in v1.2.2? |
|---|---|---|
| New config API | GET config/folders |
❌ returns 404 |
| PATCH endpoints | PATCH config/folders/{id} |
❌ returns 404 |
| POST to config | POST config/devices |
❌ returns 404 |
| Old config API | GET system/config |
✅ |
| Old config write | PUT system/config |
✅ |
| Database | POST db/scan, GET db/status |
✅ |
| System status | GET system/status |
✅ |
The core plugin already handles these differences internally (read-modify-write
via PUT system/config replaces all PATCH calls). Companion plugins should
gate their own apiCall usage with isLegacyMode() / getLegacyVersion().
Syncthing.onStatusChange(function(event, data)
if event == "process_started" then
-- daemon started
elseif event == "process_stopped" then
-- daemon stopped
elseif event == "conflicts_changed" then
-- data is a table of conflict paths
-- or nil to force refresh
end
end)
-- Later, remove the listener
Syncthing.offStatusChange(callback)All callbacks are wrapped in pcall – a broken listener will never
crash KOSyncthing+.
The plugin also broadcasts standard KOReader events that any widget
can listen to via UIManager:registerListener.
Fired after a Quick Sync completes.
Note (v1.1.1+): The plugin itself subscribes to this event via
onSyncthingSyncCompletedto run the opt-in auto-merge pass. Companion plugin handlers receive the event after the internal handler has already run, so any conflicts the auto-merge resolved will no longer appear inSyncthing.status.getConflicts()by the time your handler fires. If you need to act on all conflicts including those that are auto-merged, subscribe toSyncthingConflictDetectedinstead, which fires earlier (during the sync), before the completion event and before auto-merge runs.
{
sent = 1234,
received = 5678,
upToDate = false
}Fired when new conflicts are found.
{
"/path/to/conflict1",
"/path/to/conflict2"
}Syncthing.util.formatBytes(1234567)
--> "1.2 MB"
Syncthing.util.isValidDeviceID("...")
--> true / false
Syncthing.util.formatTime("2024-...")
--> formatted date/timeCompanion plugins can exclude their own files' conflict copies from the
conflict scanner so the Conflicts badge stays accurate. A plugin registers a
list of filename globs against the ORIGINAL basenames; the registry matches a
conflict copy by de-mangling its .sync-conflict-… / ~sync-conflict-… name
first, so a companion registers plain names (state.lua, *.sdr) and never
encodes the conflict form itself.
-- A single glob, or a list of globs. The call REPLACES this plugin's set.
_G.KOSyncthingPlusAPI.IgnoreRegistry:register(
"my_plugin_id",
{ "*.my-sidecar", "state.lua", "metadata.*.lua" }
)Patterns match the original basename; the registry handles the
.sync-conflict-… mangling, so do not add it yourself. Re-registering replaces
this plugin's previous list (it does not append).
_G.KOSyncthingPlusAPI.IgnoreRegistry:unregister(
"my_plugin_id"
)local registered = _G.KOSyncthingPlusAPI.IgnoreRegistry:isRegistered("my_plugin_id")
-- returns booleanlocal patterns = _G.KOSyncthingPlusAPI.IgnoreRegistry:getAll()
-- returns { plugin_id = { "pattern", ... }, ... }
for id, list in pairs(patterns) do
print(id, table.concat(list, ", "))
endlocal hit = _G.KOSyncthingPlusAPI.IgnoreRegistry:matchesConflictBasename(
"state.sync-conflict-20260101-120000-ABCDEFG.lua")
-- boolean: true if the de-mangled original matches a registered globBoth conflict scanners (Kindle/daemon and Android) call this, so a registered pattern is honoured identically on every platform.
local v = _G.KOSyncthingPlusAPI.IgnoreRegistry.getApiVersion()
-- returns the same version string as KOSyncthingPlusAPI.version-
All control methods use the same guards as the menu:
- Double-start prevention
- Wi-Fi checks
- Shared safety logic
-
The proxied
apiCallnever exposes the Syncthing API key -
Listener callbacks are wrapped in
pcall- A broken companion plugin cannot crash Syncthing
-
All API calls are synchronous and safe to invoke from:
- Widget callbacks
- Timers
- Any normal KOReader Lua callback
KOReader uses single-threaded LuaJIT execution.