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:

RouteRoute typeNeedsWhat the service decides
POST /checkCheckHasJudge, HasClockasks the judge; no decision is Unavailable, never Allow
POST /rerankRerankHasJudge, HasClockthe same, one question for a shortlist
POST /embedEmbedHasEmbedder, HasClockrefuses a batch the embedder should not see, then asks it
POST /memory/syncMemorySyncHasMemorymerges 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/syncHouseholdSyncHasHouseholdthe same
POST /chat/syncChatSyncHasChatthe same
POST /journal/push, /journal/pullJournalHasJournalappends a device's lines where it left off; pages the one log
POST /picture/missing, /put, /getPicturesHasPicturesthe shelf, over validated names
POST /v1/messagesMessagesHasThinker, HasHousehold, HasAllowance, HasClockvets the request, asks the thinking window, forwards, charges the answer
POST /speak, /speak/v2SpeakHasVoice, HasHousehold, HasAllowance, HasClockchecks the line, reserves the day's characters, speaks, settles; /speak answers the MP3, /speak/v2 the timed JSON below
POST /usageUsageHasHousehold, HasAllowance, HasVoice, HasClockwhat 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 of text (Unicode scalar values, so an emoji is one pair), milliseconds from the start of the audio, end >= start, neither running backwards. This is whiskers_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.