Skip to content

The envelope

Every machine-readable answer arrives in the same wrapper. There is no second shape for a second surface: a script piping a command’s output and a client fetching the equivalent endpoint receive the same document.

{ "api_version": 1, "kind": "walkthrough", "data": {} }

The release this page is built for does not carry what this part is read from. The next version's page has it.

api_version, kind and data are always there; host and job only where they apply. kind comes before data in importance rather than in byte order: a client reads it first and then reads data as that kind, never as an open value. The kinds and their payloads are every payload kind.

--json on any command. The answer goes to standard output; a failure is an envelope too, of kind error, and it goes to the error stream instead — so a script reading standard output never has to tell an answer from an apology.

A log stream is the one shape that is not a single document. It emits one envelope per line, because a stream has no last element to close a document with.

The plain-language footnote a human report prints under itself is never appended to a machine-readable answer. It is prose for a person, and adding it would corrupt the one thing that answer exists to be.

lemonfiber ui serves the same envelope over local HTTP. It takes a loopback socket — whichever port is free, unless --port names one — and prints the whole address together with a token minted for that run. --lan offers it to your network instead, and is refused until a password has been set with --set-password. Nothing is installed, and nothing keeps running afterwards. The connection is plain text unless --tls asks for it encrypted, with a certificate lemonfiber made and keeps, and it says which as it starts. --tls needs --port, because it is for a paired phone, and a phone keeps the address it was given.

Thirty-eight endpoints answer a question and close. Each one is a command a person could have typed, dispatched through the same entry point the command line uses, so the two surfaces cannot say different things about the same stack.

Endpoint What it answers
GET /api/status What the whole stack is doing
GET /api/services The same reading, narrowed to the forms named in ?form=
GET /api/forms Every form the stack declares, or what the ones named in ?form= would come to
GET /api/checks What the diagnostic checks found, or the one group named in ?only=
GET /api/storage The checks about the disk
GET /api/logs The scrollback, one envelope per line; takes ?form=, ?service= and ?tail=, and ?follow answers with a job name and carries the lines on the stream
GET /api/requests What the household has asked for, narrowed to ?member= — and always to themselves when a member is asking
GET /api/held What one member can actually watch, as the media server answers it for them. ?member= is required; ?most= defaults to 100 and is refused, not trimmed, above 500
GET /api/trace Where one item got to; ?term= names it as you would say it, ?season= narrows to one
GET /api/stuck The items whose downloads have stopped, each named the way ?term= asks for one
GET /api/config Every setting, credentials withheld, or the one named in ?key=
GET /api/quality The preset in force, what each preset means, and what it costs
GET /api/version The versions in play: lemonfiber, the stack it operates, and the container engine
GET /api/explain Every word this product explains, or what the one named in ?word= means
GET /api/front-door The one address to hand somebody who lives here, and why nothing else listed is it
GET /api/provenance Where each service comes from: its licence, its project, and the version this stack pins
GET /api/catalogue What each service is for, what its loss costs, and what became of any the stack dropped
GET /api/outbound Everything that leaves this machine, what each carries, and what switching it off stops
GET /api/stored What lemonfiber keeps on this machine, where each thing is, and why
GET /api/backups Which backups are here to restore from, by name
GET /api/clients Which app to watch on, for each kind of device somebody in the house has
GET /api/credentials Every credential the stack holds, what each is for and where it stands — never a value
GET /api/alerts What the operator is told about, and what changing that comes to
GET /api/space Where the disk went, what is on it, and what could be got back
GET /api/bandwidth What the line carries, what the stack is held to, and whether it is keeping to it
GET /api/hosting What this machine keeps running when no terminal is open
GET /api/uninstall What a removal would take; the removal being read is named rather than defaulted
GET /api/migration What is already on this machine, before anything is proposed
GET /api/history What has already been changed on this machine, and when
GET /api/update ?what=self or ?what=stack is required: where this copy stands and how it was installed, or what each service would move to; ?to= names a version
GET /api/news What is new: releases, the household’s requests and the problems the checks found, each newest first
GET /api/plugins Which plugins are installed, as lemonfiber plugin installed answers
GET /api/wiring What the stack wires to what, and how each link was settled
GET /api/settings Every setting the stack’s services read, as lemonfiber settings --json answers; only the operator is answered, and a secret’s value never
GET /api/playing What the media server is playing now, each session naming the member watching and what they are watching; a member is answered with their own sessions only
GET /api/watching What one member was part-way through and how far, most recent first. ?member= is required; ?most= defaults to 24 and is refused, not trimmed, above 500
GET /api/askable A member’s search for titles the house does not hold yet, under their own limits; ?term= is required, ?after= turns the page and ?member= names whose
GET /api/bundle/{name} The support bundle itself, handed over rather than described

