← The specification

Contract: plugin.toml — [[recipe]]

Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page

Status: Accepted

Part of the plugin.toml contract: the ordered calls a plugin may declare, each bounded by what it reads. The requirements this page describes are listed with the rest of the contract’s, under Requirements.

[[recipe]] — ordered calls, bounded by reading

[[recipe]]
id    = "adopt-existing-library"
title = "Point it at the comics the stack already files"
why   = "…"
on    = "install"

[[recipe.input]]
name   = "admin-password"
origin = "operator"
ask    = "The password you gave Komga's admin account"
secret = true

[[recipe.input]]
name   = "sonarr-key"
origin = "credential-store"
of     = "sonarr"

[[recipe.step]]
id      = "sign-in"
call    = { method  = "POST", to = "komga", path = "/api/v1/login",
            body    = "{\"password\":\"{{admin-password}}\"}" }
expect  = { status = 200 }
capture = [{ name = "token", from = "token", origin = "stack-service" }]
retry   = { times = 5, every = "6s", until = { status = 200 } }

[[recipe.step]]
id      = "create"
when    = { step = "sign-in", status = 200 }
call    = { method  = "POST", to = "komga", path = "/api/v1/libraries",
            headers = { Authorization = "Bearer {{token}}" } }
expect  = { status = 200 }

[[recipe.step]]
id      = "series"
call    = { method  = "GET", to = "sonarr", path = "/api/v3/series",
            headers = { X-Api-Key = "{{sonarr-key}}" } }
expect  = { status = 200 }

[[recipe.pair]]
value = "admin-password"
to    = "komga"

[[recipe.pair]]
value = "sonarr-key"
to    = "sonarr"

[[recipe.pair]]
value = "token"
to    = "komga"
Field Type Required Notes
id string ✔ One word of ASCII letters, digits, - and _, unique within the plugin.
on string install or demand. Absent means install.
input[].name string ✔ One word, unique among the recipe’s inputs and captures.
input[].origin string ✔ credential-store or operator.
input[].of string for credential-store The service of the stack’s whose credential lemonfiber already holds. Never one of this plugin’s own: lemonfiber holds no key for those.
input[].ask string for operator The sentence the operator is asked, in one line.
input[].secret boolean For operator: true where the value may be a secret, so a terminal takes it without showing it. Absent means false.
step[].call.method string ✔ GET, POST, PUT, PATCH or DELETE.
step[].call.to string ✔ A service id in this stack, or a DNS name outside it. Never an address, a range or a bare host port (F8-R8). Never a substitution.
step[].call.path string ✔ A plain absolute path: one leading /, then segments and an optional query. Written out, but for a query value, which may substitute an earlier capture or an input.
step[].call.headers table Headers the call carries. A value may substitute an earlier capture or an input.
step[].call.body string The body the call carries. It may substitute an earlier capture or an input.
step[].capture[].name string ✔ One word, unique within the recipe.
step[].capture[].from string ✔ Where in the answer it is read: an expectation key (below), or header.<name>.
step[].capture[].origin string ✔ stack-service or external-response.
step[].when table { step, status } or { value, equals }. The step is skipped where it does not hold.
step[].retry table { times, every, until }, until being { status } or { value, equals }.
pair[].value string ✔ A captured value’s or an input’s name.
pair[].to string ✔ A destination as call.to writes it.
pair[].release string Why a capture from a service in this stack is carried to another destination, in one sentence the operator is shown at consent (F8-R18). Refused on a pair it frees nothing on (F8-R16).

A call needs somewhere to put what an earlier step captured, and headers and body are it. Without them a recipe can name a destination and capture a value and has no way to carry one to the other — which is every first-run flow this feature exists for, since creating an account and reading back a token is worth nothing if the token cannot then be presented. A query value is the third place, because some first-run flows are written that way: Plex is claimed with POST /:/claim?token=…. After the path’s ?, a {{name}} may stand on the value side of a name=value, and what it carries is percent-encoded when it is put there. It may stand nowhere else in the path: to, the path’s segments and the query’s names are written out, so no destination is worked out while running and no call is assembled out of something that came back (F8-R2). A header’s name is written out too: it is a fixed identifier of the protocol, not a place for a value, so a {{name}} in one is refused with a code of its own, PLUGIN-33, and the manifest answers with that code whatever else is wrong with it, listing every fault.

They are also what makes the pair check bite. Every {{name}} in a call, a query value’s included, is a flow from that value to that call’s destination, so the set of flows a recipe could produce is computable by reading it — and a flow with no [[recipe.pair]] behind it fails validation before a call is made (ADR-0022). A guard can skip a step and never add one, so the set is every substitution in every step whatever the guards say: a flow a guard usually skips is still a flow.

Where a value comes from

Every value a recipe carries has one of four origins, and the manifest writes it (F8-R3):

