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 + SSEhttp://127.0.0.1:7357
WebSocketws://127.0.0.1:7358
AuthAuthorization: Bearer <token> on every HTTP request; ?access_token= also accepted on GET /events; on the WebSocket, a first frame of { "auth": "<token>" }
BodiesJSON out; only POST /engine takes a body in. Content-Type: application/json
Timests is epoch milliseconds at emit; audioStart and audioEnd are seconds into the session's audio; lagMs is signed
Absencean unmeasurable value is absent — never 0. A missing lagMs is unknown; a missing levelDb means not capturing
the error shape
{ "error": "authentication required" }
statuswhen
401no 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"
400on POST /engine, a slug that is not one or that this Mac cannot run; nothing changed
404no such route
500the 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.
response
{
"name": "Sonocles",
"version": "0.1.2",
"auth": "bearer",
"ports": {
"http": 7357,
"ws": 7358
}
}

Discovery.

response fieldtype
name*"Sonocles"
version*stringWhat the bundle was stamped with, or dev from the CLI, which has no bundle.
auth*"bearer"
ports*object
ports.http*integer
ports.wsintegerAbsent 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.

fieldtype
?access_tokenstringThe 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.

frames
→ {"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.
response
{
"state": "listening",
"listening": true,
"engine": "Parakeet EOU 120M (160 ms)",
"engineId": "fluid160",
"clients": 1,
"uptime": 41.2,
"levelDb": -19.4
}

The current state.

response fieldtype
state*"idle" | "starting" | "listening"
listening*boolean
engine*stringThe 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*integerConnections across both transports.
uptime*numberSeconds since the sockets came up.
levelDbnumberPeak input level in dBFS. Present only while capturing — absent means unmeasured, never zero.
preparingstringWhat the engine is doing before it can listen — finding, downloading or compiling models. Absent once resident.
preparingFractionnumberFraction 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.
response
{
"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 fieldtype
state*"idle" | "starting" | "listening"
listening*boolean
engine*stringThe 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*integerConnections across both transports.
uptime*numberSeconds since the sockets came up.
levelDbnumberPeak input level in dBFS. Present only while capturing — absent means unmeasured, never zero.
preparingstringWhat the engine is doing before it can listen — finding, downloading or compiling models. Absent once resident.
preparingFractionnumberFraction 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.
response
{
"state": "idle",
"listening": false,
"engine": "Parakeet 160 ms",
"clients": 1,
"uptime": 97.6
}

The state after the request — always `idle`.

response fieldtype
state*"idle" | "starting" | "listening"
listening*boolean
engine*stringThe 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*integerConnections across both transports.
uptime*numberSeconds since the sockets came up.
levelDbnumberPeak input level in dBFS. Present only while capturing — absent means unmeasured, never zero.
preparingstringWhat the engine is doing before it can listen — finding, downloading or compiling models. Absent once resident.
preparingFractionnumberFraction 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.
response
{
"engine": "fluid160",
"label": "Parakeet 160 ms",
"available": [
"fluid160",
"fluid320",
"fluid1280",
"apple"
]
}

The engine as configured.

response fieldtype
engine*"fluid160" | "fluid320" | "fluid1280" | "apple"The configured engine's slug.
label*stringThe 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.

fieldtype
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; /status says 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.
response
{
"engine": "fluid160",
"label": "Parakeet 160 ms",
"available": [
"fluid160",
"fluid320",
"fluid1280",
"apple"
]
}

The engine as configured, after the change.

response fieldtype
engine*"fluid160" | "fluid320" | "fluid1280" | "apple"The configured engine's slug.
label*stringThe 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.
response
{
"token": "3f9c0a7e5d1b2c4a6e8f0b1d3c5a7e9f2b4d6c8a0e1f3b5d7c9a2e4f6b8d0c1e"
}

The new token. The old one is dead after this response.

response fieldtype
token*string64 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.

typewhenexample
partialA 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}
finalSettled 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}
engineThe 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"}