Two of them are not shaped like the rest. GET /api/front-door takes no parameters, because the question takes none: which one address the household is given is worked out from what the stack runs rather than asked for, and a parameter here would be a way for one surface to be told a different door from another. GET /api/bundle/{name} is the one read that does not answer with an envelope — it answers with the bundle itself, because a browser has no path on the host to be told and handing the file over is the only form --out can take on a screen. The name is resolved beneath the bundles directory rather than followed, so one carrying a path, or climbing out of that directory, is refused by name.

Query parameters are what the commands themselves take, and only what reads. A read looks and does not touch: narrowing a diagnosis is a parameter here, while accepting a warning or running the checks that disturb a running stack changes something and belongs where changes are asked for.

POST /api/actions/<name> is where changes are asked for. An action that only reads and writes lemonfiber’s own files is answered with its outcome, because it has already finished by the time it could be answered. One that reaches the container engine runs for minutes, so it is answered 202 with a job envelope naming the work, and the work runs somewhere the connection cannot reach — a browser tab closed mid-repair takes nothing with it. GET /api/jobs/{job} asks what became of that work: 202 with the same job envelope while it is still going, and the envelope it came to once it has finished. DELETE /api/jobs/{job} ends it and answers with where it then stood. A name this run never handed out is 404 with ASK-7, rather than reported as unfinished.

Setup has its own small surface, because it is a conversation rather than a command: GET /api/setup says where a run stands, and POST /api/setup/answer, /next, /back, /apply and /recover move it.

GET /api/events is a server-sent event stream, and it is described in full under what a client keeps below. Each event’s name is the envelope’s kind: dashboard for the state the stack is in, plugins and wiring for what is installed and what the stack wires to what, each said when a client joins and again when it changes, alert when an alert starts and again when it resolves, and step, start and log for the lines a walkthrough, a start or a followed log writes as they are written. A line said by work an action handed to a job names that job in the envelope’s job. A member’s stream carries none of these: it carries their own household row, their held shelf and what they are playing, each the answer that read gives them.

However a read is refused, it answers with the error envelope — the same document --json writes — and its code says which refusal it was. The READ codes are a read’s own. The status says which of four sorts of refusal it is:

Refused because Status
What the request named is not one of the things there are 404
The request could not be answered as it was asked 400
Other work holds what it needs; the same request is answered after it 409
Nothing about the request was wrong; this machine could not answer it 500

The line between the first two is what the request was for. A word this product does not explain is absent — the word is the whole of what /api/explain was asked for, and there is no entry — while a parameter left out, given twice, or given a value the surface does not offer is a request that could never have been answered as it stands.

500 is reserved rather than incidental. A client told the machine failed will try again, and a client told that about a word with no entry will try forever. Which of the four applies is decided where the refusal is raised, and it reaches a client as the status alone: the envelope carries no field for it.

Every request meets the same guard, applied over the whole route tree rather than written into each handler — so an endpoint added later is guarded by having been added.

Header Rule
X-Lemonfiber-Token The run’s token, on every request. The stream too: a connection held open is a request that has not finished, not one never made
Host Must name the address the server is listening on. A request without one is refused — a Host check is what still holds when DNS rebinding has defeated an origin check
Origin Where a browser sent one, it must name the same address. Its absence is allowed, because it is a browser’s word about itself and a client that is not a browser has none to give

Nothing under /api/ is answered in prose. Every refusal is the error envelope, and its code is what a client decides on, because one status covers refusals with different remedies. Every refusal the guard makes is 403: ADMIT-4 where the request carried no token or session this run admits, and ADMIT-5 where it said it came from somewhere this server is not. A browser offered 401 would ask for credentials it has no way to supply. The one thing the guard does not hide is which paths exist: a path under /api/ that no route declares is answered 404 with ASK-9 by the app’s fallback, while one that exists answers 403, so an unadmitted caller can tell the two apart. The endpoints are named in the published contract, so what that reveals is already public. A path that exists, asked with a method it does not answer, is 405 with ASK-10.