Origin Written on Held to
stack-service a capture the step’s to is a service in this stack
external-response a capture the step’s to is a name outside it
credential-store an input of names a service of the stack’s whose credential lemonfiber holds, never one of this plugin’s own
operator an input it is supplied when the recipe runs: asked for at a terminal, given as --input <name>=<value> at the command line, or in inputs over the web API

An operator input is asked for when it is not given. At a terminal, the command line asks for each input a recipe of that act needs and was not handed with --input, in the sentence ask writes, and takes one marked secret without showing what is typed. Without a terminal, and over the web API, a missing input is refused as PLUGIN-34, naming each one missing, so a client can ask for exactly those; an input no recipe of that act asks for is refused the same way, naming it. What an operator supplies is never part of the offer, never journalled, and never repeated back — not in a report, not in a refusal, and not in the prompt’s own echo.

An origin that disagrees with where the value comes from is refused, naming both. Written rather than derived, so that what the rehearsal and the record say about a value is what the manifest’s author said, and a manifest that says something false about it is refused rather than corrected.

A credential goes back to its own service, and nowhere else (F8-R16). A value the credential store holds for a service may be carried to that service and to no other destination: not another service of the stack’s, not one of this plugin’s own, and not a host outside. A pair carrying one anywhere but its of, and a {{name}} putting one into a call to anywhere but its of, are each refused when the manifest is read, naming the input, the service it belongs to and the destination. A plugin may present a credential lemonfiber holds, which is what a first-run flow against a bundled service needs; it may not take one somewhere, which is what lemonfiber holding it is for.

What a service answers is held to that service (F8-R16). Every value captured from the answer of a service in this stack, one of this plugin’s own included, is held to that service because it came from there, whatever it holds. It may be carried back to that service freely. Carrying it to any other destination needs a pair that releases it, where the call that read it carried no credential:

[[recipe.pair]]
value   = "library-id"
to      = "sonarr"
release = "Sonarr files new series into the library Komga just made, so it needs that library's id."

What a credential buys is held as the credential is (F8-R16). A value captured from a call that carried a credential-store value, or whose guard read one, is held to that credential’s service exactly as the credential is: back to it, and nowhere else, and no release frees it. A session a service hands back for its key is as good as the key, and a sentence the operator approves is not a reason for the key to leave. A release frees only a capture from a call that carried no credential, which is what a first-run flow between two services needs: the id of a library one of them made, not the means to act as it.

A pair or a {{name}} carrying such a capture anywhere else with no release behind it is refused when the manifest is read, naming the value, the service it was read from and the destination. A release frees a capture and nothing else. On a pair carrying a credential-store value, a value captured from a call that carried or read one, an operator’s input, an external host’s answer, or a capture back to its own service, it is refused, naming the pair, because a release the operator weighs for nothing teaches them to stop weighing releases.

A guard decides a call as surely as a substitution feeds one. A when or a retry.until that reads a held value makes the step it guards a carrier of that value. Reading the manifest refuses one on a step to anywhere the value may not go, exactly as it refuses the value in the call. Otherwise which calls a recipe makes, and where, would answer questions about a value that no call carries.

And again at the call (F8-R17). Reading the manifest is the first check, not the only one. While a recipe runs, every value it holds carries the destinations it may be sent to: a credential-store value and every value captured from a call that carried or read one its own service alone, any other capture from a service in this stack that service and each destination of a released pair the operator approved in this act, and any other value the destinations its pairs name. Before a call is sent, every value it carries is held to that call’s to, and a value bound for a host outside the stack is held to the approvals this act was given as well, as <value>@<destination>. A call carrying a value anywhere else is not sent: the step comes to withheld, the recipe ends there, and the act ends with PLUGIN-38, naming the value, the destination and the step, never what the value holds. A manifest the reading passed meets this refusal only where the reading missed something, and what it missed is then a call that was not made rather than a value somewhere it may not be.

Where a call goes

A destination is one of two things and never a third (F8-R4):

  • A service in this stack: a to naming one of the stack’s services or one of this plugin’s own. lemonfiber reaches it at 127.0.0.1 on the port it publishes there, and a call to a service that publishes none is refused when the manifest is read. Another plugin’s services are not a destination.
  • A host outside it: any other to, which is a DNS name of at least two labels. It is reached over https on port 443 and nothing else. A one-label name that names no such service is refused, because it is neither.

Where a call goes is the destination, never the path. A call’s address is built from the scheme, host and port its destination gives it, and the path is set on that address rather than written after it. So the path is a plain absolute path: it begins with exactly one /, and holds no @, \, #, whitespace or control character, no percent-encoded /, \, @, # or ., and no . or .. segment — any of which could make the text after a host read as another host, or walk somewhere the destination does not name. A manifest whose path is anything else is refused with a code of its own, PLUGIN-37, whatever else is wrong with it. A value substituted into a query value is percent-encoded, and a call whose built address names any host or port other than its destination’s is refused before it is sent.

