The API
One command set, two transports. HTTP with server-sent events on 7447, WebSocket on 7448, a bearer token on both, local only (for now).
Everything the menu bar app can do, the API can do, and the app does it through the API. If it can be clicked it can be called — that is the first principle in the spec, and it is what lets Pteroprompter and the NativePHP app drive the same daemon the popover does.
Two transports, as equals
| endpoint | direction | |
|---|---|---|
| HTTP + SSE | http://127.0.0.1:7447 · events on GET /events |
request/response, plus one-way push |
| WebSocket | ws://127.0.0.1:7448 |
full duplex |
They are not alternatives and neither is the real one. One command set, one dispatcher, two framings: anything possible on one is possible on the other, and both are up whenever the daemon is. The ports are chosen only to stay clear of Sonocles’ 7357 and 7358 on the same machine.
Both are up from the moment rheocles-core starts, before any stream is
armed or any take exists.
→ { "id": 7, "method": "GET", "path": "/", "query": {}, "body": null }← { "id": 7, "status": 200, "body": { "name": "Rheocles", … } }id is anything the client likes and is echoed back untouched; method,
path, query and body are exactly the HTTP request’s; status is the
HTTP status the same command would have produced. A JSON body is inline in
body; a binary one — a preview JPEG — comes as base64 with its
contentType instead. Events arrive on the same socket as objects with an
event key and no id.
Authentication
A bearer token, always required, on every transport.
Authorization: Bearer <token>The token is provisioned to ~/Library/Application Support/Rheocles/token,
mode 0600, so any app running as you reads it and is paired with zero
clicks. It is also shown in the popover’s settings as a pairing code, for
anything that cannot read the file. POST /token/rotate (or the button
beside the code) writes a new one and refuses the old from the next request
on, both transports; a WebSocket that authenticated with the old token stays
up but must auth again before its next command.
Loopback only in the MVP. The bind address and a certificate module are designed in, so LAN is an addition rather than a rewrite: v0.2 is TLS with a self-signed certificate and approve-on-the-box pairing — Rheocles announces on Bonjour, the capture box shows Allow Len’s MacBook?, one click pins the certificate and issues a token. Sonocles is being retrofitted to the same shape; one pairing design, two apps.
On each transport
| transport | the token travels as |
|---|---|
| HTTP | Authorization: Bearer <token> on every request, including GET /. Missing or wrong → 401 with WWW-Authenticate: Bearer realm="Rheocles" |
| SSE | the header, or GET /events?access_token=<token> — a browser EventSource cannot set headers (RFC 6750 §2.3). Loopback only, so the URL form exposes nothing the header form did not |
| WebSocket | the first frame, { "auth": "<token>" }, answered { "id": null, "status": 200, "body": { "authenticated": true } }. Until then every command answers 401 and no event is delivered. A browser WebSocket cannot set a header either, so the frame is the one way that works everywhere, and it is the only way |
const token = /* the token file, or the pairing code from the popover */const ws = new WebSocket('ws://127.0.0.1:7448/')ws.onopen = () => ws.send(JSON.stringify({ auth: token }))
const es = new EventSource(`http://127.0.0.1:7447/events?access_token=${token}`)The command set
GET / |
discovery: hostname, machine id, version, output root, free space, auth mode |
GET /streams |
every stream with armed state |
POST /streams/{id}/arm |
{ armed } — never stamps |
POST /streams/disarm |
disarm every armed stream; 409 while recording |
POST /takes |
prepare: reserve paths in memory, pre-flight the disk. Nothing on disk until start |
POST /takes/{id}/start |
the cue |
POST /takes/{id}/stop |
finalise |
POST /takes/{id}/join |
{ stream } — arms if needed, starts the writer |
POST /takes/{id}/leave |
{ stream } — finalises that file, stays armed |
POST /takes/{id}/markers |
{ label } |
GET /takes/{id} |
the manifest, live while recording |
GET /takes |
recent takes, newest first, each with its combined block when it has one |
POST /takes/{id}/combine |
passthrough-mux a finished take into combined.mov after the fact; answers combined.state: "pending", completion on the take event |
POST /takes/{id}/reveal |
{ path? } — reveal the take folder, or a file in it, in the Finder; 204 |
POST /reveal |
{ path? } — reveal any path under the output root in the Finder; 204 |
POST /record |
create and start, the one-click form |
GET /settings · PATCH /settings |
{ outputRoot, codec, combine }; the root cannot move while a take is active |
POST /token/rotate |
a new token; the old one refused from the next request |
GET /events |
SSE: stream, take, levels, marker, stalled, settings |
GET /preview/{stream} |
one frame on demand — a JPEG for video, { levelDb } for audio; one at a time |
WS / |
everything above, full duplex |
Preview
GET /preview/{stream} answers one frame, on demand. Nothing is
captured when nobody is looking: each request opens the device, grabs a
single frame, and closes it — as a polite second opener, with no
configuration lock and no format change, so previewing a camera or a screen
that a take is recording never disturbs the take. One preview runs at a
time.
| stream | answers |
|---|---|
| display, window, camera | image/jpeg, longest side 640 |
| microphone, system audio | application/json { "levelDb": <peak dBFS of a short sample> } |
404 for an unknown stream; 503 no_frame if the device delivered nothing
— a camera with no signal. Over the WebSocket a JPEG cannot ride in a JSON
frame, so it comes back as base64 with its contentType:
← { "id": 16, "status": 200, "contentType": "image/jpeg", "base64": "/9j/4AAQSk…" }Reveal in the Finder
POST /takes/{id}/reveal { "path": "combined.mov" } → 204POST /takes/{id}/reveal {} → 204 the take folderPOST /reveal { "path": "takes/2026-09-11" } → 204POST /reveal {} → 204 the output rootBoth open a Finder window on the daemon’s Mac with the target selected.
POST /takes/{id}/reveal reveals the take’s folder; path, when given,
is one of the take’s files, relative to the folder, and selects it.
POST /reveal reveals any path under the output root, relative to it —
the settings panel’s button sends an empty path and gets the root. A
path must stay within its base (an absolute path, or one that climbs out
with .., is refused) and must exist: an unknown take or a path that
names nothing is 404. Success is 204 and no body, on both transports.
Why this is a daemon call and not something each app does locally: a
browser page cannot open the Finder, and Pteroprompter is a browser page;
and two front ends on one daemon should not implement the same thing
twice. The menu bar app and the NativePHP app both call these routes
and never touch NSWorkspace themselves.
Combine
POST /takes/{id}/combine runs the single-file mux after the fact — on a
take that is complete or incomplete, not the live one, with at most
one video stream and at least one file on disk. It answers the manifest
with combined.state: "pending" at once; the take event carries
complete or failed when the export finishes. A second call while one
is pending returns the pending manifest and starts nothing. 409 while
the take is recording. What the file is, and the combine option on
create that writes one without asking, is on Recording a
take.
The events
| event | when | carries |
|---|---|---|
stream |
a stream’s armed state changes | the stream as it now is |
take |
a take changes state, or its combined mux finishes |
the manifest — with its join, leave and marker history, and the combined block when it has one |
levels |
~4×/s while recording | take and streams: [{ id, levelDb?, framesWritten, drift? }]; levelDb is peak dBFS since the last event, audio only |
marker |
a marker lands | take and the { t, label } |
stalled |
an armed or recording stream stops delivering | the stream as it now is |
settings |
PATCH /settings |
the new { outputRoot, codec, combine } |
The same objects, byte for byte, on SSE and on every authenticated
WebSocket. Each has an event key and no id.
Every one of these, with request fields, response examples, errors and the WebSocket frame, is on the API reference.
Conventions
- Paths are relative. The take folder to the output root — the one
absolute path in the API, on
GET /— and each file to the take folder. Clients store them as given. - Ids are the handle. A take id, a stream id. Paths are what you get back when you ask.
- Absence is absence. An unmeasurable value is left out —
freeByteson a volume that will not say,drifton a display,framesDroppedwhen none were. Never zero by default. - Errors are one shape,
{ "error": "for humans", "code": "for_programs" }, on every route and both transports. HTTP carries the status in the status line, the WebSocket in the frame’sstatus. The codes are on the reference. - CORS is open. A page in your browser can call the API; the token is the lock, not the origin.
- Same destination twice is a
409 conflict. Rheocles never silently suffixes. A take already recording is a409 take_active. - Combine has two codes of its own, both
400:combine_requires_single_videowhencombine: trueis asked for with two or more video streams armed (orPOST /takes/{id}/combinefinds two in the take), andnothing_to_combinewhen the take has no files on disk. Both are in the error table. - A default that cannot apply is dropped, with a warning. When
settings.combineis on, the request did not saycombine, and two or more video streams are armed, create does not refuse: the take is made without a combined file andwarningscarries combine is on by default but 2 video streams are armed; recording without a combined file. An explicitcombine: truein the same state is the400.
The reference is generated
docs/openapi.yaml is hand-authored by the engine’s owner and is a
first-class artifact: CI runs the real daemon and validates its live responses
against the file, so “current with the code” is enforced rather than hoped.
The reference page is built from that file at deploy
time — every route, both transports (the WebSocket from its x-websocket
block), every event — not copied from it, so it cannot drift either. It is
also what a Saloon SDK is generated from.