The token printed at start answers one question — is this the machine’s own operator — and is no use to somebody holding a phone. POST /api/session is the one route that answers a request carrying no token: it takes {"password": "…"}, and optionally a name, and exchanges them once for a session, which travels in the same X-Lemonfiber-Token header. The Host and Origin rules still apply to it. A wrong password answers 401 with ADMIT-8, the one status that says trying again could help. Too many of them answer 429 with ADMIT-9, and a Retry-After header saying how long to wait.

The machine’s own password is tried first and admits the operator. Otherwise the name and password are checked against the media server’s household, and a match admits that member; the admission envelope then carries their id, and its absence means the operator. A member’s session is checked with the media server again on every request.

A member may ask for three things, each narrowed to themselves whatever the request named: what the household asked for, GET /api/held for their own shelf, and GET /api/playing for their own sessions. Everything else — every other read and every action — is refused 403 with ADMIT-6: This is not something this account may ask for. Where the media server cannot be asked, the answer is 403 with ADMIT-7, saying nobody was identified and nothing about the account changed, so the thing to fix is the media server rather than the account.

The binary carries a web app, embedded exactly as the stack beside it is: a submodule at assets/web, pinned to an exact revision and never to a branch. What is pinned there is the app’s built tree rather than its source — cutting a version tag on lemonfiber-web builds it and pushes the output to a tag of its own — so what a binary carries is addressable as a revision and can be checked by its contents rather than fetched while the binary is being built.

The pair is held together at compile time. The app declares the wire version it speaks, the build compares it against the one this binary serves, and a build whose two halves disagree stops and names both numbers. An incompatible pairing therefore cannot ship, because it cannot be built — the same argument the embedded stack rests on, for the other embedded thing.

An absent app is not a mismatch. A build told to carry none says so rather than answering with an empty document, the endpoints below /api answer as usual, and lemonfiber ui --assets <PATH> serves a directory instead, which is what somebody working on the app itself needs.

GET /api/capabilities answers with what this stack can do, as the credential that asked may do it, so a client on a phone reads it rather than deducing it from a version. The payload is kind: "capabilities", and its data maps each request, named by the path it is served at, to one of three states:

State Means
available This stack can do it, and this credential may
unconfigured The stack has it, and a setting switched off has to be on
unpermitted The stack has it, and this credential may not ask for it

A request the stack does not have is absent from the map rather than listed, so a stack older than a client’s request says so by not naming it. The set is generated from the lists the surface routes by, and each state is decided by the same rule every request meets.

What a client keeps that the schema cannot say

Section titled “What a client keeps that the schema cannot say”

The generated artefact fixes what the wire looks like. These are the rules about what it means, and every client implements and tests them.

Rule Why it is a rule rather than a suggestion
The per-run token travels in the X-Lemonfiber-Token header, never in a URL URLs reach logs, history and referrers; a credential that lands in any of those has leaked
The base address must resolve to loopback, and a loopback address is not refused for being named rather than numeric Refusing the word localhost outright is the wrong trade — it is what an operator types and what a printed address may contain. Refusing a name that resolves off loopback is the protection that matters
There is no default port The binary chooses a free one unless told otherwise and prints the whole address; a client is configured with that address rather than assembling one
The stream emits a heartbeat at least every 15 seconds, and a client treats twice that in silence as broken Without it, “nothing has changed” and “the connection died twenty minutes ago” look identical
Every event carries an id; a resuming client sends the last one it saw as Last-Event-ID Resumption decides what is retransmitted, not what is current
Anything held from before a gap is stale until replaced A reconnected stream that presents pre-gap values as current is lying about what it missed
An api_version a client cannot speak is refused plainly, naming both versions Rendering a partial view of fields whose meaning has quietly changed is worse than stopping

The artefact is contract/web-api/ in the lemonfiber repository, one file per kind and per shared definition, built from the Rust types that actually serialise the reply. It is never hand-written, and regenerating it must produce no diff — a serialised shape that changes without the artefact changing with it fails the build rather than reaching a client.

It is published with every release. An SDK does not fetch it while it builds; it vendors a copy pinned to an exact revision recorded beside it, so a contract change arrives as a diff somebody reads rather than as something that happens to a build nobody was watching. Generating from a version an SDK does not implement is refused, naming both versions, and writes nothing: types that compile and lie are worse than a build that stops.

Every payload kind lists what data holds for each kind. The two numbers in play — the package version and api_version — do different jobs, which is two version numbers. To consume it from code, start with the TypeScript SDK, the PHP SDK or the Python SDK.