Winston plugins are plain JavaScript. Each enabled session runs in a separate,
killable JavaScriptCore worker process. Its bare JS context has no I/O of
any kind — a plugin can only do what the Winston
API grants it, and each namespace exists only if its permission is declared in
the manifest and confirmed by the user in Settings → Plugins. Plugins are
disabled by default.
New to this? Start with the step-by-step Writing Winston Plugins guide (beginner-friendly, with a full worked tutorial). This page is the terse reference.
Drop a folder into ~/Library/Application Support/Winston/Plugins/ and click
Refresh in Settings → Plugins:
Plugins/
cz.example.my-plugin/ ← folder name must equal the manifest "id"
manifest.json
index.js
Plugin-writable state lives separately under PluginData/<id>/ — replacing
the plugin folder (an update) never touches its data.
{
"id": "cz.example.my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"api": "1",
"entry": "index.js",
"permissions": ["library.read"],
"description": "What it does.",
"author": "You"
}id— reverse-DNS style, lowercase letters/digits/dots/hyphens.api— targeted API major. Winston refuses to load a mismatched major.permissions— any of:
| Permission | Grants |
|---|---|
library.read |
Winston.library.list/get |
library.write |
Winston.library.update (fills empty fields only) |
metadata.fetch |
Winston.metadata.fetch through Winston's online catalog service |
ui.toast |
Winston.ui.toast |
Winston.storage and console need no permission (both are scoped to the
plugin itself). Unknown permission strings make the whole manifest invalid.
Consent is keyed by the SHA-256 digest of the complete plugin bundle as well as
its id and version. Changing any bundled file requires confirmation again.
Attach entry points to the pre-created exports object:
exports.activate = async () => {
// runs once when the plugin is enabled (and at every launch while enabled)
};
exports.deactivate = () => {
// best-effort, called when the plugin is disabled or Winston quits
};Each synchronous JavaScript turn has ~10 s to return. A worker that hangs or
exceeds its resource limits is terminated, quarantined, and persistently
disabled. Every API call returns a Promise; rejections carry
a stable code (invalid-argument, permission-denied, unavailable,
timeout) plus a human-readable message.
Winston.host // { appVersion, apiVersion, locale }
Winston.capabilities.has(name) // e.g. has("library.update") — feature-detect, don't version-sniff
console.log/info/warn/error/debug(...) // → Settings → Plugins log + OSLog category "plugins"
await Winston.storage.set("key", value) // value: anything JSON-serializable
await Winston.storage.get("key") // → value or null
await Winston.storage.remove("key")
await Winston.library.list() // → { items: [Book], nextCursor: string|null }
await Winston.library.list({ // page size defaults to 50, maximum 100
text: "čapek", // optional display-title/author filter
cursor: previousPage.nextCursor, // omit/null for the first page
limit: 100
})
await Winston.library.get(uuid) // → Book or null
await Winston.library.update(uuid, { // fills empty fields only; returns
publisher: "Argo", // { applied: ["publisher", ...] }
title: "...", author: "...", year: "...", language: "...", translator: "...", isbn: "...",
series: "...", seriesIndex: "...", description: "...", tags: ["..."]
})
await Winston.metadata.fetch({ isbn: "9788025712345" }) // or
await Winston.metadata.fetch({ title: "...", author: "..." })
// → { title, authors, publisher, year, description, subjects,
// ratingsAverage, ratingsCount, ratingsSource } or null.
// Rejects with code "unavailable" while online metadata is off in Settings.
await Winston.ui.toast("message") // styles: "info" (default),
await Winston.ui.toast("message", "success") // "success", "error"A Book is a snapshot (changing it does nothing — use library.update):
uuid, title, author, displayTitle, displayAuthor, publisher, year, language, translator,
isbn, series, seriesIndex, tags, description, rating, communityRating,
readingStatus, format, fileSizeBytes, dateAdded, workUUID, workTitle,
editionCount, formats, physicalCopy, shelfLocation
- No file paths in or out; books are addressed by
uuidonly. - No network access —
metadata.fetchgoes through Winston's own catalog service, which the user can switch off globally. library.listis a bounded snapshot page, never a fetch of the full live SwiftData catalog. FollownextCursoronly while you need more results.library.updatecan never overwrite a non-empty field, so user edits win.- Disabling or quarantining a plugin invalidates its session lease before shutdown; an already-started host request cannot commit a later write.
- Uncaught exceptions are logged and counted; repeated faults quarantine the
plugin. Note that a rejection you don't
catchinside anasyncfunction is silent — wrap youractivatebody intry/catchandconsole.error.
Example plugins live in docs/example-plugin/: cz.example.library-report
(a minimal read-only report) and cz.example.metadata-filler (the
guide's worked tutorial).