Writing a lemonfiber plugin
The plugin you copy. One manifest, its recorded responses, and a workflow running the commands a plugin is judged by, so the first thing you see is the bar you will be held to, and it is already green.
This page is for plugin authors. The README is for the people who
run your plugin: it has a {placeholder} for everything that is yours to fill
in.
What a plugin is here
Section titled “What a plugin is here”Data. A plugin is a plugin.toml and a directory of recorded responses, and
there is nowhere in the format to put code — contributed code is never executed,
and no setting, sandbox or grant can make it so. What you are writing is a
description: which image runs, what it can do, what must be true before it
installs, and what it adds to the diagnostics lemonfiber already runs.
lemonfiber reads that description and does the work, and it knows nothing about
your plugin that this file does not say.
That is what makes a stranger’s plugin reviewable at all. There is nothing in it to judge except declarations — and every declaration here is either checked by the harness or demonstrated by a recording.
What lemonfiber installs today
Section titled “What lemonfiber installs today”A manifest names what it needs from lemonfiber in [requires].capabilities, and
lemonfiber refuses to install one that asks for something it does not offer.
lemonfiber 0.16.0, the release targets.toml names, offers a plugin
doctor.contribute and nothing else. A plugin that adds a service, as this one
does, also asks for service.add and service.health.http, so lemonfiber
validates and proves it and then refuses to install it, naming both.
CI reports that as a notice, not a failure: just ci is green, and the reader
step prints 0.16.0 would not install this. Say so at the top of your README
until lemonfiber offers what your plugin asks for.
Start here
Section titled “Start here”gh repo create my-plugin --template lemonfiber/plugin-templatecd my-pluginjust ci # green before you have changed anythingjust ci turns this clone’s git hooks on as its first step, which is what makes
.githooks/commit-msg say before a push what commitlint, dco, spec-check
and attribution would say after one.
Then, roughly in this order:
[plugin]—id,name,description,upstream,license. Thatlicenseis the upstream service’s, and has nothing to do with this repository’s.[[service]]—image,digest,tag,port,bind, andconfig_pathif the image keeps its state somewhere other than/config. Pin the digest: the tag is the readable name for it and is never resolved.health— start the image and time it, then write down what you measured rather than a number that looks safe.provides— a core name out of lemonfiber’s published vocabulary for each contracted thing your service does, and namespaced names of your own (my-plugin:something) for everything else.[[claim]]— one for every core name, binding every probe that vocabulary declares for it.[[proof]]andfixtures/— record real responses off the image at the digest you pinned, and delete the Kavita ones.[[contribution]]— the checks the doctor should run for your service, each with a remedy. Namespaced with your plugin’s id.targets.toml— the lemonfiber release you validated and proved against, or, while what your plugin needs is on lemonfiber’smainand in no release, that commit in full ascommit = "<40 hex>".README.md— fill in every{placeholder}, delete what does not apply to your service, and check every sentence against yourplugin.toml. It is what somebody deciding whether to install your plugin reads.
just proofs after each recording tells you whether what you wrote down
matches what you recorded. When something does not hold, lemonfiber names where
in the manifest it is and what was expected, and reports every violation in one
pass rather than the first.
What a manifest may contain is not written down here. It is
plugin-manifest.schema.json, generated from the types lemonfiber reads a
manifest with and kept at
contract/plugin-manifest.schema.json
in lemonfiber’s repository — the one document your editor, this repository’s CI
and the catalogue all hold a manifest to. Point your editor at it and you get
completion and inline validation with nothing installed.
The harness is one artefact rather than two. .github/reader/ lives here and
is copied byte for byte into every plugin repository and into the reviewed
catalogue, lemonfiber-plugins,
whose harness job fails when its copy differs from this one. So you meet the
catalogue’s bar in your own repository, before you ever open a pull request to
the catalogue.
Why it describes something real
Section titled “Why it describes something real”The template has to validate and prove unmodified
(authoring requirements).
A template whose proofs are invented would pass that bar and teach an author to
invent proofs, so this one describes Kavita — a comics, manga and ebook reader — and
every recording under fixtures/ came off that image at the digest
plugin.toml names.
Nothing here is a recommendation to install Kavita. It is here so that the proofs are proofs.
What is in it
Section titled “What is in it”| File | What it is |
|---|---|
README.md |
What the people who run your plugin read. Placeholders until you fill them in. |
docs/development.md |
This page. |
plugin.toml |
The whole of what lemonfiber will act on. Identity, the service, the capability it claims, the proofs, the checks it contributes. |
fixtures/*.json |
Recorded responses, so everything provable is provable with no live instance anywhere |
targets.toml |
Which lemonfiber release, or which commit of its main, this is validated against. A CI fact, not a manifest one. |
proofs.json |
The outcome of every proof, which lemonfiber’s release process re-reads. Generated; CI fails if it is stale. |
.github/reader/ |
The CI harness. It fetches the lemonfiber release targets.toml names and checks it against its published digest, or builds the commit it names, and asks it lemonfiber plugin claims and lemonfiber plugin provenance. It describes no part of the format and decides no verdict. Not part of what an operator installs. |
The shape, in the order the manifest carries it
Section titled “The shape, in the order the manifest carries it”The service. Which image runs, at which digest, on which port, and whether that port is for administration on this machine or for the household. Not how the container is assembled — lemonfiber writes that, which is what makes what can this plugin reach answerable from the format rather than from the instance.
What it can do. provides carries two kinds of name and no third. A core
name comes from lemonfiber’s published capability vocabulary and means the
contracted thing that vocabulary defines; something asking for it can be answered
by this plugin, which is what makes a plugin useful rather than merely readable.
A namespaced name — kavita:opds — is this plugin’s own and is inert until
something asks for it.
The claim. A core capability is demonstrated, not asserted. The vocabulary
says what must be shown; the [[claim]] says where to ask it on this service and
what the answer must be, with a recording of that answer. Leave a probe unbound,
or bind one with a weaker expectation than the probe permits, and the manifest is
refused naming the probe.
A probe asserts what the service does, never what the operator’s library happens to hold. The catalogue probe here says “the answer reads as an array” and nothing about how long it is — a probe demanding a non-empty one would refuse to install on the machine of somebody who has not copied their books over yet. A contributed check is the opposite and may say exactly that, because a check reports on the stack rather than gating an install.
The proofs. What must hold before this is installed. Every one asserts a
body, never only a status — Docker publishes a port by putting a proxy in front
of it, and that proxy accepts a connection before knowing whether anything inside
is listening. One of the two proofs here exists only to record that: asked for a
path it does not implement, this service answers 200 and its single-page app.
The contributions. A row in a register lemonfiber already runs. The doctor
already runs checks independently, bounds each one, keeps unverified distinct
from pass, and carries a remedy on anything that does not pass; a contributed
check is another row in it, attributed to this plugin wherever it appears. No
code is contributed and none can be — there is nothing for contributed code to
be.
The check here and the guarded probe ask almost the same question, and the
difference is the point: a probe is asked once, at install; a check is asked
every time the doctor runs. An upgrade, a reconfiguration or a reverse proxy in
front of the service are all ways for it to stop refusing what it used to refuse.
Running the checks
Section titled “Running the checks”just ci # all of them, in CI's orderjust lists the rest. Each is also a command:
python3 .github/reader/reader.py reader # the build targets.toml names, and what it says herepython3 .github/reader/reader.py manifest # every refusal it makes of the manifestpython3 .github/reader/reader.py proofs # every assertion on its recording; writes proofs.jsonpython3 .github/reader/reader.py image # the digests, their tags, what vouches for themThe first run fetches the release into .lemonfiber/, which git ignores, and
checks it against the digest published with it. A commit is built there instead,
once, with the toolchain and lockfile it pins, so the first run needs git and
rustup and takes minutes rather than seconds. Each command then asks it:
lemonfiber plugin claims . answers the first three and lemonfiber plugin provenance . the last, and running either yourself gives the same answer in
full.
reader.py proofs writes proofs.json every time, and CI then compares the file
against the committed one, so a run that says everything passed can still be
refused by git diff --exit-code proofs.json.
A capability in [requires] that the release does not offer a plugin is the one
refusal reported without failing: it says the plugin would not be installed on
that release, which naming the release in targets.toml does not claim.
just ci is every gate CI runs over the contents of this repository. The jobs it
leaves out are named in the justfile beside the recipe, with what covers each.
Recording a fixture
Section titled “Recording a fixture”A recording is a moment, and it is reviewed like the manifest: a fixture nobody
can read is a place for something to hide. Record with the service running at the
digest plugin.toml names:
docker run --rm -p 5057:5000 \ docker.io/jvmilazz0/kavita@sha256:b9c671586db2a6a688da3cb4b45f1319cca33b01e6e760c8bf3c19d60101bdf2curl -si http://127.0.0.1:5057/api/healthThe host port is deliberately not 5000: macOS answers that one itself, with an
AirPlay receiver that returns 403 to everything, and a recording made against
it would be a recording of the wrong server. Then write down what state the
service was in when it answered:
{ "recorded_from": "docker.io/jvmilazz0/kavita@sha256:…", "note": "What this instance was, and why this response is worth keeping", "request": { "method": "GET", "path": "/api/health" }, "response": { "status": 200, "headers": { "content-type": "text/plain" }, "json": null, "body_starts_with": "Ok" }}Moving the image pin means re-recording every fixture against the new image in the same change. A recording that describes a different image is worse than no recording.
The recipe this does not declare, and what one looks like
Section titled “The recipe this does not declare, and what one looks like”[[recipe]] is in the format and is checked. It is not declared here, and the
reason is worth copying rather than the block: a manifest declaring one asks for
recipe.run by name, so a lemonfiber that cannot run recipes refuses it.
lemonfiber does not offer recipe.run, and a template an author copies should
not be one that installs nowhere.
What one looks like:
[requires]capabilities = ["service.add", "service.health.http", "doctor.contribute", "recipe.run"]
[[secret]]id = "token"of = "kavita"why = "Kavita answers a library being created only to a signed-in administrator."
[[recipe]]id = "adopt-the-library-the-stack-already-fills"title = "Point it at the comics on disk instead of asking the operator to"why = "A reader with no library configured is a reader nobody can read anything in."
[[recipe.step]]id = "sign-in"call = { method = "POST", to = "kavita", path = "/api/account/login" }expect = { status = 200 }capture = [{ name = "token", from = "json.token", origin = "stack-service" }]
[[recipe.step]]id = "create"expect = { status = 200 }
[recipe.step.call]method = "POST"to = "kavita"path = "/api/library/create"headers = { Authorization = "Bearer {{token}}" }
[[recipe.pair]]value = "token"to = "kavita"Three rules do the work, and the validator holds all three without running
anything. Every destination is a name — a service in this stack or a DNS name
outside it — never an address, a range or a bare host port, because a declared
address is a declared address wherever it points and this machine sits beside a
router’s administration page. Every substitution refers to something an earlier
step captured. And every value that could reach a destination has a
[[recipe.pair]] behind it: a captured token going anywhere no pair permits is a
validation failure found before a call is made, not after two have landed.
The [[secret]] above is not one of the three, and it is not optional either:
every value a step captures has to be declared as a secret of the same id, and
lemonfiber refuses a manifest whose recipe takes out a value no [[secret]]
names. What installing a plugin commits the operator to holding is something they
read in the manifest, not something they find out afterwards.
The verbosity is the point. Three captures and two destinations are six possible flows, most of which a plugin will never want — and the ones it does not declare are the ones nobody has to wonder about afterwards.
A bundled part
Section titled “A bundled part”bundled-part/ is the variant a first-party part of the stack lemonfiber ships
starts from: one service, no adapter, no Rust and no Python. It describes
SABnzbd as the stack carries it, with the starting file and the recordings the
stack held, and the
bundle contract
says how the catalogue pins such a part and how lemonfiber carries it.
A part’s repository is this repository with bundled-part/ laid over its
root, and registry/convert.py in
lemonfiber-plugins writes it
from the part’s description in the media stack:
- Taken from
bundled-part/:targets.toml,justfile,.gitignore,.github/dependabot.ymland its workflows. - The part’s own, in place of the variant’s:
plugin.toml,proofs.json,config/<id>/andrecordings/<id>/, and aREADME.mdand anAGENTS.mdthat say for the part what the variant’s say for SABnzbd. - Kept from the root:
.editorconfig,.githooks/,.markdownlint.jsonc,typos.toml,LICENSE,CLAUDE.md,.github/CODEOWNERS,.github/labeler.yml, and thelabelsandstaleworkflows. - Dropped:
fixtures/, the root’sproofs.json,.github/reader/,ruff.toml,docs/,built-part/and thebuilt-partworkflow, and thebundleworkflow, which only a part that builds an image runs.
A workflow bundled-part/ holds replaces the root’s of the same name.
What changes from the plugin above:
- The id is the service’s.
[plugin].idis the id of the one service, so the container,./config/<id>, the settings and the journal keep the names the stack gave them. - It declares
replacesnaming its own id. That is how a manifest takes the id, the port and the hostname the bundle reserves for the part (ARCH-R222); any other service taking one is refused. - Its starting files sit in
config/<id>/, at most 64 files and 1 MiB, with no symbolic link, and lemonfiber writes each into the service’s configuration directory. - Its recordings sit in
recordings/<id>/, the paths the stack held them at, so the bundle’s copy of the tree keeps every path. - It declares
reachesandasks_foron its service, as every first-party service does, andapiwhere lemonfiber reaches it through an in-process adapter while the bundle assembles it. - It names no profile and no form beyond its own
forms. The bundle places the part in a profile, andformsnames exactly the forms whose closure holds it. - Its CI is spec’s.
plugin.ymlcalls spec’sfirst-party-pluginworkflow, which takes this repository’s reader at a pinned commit and runsmanifest,proofsandimageas one job, skipped where nothing it reads changed. CodeQL analyses the workflows alone, when a change touches.github/, and the Sonar gate is told no code changed. - It is never tagged. Its image is the upstream’s, so the catalogue pins a revision and the digest of its tree, and a person moves the pin.
The template’s own plugin workflow judges bundled-part/ with that same
workflow, so the variant holds as the repositories made from it do.
A built part
Section titled “A built part”built-part/ is the variant for a part whose image lemonfiber builds from its
upstream’s, because the upstream’s image cannot run under the posture its
manifest declares as it is published. It describes Door, Caddy standing in
front of Jellyfin, whose binary carries a file capability that a container
holding no NET_BIND_SERVICE refuses to execute. The Dockerfile builds from
the digest the stack pins and takes that capability off.
registry/convert.py --variant built-part writes a part’s repository from it as
it writes one from bundled-part/, laid over the root the same way, with these
differences:
- The part’s own as well:
Dockerfile, written as aFROMof the image, tag and digest the stack pins, to which the part adds what its posture lacks; andposture/prepare, where the part needs one. - Its image is built and published here. The
imageworkflow calls spec’sfirst-party-image: on a pull request it builds the image and runs it under the posture its manifest declares, failing with the container’s logs where it does not come up healthy (ARCH-R250). On main it publishesmain-<12 hex>, and on a tag the train cuts it publishes that tag, at the versionplugin.tomldeclares (OPS-R90). Each published image is run under its posture again, and one rolling pull request through the release App sets thedigestandtagof every service running it (OPS-R91). posture/prepareis run with a copy of the configuration directory before the container starts, for what lemonfiber writes there at install. Door’s writes the certificate its TLS port serves.- It is tagged. A train tag’s pin is on
release/image-pin, and thebundleworkflow asks the catalogue to pin the revision that pull request merges. A commit’s pin is onrelease/image-commit-pin, which moves the manifest and never the bundle. - Until the first pin merges, the manifest names the upstream’s image, and the service whose id is the plugin’s says how the image built here is run.
- Dependabot also watches the
Dockerfile, so the upstream’s digest moves by a reviewed pull request.
The repository needs a release environment admitting main and v* tags, the
release App installed, and packages: write on its image in GHCR.
The template’s plugin workflow judges built-part/ as it judges
bundled-part/, and its built-part workflow builds the variant’s image and
runs it under its posture on every pull request; nothing is published from the
template.
What is deliberately absent
Section titled “What is deliberately absent”[[secret]] and [[override]], because capturing a value and changing a bundled
setting are recipe verbs, and lemonfiber runs no recipe.
A dashboard widget, because a widget reads a service’s API with a credential, and
a credential needs a recipe. A dashboard panel or a command of its own, because
there is no point to declare either at. A minimum lemonfiber version, because a
manifest carries none — [requires].capabilities says what this needs, and an
unmet requirement is refused by naming the capability rather than a number that
cannot say which one.
Licence
Section titled “Licence”The plugin data in this repository is under the Hippocratic License 3.0 (HL3-CORE) — see LICENSE. Kavita itself is GPL-3.0-only and is not distributed here; this repository names an image, it does not contain one.
license in plugin.toml is a fact about the upstream service and says nothing
about the repository that declares it. Copying this template does not copy a
licence decision: the copy is yours to license.
This page lives in another repository Rendered from lemonfiber/plugin-template at d12166c, 2026-10-27. Read the source of this page