The protocol

Two transports, one frame. What every field carries, which one to trust for timing, and why a missing number is never zero.

Every consumer gets the same JSON, whichever transport it listens on.

Transports

endpoint direction
SSE GET http://127.0.0.1:7357/events one-way
WebSocket ws://127.0.0.1:7358 bidirectional; inbound carries the auth frame and nothing else yet

Both bind loopback only, and both come up when the app launches — not when capture starts. That distinction matters: it is what lets POST /start work on a process that has never been told to listen, and it is the fix for a bug where stopping capture orphaned the listeners, leaving ports bound to a deallocated server that accepted connections and answered nothing.

They are not alternatives. Run both; a browser reading over SSE and a tool holding a socket can want the same stream at the same time.

Both are behind the token — ?access_token= on the SSE URL, a first frame of {"auth": "<token>"} on the WebSocket. Authentication has the whole scheme.

Frames

{
"type": "partial",
"text": "and this week we are looking at",
"ts": 1788569799791,
"seq": 41,
"audioStart": 5.04,
"audioEnd": 11.36,
"lagMs": 180
}
field meaning
type partial (volatile, revised as you speak) or final (settled)
text the hypothesis
ts epoch ms at emit — when we learned, not when it was said
seq monotonic; a gap means a dropped or reordered frame
audioStart / audioEnd seconds into the session’s audio the text covers
lagMs live audio edge minus audioEnd, at emit

text and ts are the original contract and have not moved. Everything else is additive: a consumer reading only text is unaffected.

On the wire, SSE delivers one frame per data: line; the WebSocket delivers one frame per message. Same bytes.

One thing on the stream is not a frame: when the engine changes, both transports carry { "event": "engine", "engine": "fluid320", "label": "Parakeet 320 ms" }. It has event where a frame has type, so a consumer that only handles frames skips it — the snippets below show the one-line check — and Engine selection says when it is sent.

Partials drive things; finals are for the record

A partial arrives roughly every 200 ms, about one word each, about 180 ms behind the speaker. That is what should drive a scroll or fire a cue.

A final arrives 1.6–3.1 s after the speech it describes, because the engine waits out its end-of-utterance debounce (1280 ms of silence) before deciding an utterance ended, and then still has to finish decoding. That is fine for what finals are for — settled text, and settling takes evidence — but nothing time-sensitive should wait for one.

text is the current utterance, not the session

Each final closes an utterance and the next partial starts empty. A consumer that wants a running transcript accumulates the finals itself.

This is not merely a preference. The underlying engine latches its end-of-utterance flag until it is reset and accumulates the transcript indefinitely otherwise, so an earlier build finalised only the first utterance of a session and grew text without bound — thirty-five seconds in, every frame still carried every word since second three, five times a second. Over a talk that is untenable.

Resetting between utterances costs the engine’s token timings, which restart at zero. The sidecar keeps its own offset so audioStart and audioEnd stay absolute on the session timeline across every reset:

utterance window
one 0.76 – 4.76
two 9.28 – 11.60
three 16.20 – 18.76

That is what makes the timestamps usable for placing markers rather than merely ordering words.

On lagMs being signed, and sometimes absent

Negative means the engine reported audio ahead of what we had captured — a real clock disagreement, surfaced rather than clamped. Absent means the engine did not report timing for that hypothesis.

Why consumers should use audioEnd, not ts

Arrival time is a lossy proxy for spoken time. Delivery clusters rather than ticking evenly, so ts carries the jitter of the delivery schedule on top of the actual timing. Anything placing a marker accurately, or leading a scroll by a fixed amount, wants audioEnd.

Consuming it

server-sent events
const token = '…' // the contents of ~/Library/Application Support/Sonocles/token
const es = new EventSource(`http://127.0.0.1:7357/events?access_token=${token}`)
es.onmessage = (e) => {
const f = JSON.parse(e.data)
if (f.event === 'engine') return picker.select(f.engine) // not a frame
if (f.lagMs != null) { /* trust f.audioEnd for timing */ }
}
WebSocket
const ws = new WebSocket('ws://127.0.0.1:7358')
ws.onopen = () => ws.send(JSON.stringify({ auth: token }))
ws.onmessage = (e) => {
const m = JSON.parse(e.data)
if ('status' in m) return // the auth answer; frames have no status
if (m.event === 'engine') return picker.select(m.engine) // not a frame
handle(m)
}
curl
TOKEN="$(cat ~/Library/Application\ Support/Sonocles/token)"
curl -sN "http://127.0.0.1:7357/events?access_token=$TOKEN"

A consumer can attach before capture starts and stay attached across stops. The stream opens either way; frames arrive only while listening. The API reference has the frame schema as the code is tested against it.