whiskers-service
What Whiskers' service does with a request, written once and generic over the ports it runs on.
Service<C, R> takes a [Request] (a method, a path, a body) and returns a [Response] (a status,
a content kind, bytes): plain values, no HTTP library. A native server, a Worker's fetch handler and a test
each translate to and from them, so the routes behave the same wherever they run.
Which routes a service has
C is the adapter: a type that implements the capability traits of whiskers-ports (HasJudge, HasJournal, ...) for the
ports it holds. R is the routes the service has been given, a type-level list built by serve:
// The native binary has every capability, so it can be given every route, and `complete` does.
let service = Service::complete(native);
// A Worker that holds only the hubs is given only the hub routes. It implements three traits, no stand-ins.
let service = Service::new(worker).serve(MemorySync).serve(HouseholdSync).serve(ChatSync);
A route's type states what it needs in its Route<C> impl, so serve does not compile for an adapter without those
capabilities (the trait bound Worker: HasJudge is not satisfied); the compile_fail example on Service is that
promise as a test. A route that was not given is a 404, as an unknown path is, and is as absent as one: no handler runs and no
"not configured" answer exists. (The refusals every route shares, a method other than POST, a body that cannot be read or is
over MAX_BODY, come first and are the same whichever routes the service has.) The routes, and what each needs:
| Route | Route type | Needs | What the service decides |
|---|---|---|---|
POST /check | Check | HasJudge, HasClock | asks the judge; no decision is Unavailable, never Allow |
POST /rerank | Rerank | HasJudge, HasClock | the same, one question for a shortlist |
POST /embed | Embed | HasEmbedder, HasClock | refuses a batch the embedder should not see, then asks it |
POST /memory/sync | MemorySync | HasMemory | merges the device's copy into the hub and returns the converged copy; a copy the hub cannot keep is a 500 on every sync route, so a device never mistakes it for "yours was older" |
POST /household/sync | HouseholdSync | HasHousehold | the same |
POST /chat/sync | ChatSync | HasChat | the same |
POST /journal/push, /journal/pull | Journal | HasJournal | appends a device's lines where it left off; pages the one log |
POST /picture/missing, /put, /get | Pictures | HasPictures | the shelf, over validated names |
POST /v1/messages | Messages | HasThinker, HasHousehold, HasAllowance, HasClock | vets the request, asks the thinking window, forwards, charges the answer |
POST /speak, /speak/v2 | Speak | HasVoice, HasHousehold, HasAllowance, HasClock | checks the line, reserves the day's characters, speaks, settles; /speak answers the MP3, /speak/v2 the timed JSON below |
POST /usage | Usage | HasHousehold, HasAllowance, HasVoice, HasClock | what the parents' screen shows about both allowances |
Why this shape (a type-level list of routes rather than a service per route group) is in whiskers-ports/README.md.
The wire is unchanged from before this crate existed except for two changes, each decided by the operator on
2026-10-04: /speak/v2 is new, and /chat/sync answers a copy it could not keep with a 500. Every other route: same
routes, same JSON, same statuses, same status text. The service decides the policy (what the thinking window allows, the order the speech checks run in,
what a push that will not be taken is answered with); the adapters only keep state and call out.
The adapter's part of a request: read at most MAX_BODY + 1 bytes of the body (a longer one is refused as a
400 here), say whether it could be read, and turn the Response into its server's. whiskersd is the
native adapter; the tests run on the in-memory one from whiskers-conformance.
Migration (2026-10-05): Service::new(adapter) used to need a whole Backend and served every route; it now serves none, and
Service::complete(adapter) is the old behaviour for a Backend. A route an adapter lacks the capability for used to answer
with the failure of a stand-in port (/check a 200 Unavailable, /usage a 500); it is now a 404.
It builds for wasm32-unknown-unknown (no files, threads, sockets or clock), so a Worker links it.
The timed voice: POST /speak/v2
Request: {"text": "..."}, exactly as /speak, and the same checks in the same order (bad body 400, empty 400, voice not
configured 503, line over 500 characters 413, the day's allowance spent 429), the same 502 when the voice fails, and the
same reserve-then-settle of the day's characters. Reply, application/json:
{"audio_base64": "//sBAg==", "text": "hi", "spans_ms": [[0, 100], [100, 200]]}
audio_base64: the MP3, standard base64 with padding.text: the line that was spoken, exactly as asked.spans_ms: one[start, end]per character oftext(Unicode scalar values, so an emoji is one pair), milliseconds from the start of the audio,end >= start, neither running backwards. This iswhiskers_ports::LineTiming.
JSON with the audio inline stays the wire: one line's audio is tens of kilobytes, and one JSON body is what the app, a Worker and a test all read without a multipart parser. Nothing in it is ElevenLabs'; the adapter converts.