RheoclesREE-oh-kleez

The API

One command set, two transports. HTTP with server-sent events on 7447, WebSocket on 7448, a bearer token on both, loopback only.

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.

a WebSocket frame is the HTTP request, as an object
→ { "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
pairing, both ways
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 /takes create: reserve paths, write the manifest. Not recording
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
POST /record create and start, the one-click form
GET /settings · PATCH /settings { outputRoot, codec }; 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…" }

The events

event when carries
stream a stream’s armed state changes the stream as it now is
take a take changes state the manifest — with its join, leave and marker history
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 }

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 — freeBytes on a volume that will not say, drift on a display, framesDropped when 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’s status. 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 a 409 take_active.

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.