An external name is resolved before every call, once, and the call is refused where any address it answers with is not out on the internet (F8-R9). The call is then made to the addresses that were checked and no others, so a name answering differently a moment later is not asked again.

An address is not out on the internet where it is classed, by what the address is rather than by a list of hosts, as any of these:

Family Classes
IPv4 loopback 127/8, unspecified 0/8, private 10/8 172.16/12 192.168/16, link-local 169.254/16 (where cloud metadata answers), shared 100.64/10, IETF protocol 192.0.0/24, documentation 192.0.2/24 198.51.100/24 203.0.113/24, benchmarking 198.18/15, multicast 224/4, reserved 240/4, broadcast 255.255.255.255
IPv6 loopback, unspecified, unique-local fc00::/7, link-local fe80::/10, site-local fec0::/10, multicast ff00::/8, documentation 2001:db8::/32 3fff::/20, benchmarking 2001:2::/48
IPv4 inside IPv6 mapped ::ffff:0:0/96, compatible ::/96, NAT64 64:ff9b::/96 64:ff9b:1::/48, 6to4 2002::/16, Teredo 2001::/32: the IPv4 address each carries is classed as itself

An address written as the host is read as the address it is however it is written — dotted decimal, a single number, octal, hexadecimal or a short form such as 127.1 — and classed before anything is asked, so no spelling reaches a resolver that would read it as an address. That refusal ends with a code of its own, PLUGIN-35, apart from a network that failed (ARCH-R149). A call follows no redirect: a 3xx is an answer, and a guard may branch on it.

Branching, and waiting

A step may carry when, and is skipped where it does not hold: { step, status } holds where that earlier step was made and answered that status, and { value, equals } where that earlier capture or input holds exactly that text. A guard names only what came before it. There are no jumps, so a recipe runs each step at most once, in the order written, and always ends.

A step may carry retry, and is made again until until holds or times is spent. The bounds are published and are this contract’s, not a manifest’s:

Bound At most
retry.times 10
retry.every 30 seconds
A recipe’s retries, waited out in all 5 minutes
One call, from asking to its last byte 30 seconds
One answer’s body 1 MiB

A step whose retries are spent fails the recipe, naming the call and what it last answered. A value larger than the bounds allow is refused when the manifest is read, naming the bound.

What a capture reads

capture.from is an expectation key as [[proof]] writes one: a member name, or a JSON Pointer with the selector this contract defines. A body that is not JSON has nothing to read, and the capture fails the step. header.<name> reads that header of the answer instead, the first where it arrived twice. A member whose own name begins header. is reached by its pointer. Nothing else is read: a status is branched on by when, never captured.

When a recipe runs

A recipe runs on install or on demand, as on says.

An install recipe runs during an install and an update, in the order declared, after the plugin’s proofs and the stack’s checks hold and before the record is written. A removal runs none. Where one fails, the install goes back as an install whose proofs failed goes back, and the act ends with a problem: PLUGIN-35 where a call was refused because its host stands for an address not out on the internet, PLUGIN-38 where a call was withheld because a value it carries may not go where it was going (F8-R17), and PLUGIN-36 where a step failed any other way — nothing answered, the answer was not the one it expects, a capture found nothing, or the answer was larger than a recipe reads. The problem’s detail names the recipe, the step and each call that had already landed somewhere and cannot be put back from here, and its steps carry what every step came to as data (G4-R17). Neither repeats a value: an answer that was not the one a step expects is said as which constraint did not hold where, never as what the answer held there, and what a call carried or an operator gave is named, never shown. A recipe that holds is reported on the install, every step with what it came to.

A demand recipe runs only through lemonfiber plugin run <plugin> <recipe> and the plugin-run action. Its reading lists the steps and pairs, its offer covers them, and its approvals are given to that act and to no other: approving a pair at install is not approving it for a demand recipe, and approving it for one run is not approving it for the next (ARCH-R147). A demand recipe that fails changes no install record, and ends with the same problem, saying what landed.

A pair carrying a value to a host outside the stack is approved as itself, as <value>@<destination>, and so is a pair carrying a release. The consent names a released pair as the value, the service it was read from (from), the destination and the release sentence, and an act with any released pair not approved does not proceed (F8-R18). Any other pair to a service in this stack is listed on the reading and asks for no approval, because it carries nothing out of the machine and nothing away from the service it came from.

A value a recipe captures is a declared [[secret]] and is kept, beside the settings, in a file only its owner reads. It is taken away with the plugin. An input is read when the recipe runs and is not kept.

Running one is asked for by name

[requires]
capabilities = ["service.add", "service.health.http", "recipe.run"]

A lemonfiber that does not offer recipe.run refuses such a manifest by naming that capability (F3-R21, ARCH-R90). Parsing the block and skipping it would install a plugin whose declared behaviour is wider than its actual one, and that is the tolerated unknown ARCH-R91 exists to refuse.

F8 governs what the calls may do; this says where they are written and what bounds them.