1# whiskers-service 2 3What Whiskers' service does with a request, written once and generic over the ports it runs on. 4 5`Service<C, R>` takes a [`Request`] (a method, a path, a body) and returns a [`Response`] (a status, 6a content kind, bytes): plain values, no HTTP library. A native server, a Worker's `fetch` handler and a test 7each translate to and from them, so the routes behave the same wherever they run. 8 9## Which routes a service has 10 11`C` is the adapter: a type that implements the capability traits of `whiskers-ports` (`HasJudge`, `HasJournal`, ...) for the 12ports it holds. `R` is the routes the service has been given, a type-level list built by `serve`: 13 14```rust 15// The native binary has every capability, so it can be given every route, and `complete` does. 16let service = Service::complete(native); 17// A Worker that holds only the hubs is given only the hub routes. It implements three traits, no stand-ins. 18let service = Service::new(worker).serve(MemorySync).serve(HouseholdSync).serve(ChatSync); 19``` 20 21A route's type states what it needs in its `Route<C>` impl, so `serve` does not compile for an adapter without those 22capabilities (`the trait bound `Worker: HasJudge` is not satisfied`); the `compile_fail` example on `Service` is that 23promise 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 24"not configured" answer exists. (The refusals every route shares, a method other than POST, a body that cannot be read or is 25over `MAX_BODY`, come first and are the same whichever routes the service has.) The routes, and what each needs: 26 27| Route | Route type | Needs | What the service decides | 28|---|---|---|---| 29| `POST /check` | `Check` | `HasJudge`, `HasClock` | asks the judge; no decision is `Unavailable`, never `Allow` | 30| `POST /rerank` | `Rerank` | `HasJudge`, `HasClock` | the same, one question for a shortlist | 31| `POST /icon` | `PickIcon` | `HasEmbedder`, `HasJudge`, `HasIcons`, `HasClock` | the admitted pixel icon for the words of a fact: the closest twenty by meaning, then one Jev Choice with a "none fits" way out (`{"Icon":"cat"}`, `"NoneFits"` or `{"Unavailable":..}`) | 32| `POST /embed` | `Embed` | `HasEmbedder`, `HasClock` | refuses a batch the embedder should not see, then asks it | 33| `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" | 34| `POST /household/sync` | `HouseholdSync` | `HasHousehold` | the same | 35| `POST /chat/sync` | `ChatSync` | `HasChat` | the same | 36| `POST /journal/push`, `/journal/pull` | `Journal` | `HasJournal` | appends a device's lines where it left off; pages the one log | 37| `POST /picture/missing`, `/put`, `/get` | `Pictures` | `HasPictures` | the shelf, over validated names | 38| `POST /v1/messages` | `Messages` | `HasThinker`, `HasHousehold`, `HasAllowance`, `HasClock` | vets the request, asks the thinking window, forwards, charges the answer | 39| `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 | 40| `POST /usage` | `Usage` | `HasHousehold`, `HasAllowance`, `HasVoice`, `HasClock` | what the parents' screen shows about both allowances | 41 42Why this shape (a type-level list of routes rather than a service per route group) is in `whiskers-ports/README.md`. 43 44The wire is unchanged from before this crate existed except for two changes, each decided by the operator on 452026-10-04: `/speak/v2` is new, and `/chat/sync` answers a copy it could not keep with a 500. Every other route: same 46routes, same JSON, same statuses, same status text. The service decides the policy (what the thinking window allows, the order the speech checks run in, 47what a push that will not be taken is answered with); the adapters only keep state and call out. 48 49The adapter's part of a request: read at most `MAX_BODY + 1` bytes of the body (a longer one is refused as a 50400 here), say whether it could be read, and turn the `Response` into its server's. `whiskersd` is the 51native adapter; the tests run on the in-memory one from `whiskers-conformance`. 52 53Migration (2026-10-05): `Service::new(adapter)` used to need a whole `Backend` and served every route; it now serves none, and 54`Service::complete(adapter)` is the old behaviour for a `Backend`. A route an adapter lacks the capability for used to answer 55with the failure of a stand-in port (`/check` a 200 `Unavailable`, `/usage` a 500); it is now a 404. 56 57It builds for `wasm32-unknown-unknown` (no files, threads, sockets or clock), so a Worker links it. 58 59## The timed voice: `POST /speak/v2` 60 61Request: `{"text": "..."}`, exactly as `/speak`, and the same checks in the same order (bad body 400, empty 400, voice not 62configured 503, line over 500 characters 413, the day's allowance spent 429), the same 502 when the voice fails, and the 63same reserve-then-settle of the day's characters. Reply, `application/json`: 64 65```json 66{"audio_base64": "//sBAg==", "text": "hi", "spans_ms": [[0, 100], [100, 200]]} 67``` 68 69- `audio_base64`: the MP3, standard base64 with padding. 70- `text`: the line that was spoken, exactly as asked. 71- `spans_ms`: one `[start, end]` per character of `text` (Unicode scalar values, so an emoji is one pair), milliseconds 72 from the start of the audio, `end >= start`, neither running backwards. This is `whiskers_ports::LineTiming`. 73 74JSON with the audio inline stays the wire: one line's audio is tens of kilobytes, and one JSON body is what the app, a 75Worker and a test all read without a multipart parser. Nothing in it is ElevenLabs'; the adapter converts.