Using the Python client
This guide covers everything the lemonfiber package does. The
README has the install steps and a first call.
Two clients
Section titled “Two clients”SyncClient (built on urllib3) and AsyncClient (built on aiohttp) answer the
same calls in the same way; the asynchronous one is awaited. Both take an
Address and a Credential, and both are context managers.
from lemonfiber import Address, AsyncClient, Credential, Read, SyncClient, expect
with SyncClient(Address("http://127.0.0.1:9000"), Credential(token)) as client: status = expect(client.read(Read.STATUS), "status")
async with AsyncClient(Address("http://127.0.0.1:9000"), Credential(token)) as client: status = expect(await client.read(Read.STATUS), "status")AsyncClient takes the aiohttp.ClientSession your application already holds,
as session=, and never closes it. Home Assistant gives each integration one.
Given none, the client opens its own and closes it in aclose.
The address
Section titled “The address”A loopback address, or a host name that resolves only to loopback, needs nothing
more. Any other address is refused unless it comes with the stack’s certificate
pin, and it must be https:
address = Address( "https://nas.local:8443", pin="86b25c676b761e9a398081373fec783c2bec970baa255370838aebb5c687841e")The pin is the SHA-256 digest of the stack’s certificate, 64 hexadecimal
characters. lemonfiber ui --lan --tls prints it when it starts. It is checked
against the certificate the stack presents once the TLS handshake completes and
before any request is written. Nothing can weaken it.
A host name is resolved once, when the Address is made, and every connection
goes to an address it resolved to then. A name that later resolves somewhere else
reaches nothing new. await Address.resolved(...) does the resolving without
blocking the event loop.
The credential
Section titled “The credential”A Credential is one of:
- the per-run token
lemonfiber uiprints; - a session, from exchanging a password once with
admitoradmit_async(passname=for a household member; the operator passes none); - an integration key the operator minted.
It travels in the X-Lemonfiber-Token header and never in a URL, and its repr
shows none of it.
Integration keys
Section titled “Integration keys”A program that runs beside the stack for months holds a key rather than the
per-run token. keys lists the keys the credential may see, without their
secrets; mint mints one, its secret in that answer and no other; revoke
revokes one by its name. A Minting carries the name, the scope, the purpose and
the minter’s password, given again, which its repr leaves out:
minting = Minting(name="home", scope="read", purpose="home-assistant", password=password)made = await client.mint(minting) # a minted-key envelope; show its secret onceawait client.revoke("home") # the keys as they now standmint_claim mints a key as a claim instead, answered with a key-claim
envelope: no secret, but a key link, lemonfiber://key?..., that collects the
key once, within ten minutes. A program holding nothing else collects it with
collect or collect_async:
link = KeyLink(scanned) # KeyLinkRefusedError where it is not a key linkkey = collect(link) # name, scope, purpose, secret, pin and addressclient = SyncClient(link.address, Credential(key["secret"])) # held to the same pinThe claim is sent alone, with no credential, over the TLS the link’s fingerprint
verifies and over nothing a trust store vouches for. A claim already lapsed
raises ClaimLapsedError and nothing is sent. A claim the stack turns away,
because it collected its key already, lapsed or was never minted, raises
ClaimRefusedError with the stack’s sentence. KeyLink’s repr leaves the
claim out, and nothing here keeps the collected secret.
Device keys
Section titled “Device keys”A phone holding a key pair in its hardware registers it once as a device key, with the platform’s attestation and the password, and from then on proves a mint with a signature over a challenge the stack issued, in place of the password:
client.register_device_key(Registering("pixel", public_key_der, AndroidAttestation(chain_der), password))challenge = client.device_key_challenge("pixel")["data"]["challenge"]text = signed_text("den", "read", "mcp", challenge) # ECDSA P-256 over SHA-256, in DER, by the phoneclient.mint(Minting("den", "read", "mcp", device=DeviceProof("pixel", challenge, phone_signs(text))))A challenge is good for one mint from the same credential within a minute.
device_keys lists the device keys and revoke_device_key revokes one; the
keys it proved stay, each naming it as proved_by in the keys listing. IosAttestation carries an App Attest object
instead, made with the SHA-256 of the public key’s DER as its client data hash.
Reading
Section titled “Reading”read takes a Read, one member for each read the contract lists that answers
with one envelope, and an optional query. READS, importable from
lemonfiber.contract, says which kinds each answers with and which query
parameters it takes:
status = expect(client.read(Read.STATUS), "status")status["data"]["condition"] # "inactive" | "degraded" | "partial" | "active"A read whose path a caller fills keeps its segment in its name and value:
Read.HELD_ID is held/{id}, and READS names its segments. The query
gives each segment as one value, which is sent escaped as one path segment;
one not given, given as a list, empty, . or .. raises MisaskedError and
nothing is sent:
title = expect(client.read(Read.HELD_ID, {"id": "tt0133093"}), "title")Every answer is an envelope: api_version, kind and data. expect narrows
an envelope to the kind you expect, and raises UnexpectedKindError for any
other. Every shape the contract describes is importable from
lemonfiber.contract.
Some reads are not a single envelope. logs takes the services and forms to
narrow to and how many of the latest lines to answer with (tail), and answers
with a LogEnvelope a line, refusing a line of any other kind. bundle answers
with a BundleFile: a support bundle’s name, bytes and type.
poster and backdrop answer with a Picture, a title’s image as its bytes
and media type, read by the id the shelf lists the title under. They take
member and defaults, as the title read does:
poster = client.poster(title_id, {"member": "ada"})poster.content # the image, as deliveredposter.media_type # "image/webp"A picture is one of PICTURE_TYPES (JPEG, PNG, WebP, GIF and AVIF, never SVG)
and at most PICTURE_MOST bytes, 2 MiB. Any other answer raises
UnreadableResponseError. The client reads no further than one byte past the
limit, and none of an answer that states a longer length. A title outside the member’s limits raises
MissingError with PLAY-2, and a title with no picture of that kind raises
MissingError with PLAY-9.
A member searches for a title to ask for with Read.ASKABLE, which takes
member, term and after, and asks for one with the ask action.
askable_poster answers with a found title’s poster as a Picture, read by
the id the search lists it under, and takes member and kind:
found = expect(client.read(Read.ASKABLE, {"member": "ada", "term": "dune"}), "askable")title = found["data"]["titles"][0]poster = client.askable_poster(title["id"], {"member": "ada", "kind": title["kind"]})client.act("ask", {"name": "ada", "id": title["id"], "kind": title["kind"]})A title the member may not ask for raises MissingError with WISH-3, and one
with no poster raises MissingError with WISH-13.
A read (read, capabilities, logs, bundle, poster, backdrop, askable_poster and job) is asked twice more
where nothing answered or a gateway in front of the stack answered 502, 503 or
504, a quarter of a second later and then half a second, every attempt within
the client’s timeout.
What a stack can do
Section titled “What a stack can do”capabilities() answers with a CapabilitySet: every request the stack serves,
by its path, and what it comes to for the credential that asked:
| State | Meaning |
|---|---|
available |
You can use it |
unconfigured |
A setting on the stack has to be turned on first |
unpermitted |
This credential may not use it, for example a key’s scope |
available, unconfigured and unpermitted collect the paths in each state.
of_action, of_read and of answer for one request, or None where the stack
does not have it. A path this package does not know is kept rather than refused.
read_at says when the set was read, since it can change while you hold it.
scope says whose credential asked: operator, a read or act key, or a
household member. Read it rather than guess it from which requests are
permitted. stack is the stack’s own identifier, the one pairing material
carries, or None where the stack has nowhere to keep one: two credentials
with the same stack reach the same stack.
Acting
Section titled “Acting”act sends an action once and never retries it, because a second sending would
be a second change; release is sent once too. The action names and arguments are the command line’s own.
await client.act("restart", {"services": ["sonarr"], "dry_run": True}) # says what it would do; does nothingstarted = expect(await client.act("restart", {"services": ["sonarr"]}), "job")outcome = await client.follow(started) # Finished or Ended; a failure is raisedAn action that reaches the services answers with a job rather than its outcome.
job asks where a job stands, release stops it, and follow asks every few
seconds until it is finished or ended (every= sets how often, within= how
long before it raises StillRunningError).
What an integration key may call
Section titled “What an integration key may call”KEY_CALLABLE lists the actions an integration key may call, in the contract’s
order. Each is a KeyCallable saying whether calling it disturbs the running
system and whether it takes a rehearsal (dry_run), so the real call can be
offered after a rehearsal, and moved names the code a call is refused with when
the offer it carries back has moved since its rehearsal, or None where it takes
no offer. is_key_callable tells whether an action is on the list. A read key
may call no action, and an act key only these.
Live updates
Section titled “Live updates”from lemonfiber import Gap, Live, Stale, Unrecognised
async with client.events() as stream: async for arrival in stream: match arrival: case Live(envelope): # just carried ... case Stale(envelope, quiet_for): # held from before a gap: not what is true now ... case Gap(why, quiet_for): # the stream broke and is being reopened ... case Unrecognised(kind): # a kind this package was not generated with ...SyncClient.events() is the same stream for a plain for loop.
- The server sends a heartbeat at least every 15 seconds (
HEARTBEAT). Silence for 30 seconds (SILENCE_ALLOWED) is a broken stream, not a quiet one. - A broken stream is reopened from the last event it carried, sent as
Last-Event-ID. It waits one second, then twice as long after each failure, and raisesStreamLostErrorafter five failures in a row. - Every value held from before a gap arrives again as
Stale, and stays stale until the stream carries it again.stream.held()says where each kind stands. - A refused credential, a refused certificate or an answer in another
api_versionis raised at once, not retried. - An event longer than 16 MiB is refused with
UnreadableResponseError.
Errors
Section titled “Errors”Every failure is a LemonfiberError. When lemonfiber refuses a request, it
raises a RefusedError, chosen by the refusal code where the contract lists one
and by the HTTP status where there is none:
| Error | Meaning |
|---|---|
NotAdmittedError |
The credential is absent, wrong, expired, revoked or from another run |
DeclinedError |
lemonfiber objects to who is asking or where from; a new credential would not help |
MissingError |
lemonfiber has nothing by the name the request gave |
MisaskedError |
lemonfiber could not answer the request as it was asked |
BusyError |
Other work held the stack. The same request may be sent again once it is done |
TooManyAttemptsError |
Too many wrong attempts lately. retry_after says how many seconds, where known |
FailedError |
Nothing about the request was wrong; the machine could not answer it |
Each refusal carries the HTTP status, the code and the problem document.
REFUSAL_CODES lists what the contract says about each code, and
is_refusal_code tells a listed code from any other string. The codes are listed
in every error by code.
The other errors are exported from lemonfiber with a docstring each. The ones
you will meet most are UnreachableError (nothing answered, or what answered was
not lemonfiber), CertificateRefusedError (the stack presented a different
certificate), ConfigurationError (an address, pin or credential the client
cannot use; nothing was sent) and ApiVersionMismatchError (an answer in an
api_version this package does not speak, naming both).
Where the types come from
Section titled “Where the types come from”Everything under src/lemonfiber/_generated/ is generated from the contract
vendored under contract/, which lemonfiber builds from the Rust types that
produce its answers. A revision of lemonfiber holds it either as the directory
contract/web-api/, an index naming one file per kind, per definition and per
list, or as the single file contract/web-api.contract.json. just sync vendors
whichever the revision holds, and just generate reads either into the same
types. Nothing there is edited by hand. contract/VERSION names the
lemonfiber commit the copy came from.
uv run just sync <tag-or-commit> # vendor the contract at that revision of lemonfiberuv run just generate # rewrite src/lemonfiber/_generated/ from itEach kind’s envelope, and the shapes only it carries, is one module under
kinds/. Shapes several kinds share are under shared/. lemonfiber.contract
re-exports every shape, whichever module holds it.
This page lives in another repository Rendered from lemonfiber/sdk-python at 42623f8, 2026-10-26. Read the source of this page