API reference
Generated from docs/openapi.yaml, version 0.1.3. CI binds the real service and validates it against the same file.
Conventions
| HTTP + SSE | http://127.0.0.1:7357 |
| WebSocket | ws://127.0.0.1:7358 |
| Auth | Authorization: Bearer <token> on every HTTP request; ?access_token= also accepted on GET /events; on the WebSocket, a first frame of { "auth": "<token>" } |
| Bodies | JSON out; only POST /engine takes a body in. Content-Type: application/json |
| Times | ts is epoch milliseconds at emit; audioStart and audioEnd are seconds into the session's audio; lagMs is signed |
| Absence | an unmeasurable value is absent — never 0. A missing lagMs is unknown; a missing levelDb means not capturing |
{ "error": "authentication required" }| status | when |
|---|---|
401 | no token, or the wrong one — on every route, before routing, so an unknown path without the token is 401 and not 404. Carries WWW-Authenticate: Bearer realm="Sonocles" |
400 | on POST /engine, a slug that is not one or that this Mac cannot run; nothing changed |
404 | no such route |
500 | the token file could not be written on rotate; the old token stays valid |
Discovery
The one call a client makes first.
GET/
Who this is, how to authenticate, where the sockets are.
The first call a client makes. auth is always bearer; it is here so a client written against a future scheme can tell which one it has reached rather than guessing from a 401.
- 401
- No token, or the wrong one. Carries
WWW-Authenticate: Bearer realm="Sonocles". The check runs before routing, so an unknown path without the token is 401, not 404.
{ "name": "Sonocles", "version": "0.1.2", "auth": "bearer", "ports": { "http": 7357, "ws": 7358 }}Discovery.
| response field | type | |
|---|---|---|
name* | "Sonocles" | |
version* | string | What the bundle was stamped with, or dev from the CLI, which has no bundle. |
auth* | "bearer" | |
ports* | object | |
ports.http* | integer | |
ports.ws | integer | Absent when the WebSocket transport was not started. |
The stream
The same frames on both transports, both live at once. A consumer can attach before capture starts and stay attached across stops.
GET/events
The stream — one Frame per data line, for as long as the connection is open.
EventSource cannot set a header, so the token is accepted as ?access_token= here (RFC 6750 §2.3). Frames arrive only while listening; the connection opens either way, so a consumer can attach before start.
| field | type | |
|---|---|---|
?access_token | string | The bearer token, for clients that cannot set the header. |
- 401
- No token, or the wrong one. Carries
WWW-Authenticate: Bearer realm="Sonocles". The check runs before routing, so an unknown path without the token is 401, not 404.
An open text/event-stream; each data line is a Frame.
WSws://127.0.0.1:7358
The same frames as GET /events, after one auth frame.
The first frame must authenticate; until it does, every other frame is answered 401 and no event is delivered. A browser WebSocket cannot set an Authorization header, so the frame is the only way. An id in the frame, of any JSON type, is echoed in the answer. After the auth answer, every message is a Frame or the engine event — the same bytes GET /events delivers as data: lines. Frames have type; the engine event has event; the auth answer has status. The Frame and EngineEvent schemas, one per message — the three kinds are under Frames below.
→ {"auth":"<token>"}← {"id":null,"status":200,"body":{"authenticated":true}}← {"type":"partial","text":"the menu bar","ts":1788569799791,"seq":3,"audioStart":40.28,"audioEnd":40.8,"lagMs":200}Control
Start and stop capture, and choose the engine, without touching the popover. Start, stop and status answer the same Status object; the engine routes answer the Engine one.
GET/status
What the sidecar is doing — idle, starting, or listening — and whether it hears anything.
state has three values rather than a boolean because POST /start returns before capture is up: models load, macOS may ask for the microphone. Poll until listening. levelDb is present only while capturing; absent means unmeasured, never zero.
- 401
- No token, or the wrong one. Carries
WWW-Authenticate: Bearer realm="Sonocles". The check runs before routing, so an unknown path without the token is 401, not 404.
{ "state": "listening", "listening": true, "engine": "Parakeet EOU 120M (160 ms)", "engineId": "fluid160", "clients": 1, "uptime": 41.2, "levelDb": -19.4}The current state.
| response field | type | |
|---|---|---|
state* | "idle" | "starting" | "listening" | |
listening* | boolean | |
engine* | string | The engine's own name once running, else the configured choice's label. For a display; engineId is for a control. |
engineId* | "fluid160" | "fluid320" | "fluid1280" | "apple" | The configured engine's slug — what GET /engine answers and POST /engine takes — so a client polling /status needs no second call. |
clients* | integer | Connections across both transports. |
uptime* | number | Seconds since the sockets came up. |
levelDb | number | Peak input level in dBFS. Present only while capturing — absent means unmeasured, never zero. |
preparing | string | What the engine is doing before it can listen — finding, downloading or compiling models. Absent once resident. |
preparingFraction | number | Fraction of the download complete, where one is knowable. Absent while compiling, which has no measurable progress and is not given an invented one. |
POST/start
Begin capture. Answers at once with the state, which is `starting` until the models are resident and the microphone is granted.
Idempotent: a second start while starting or listening changes nothing. On the first run the models download (~220 MB, once) and compile for the Neural Engine, and preparing says which.
- 401
- No token, or the wrong one. Carries
WWW-Authenticate: Bearer realm="Sonocles". The check runs before routing, so an unknown path without the token is 401, not 404.
{ "state": "starting", "listening": false, "engine": "Parakeet 160 ms", "clients": 1, "uptime": 3.1, "preparing": "Downloading model — 42% (file 2 of 5)", "preparingFraction": 0.42}The state after the request — `starting` on the way up, `listening` once there.
| response field | type | |
|---|---|---|
state* | "idle" | "starting" | "listening" | |
listening* | boolean | |
engine* | string | The engine's own name once running, else the configured choice's label. For a display; engineId is for a control. |
engineId* | "fluid160" | "fluid320" | "fluid1280" | "apple" | The configured engine's slug — what GET /engine answers and POST /engine takes — so a client polling /status needs no second call. |
clients* | integer | Connections across both transports. |
uptime* | number | Seconds since the sockets came up. |
levelDb | number | Peak input level in dBFS. Present only while capturing — absent means unmeasured, never zero. |
preparing | string | What the engine is doing before it can listen — finding, downloading or compiling models. Absent once resident. |
preparingFraction | number | Fraction of the download complete, where one is knowable. Absent while compiling, which has no measurable progress and is not given an invented one. |
POST/stop
End capture. The sockets stay up; `start` brings it back.
Idempotent: stop on an idle sidecar answers idle and does nothing.
- 401
- No token, or the wrong one. Carries
WWW-Authenticate: Bearer realm="Sonocles". The check runs before routing, so an unknown path without the token is 401, not 404.
{ "state": "idle", "listening": false, "engine": "Parakeet 160 ms", "clients": 1, "uptime": 97.6}The state after the request — always `idle`.
| response field | type | |
|---|---|---|
state* | "idle" | "starting" | "listening" | |
listening* | boolean | |
engine* | string | The engine's own name once running, else the configured choice's label. For a display; engineId is for a control. |
engineId* | "fluid160" | "fluid320" | "fluid1280" | "apple" | The configured engine's slug — what GET /engine answers and POST /engine takes — so a client polling /status needs no second call. |
clients* | integer | Connections across both transports. |
uptime* | number | Seconds since the sockets came up. |
levelDb | number | Peak input level in dBFS. Present only while capturing — absent means unmeasured, never zero. |
preparing | string | What the engine is doing before it can listen — finding, downloading or compiling models. Absent once resident. |
preparingFraction | number | Fraction of the download complete, where one is knowable. Absent while compiling, which has no measurable progress and is not given an invented one. |
GET/engine
Which engine is configured, and which ones this Mac can run.
engine is the slug — what sonocles-cli --engine takes — and label is for humans. available lists the slugs this machine can run: apple needs macOS 26, and a client on 15 should learn that here rather than from a 400.
- 401
- No token, or the wrong one. Carries
WWW-Authenticate: Bearer realm="Sonocles". The check runs before routing, so an unknown path without the token is 401, not 404.
{ "engine": "fluid160", "label": "Parakeet 160 ms", "available": [ "fluid160", "fluid320", "fluid1280", "apple" ]}The engine as configured.
| response field | type | |
|---|---|---|
engine* | "fluid160" | "fluid320" | "fluid1280" | "apple" | The configured engine's slug. |
label* | string | The configured engine's name, for humans. |
available* | "fluid160" | "fluid320" | "fluid1280" | "apple"[] | The slugs this Mac can run, in the order the popover shows them. apple is absent below macOS 26. |
POST/engine
Switch engine. If capture is running, it stops and starts again on the new one.
The one route that takes a body. A half-swapped pipeline would report numbers belonging to neither engine, so a switch while listening is a stop and a start — starting is visible on /status in between, and a consumer on /events sees a gap in frames. The engine event then goes out on both transports. The same engine again changes nothing and announces nothing.
| field | type | |
|---|---|---|
engine* | "fluid160" | "fluid320" | "fluid1280" | "apple" | The slug. Anything else is 400. |
- 400
- Not one of the four slugs, or one this Mac cannot run. Nothing changed;
/statussays what it said before. - 401
- No token, or the wrong one. Carries
WWW-Authenticate: Bearer realm="Sonocles". The check runs before routing, so an unknown path without the token is 401, not 404.
{ "engine": "fluid160", "label": "Parakeet 160 ms", "available": [ "fluid160", "fluid320", "fluid1280", "apple" ]}The engine as configured, after the change.
| response field | type | |
|---|---|---|
engine* | "fluid160" | "fluid320" | "fluid1280" | "apple" | The configured engine's slug. |
label* | string | The configured engine's name, for humans. |
available* | "fluid160" | "fluid320" | "fluid1280" | "apple"[] | The slugs this Mac can run, in the order the popover shows them. apple is absent below macOS 26. |
The token
Rotation is the whole management surface. There is nothing to create, list or delete.
POST/token/rotate
Write a new token and refuse the old one from the next request, on both transports.
Takes no body. Every other paired client has to read the file again — that is what rotation is for. A WebSocket that authenticated with the old token stays up but must auth again.
- 401
- No token, or the wrong one. Carries
WWW-Authenticate: Bearer realm="Sonocles". The check runs before routing, so an unknown path without the token is 401, not 404. - 500
- The token file could not be written. The old token stays valid, so a half-rotation cannot lock everyone out.
{ "token": "3f9c0a7e5d1b2c4a6e8f0b1d3c5a7e9f2b4d6c8a0e1f3b5d7c9a2e4f6b8d0c1e"}The new token. The old one is dead after this response.
| response field | type | |
|---|---|---|
token* | string | 64 hex characters. Also written to the token file. |
Frames
Delivered on GET /events as data: lines, and on the WebSocket as messages after the auth answer — the same bytes on both. type says which frame; the one message that is not a frame has event instead.
| type | when | example |
|---|---|---|
partial | A volatile hypothesis, revised as you speak; about one word each, roughly every 200 ms. What should drive a scroll or fire a cue. | {"type":"partial","text":"the menu bar","ts":1788569799791,"seq":3,"audioStart":40.28,"audioEnd":40.8,"lagMs":200} |
final | Settled text for one utterance, 1.6–3.1 s after it ended. The next partial starts empty. For the transcript, never for timing. | {"type":"final","text":"the menu bar app is listening","ts":1788569802140,"seq":9,"audioStart":40.28,"audioEnd":42.12,"lagMs":1810} |
engine | The engine changed, from the popover or over POST /engine. Has `event`, not `type`, so a frames-only consumer skips it. If capture was running it stopped and is starting again on the new engine. | {"event":"engine","engine":"fluid320","label":"Parakeet 320 ms"} |