RheoclesREE-oh-kleez

Draft. Written against the spec, ahead of the code. The words are the intent; the software is not there yet.

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. 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, and can be rotated there; rotation invalidates the old token for everyone at once.

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 /events SSE: state, levels, drift, joins, errors
GET /preview/{stream} low-rate preview frames; one at a time
WS / everything above, full duplex

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 — or is null where the field is always present, like a stream’s drift before it has written a frame. 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 — not copied from it — so it cannot drift either. It is also what a Saloon SDK is generated from.