Draft. Written against the spec, ahead of the code. The words are the intent; the software is not there yet.
API reference
Every route, on both transports. 3 generated from docs/openapi.yaml (0.1.0); 12 still only in the spec, marked planned.
Conventions
| HTTP + SSE | http://127.0.0.1:7447 |
| WebSocket | ws://127.0.0.1:7448 |
| Auth | Authorization: Bearer <token> on every HTTP request; ?access_token= also accepted on GET /events; on the WebSocket, a first frame of { "auth": "<token>" } |
| Bodies | JSON in, JSON out; Content-Type: application/json |
| Paths | every file path in every response is relative to outputRoot from GET / |
| Times | host times are ISO 8601 UTC with milliseconds; t is seconds from the cue; timecode is HH:MM:SS:FF |
| Absence | an unmeasurable value is absent, or null where the field is always present — never 0 |
{ "error": "destination ep12 already exists", "code": "conflict" }| status | code | when |
|---|---|---|
400 | bad_request | malformed JSON, missing body, a frame without method/path |
401 | unauthorized | no token, wrong token, WebSocket before the auth frame |
403 | permission_denied | macOS has not granted the device class this stream needs |
404 | not_found | no such route, stream or take |
405 | method_not_allowed | the path exists, the method does not |
409 | conflict | destination already exists; a take already active |
500 | internal | a handler threw |
501 | unsupported | this kind cannot be captured yet |
503 | device_unavailable | the device is gone, busy, or refused the configuration |
507 | insufficient_storage | the disk pre-flight refused the take |
Discovery
GET/pinned
Who this daemon is, where files go, how much room there is.
- 401
- No token, or the wrong one.
Discovery.
→ {"id":1,"method":"GET","path":"/"}← {"id":1,"status":200,"body":{"name":"Rheocles","version":"0.1.0","hostname":"lens-macbook-pro.local","machineId":"CD3B7EE5-5E6C-5155-854A-72E4728555F7","outputRoot":"/Users/gopher/Movies/Rheocles","freeBytes":44878079167,"auth":"bearer","ports":{"http":7447,"ws":7448}}}| response field | type | |
|---|---|---|
name* | "Rheocles" | |
version* | string | |
hostname* | string | |
machineId* | string | The kernel host UUID; stable across renames and reboots. |
outputRoot* | string | The one absolute path in the API. Every other path is relative to it. |
freeBytes | integer | Bytes available for important usage on the output root's volume. Absent when unmeasurable — absent means unknown, never zero. |
auth* | "bearer" | |
ports* | object | |
ports.http* | integer | |
ports.ws* | integer |
Streams
Every input on the machine, with a stable id. Arming makes a device live; it never writes.
GET/streamspinned
Every stream with its armed state, plus what macOS lets this process see.
- 401
- No token, or the wrong one.
Streams, in a fixed order — displays, windows, cameras, microphones, system audio.
→ {"id":2,"method":"GET","path":"/streams"}← {"id":2,"status":200,"body":{"streams":[],"permissions":{"camera":"authorized","microphone":"authorized","screen":"authorized"}}}| response field | type | |
|---|---|---|
streams* | object[] | |
streams[].id* | string | Stable and URL-safe, <kind>:<identifier>. Clients hold this. |
streams[].kind* | "display" | "window" | "camera" | "microphone" | "systemAudio" | |
streams[].name* | string | What the user calls it. |
streams[].model* | string | What the hardware calls itself, or the owning application for a window. |
streams[].capabilities* | object | |
streams[].capabilities.video | object | |
streams[].capabilities.audio | object | |
streams[].armed* | boolean | Whether the capture session is live. |
streams[].active | object | What the device is actually delivering; present only while armed. |
streams[].active.video | object | |
streams[].active.audio | object | |
streams[].framesSeen | integer | Frames or audio buffers delivered since arming; present only while armed. |
permissions* | object | What macOS has let this process do. Screen Recording gates displays and windows both; with screen anything but authorized the list has none. |
permissions.camera* | "authorized" | "denied" | "restricted" | "notDetermined" | |
permissions.microphone* | "authorized" | "denied" | "restricted" | "notDetermined" | |
permissions.screen* | "authorized" | "denied" | "restricted" | "notDetermined" |
POST/streams/{id}/armpinned
Make a device live (frames flow and are discarded) or release it. Never stamps, never writes.
| field | type | |
|---|---|---|
armed* | boolean |
- 400
- Malformed or missing JSON body.
- 401
- No token, or the wrong one.
- 403
- macOS has not granted this process the device class.
- 404
- No such stream or take.
- 503
- The device is gone, busy, or refused the configuration.
The stream as it now is. Idempotent in both directions.
→ {"id":3,"method":"POST","path":"/streams/camera:0x2300000fd9009c/arm","body":{"armed":true}}← {"id":3,"status":200,"body":{"id":"camera:0x2300000fd9009c","kind":"camera","name":"Elgato 4K X","model":"UVC Camera VendorID_4057 ProductID_156","capabilities":{"video":{"width":3840,"height":2160,"maxFrameRate":30}},"armed":true,"active":{"video":{"width":1280,"height":720,"maxFrameRate":120}},"framesSeen":0}}| response field | type | |
|---|---|---|
id* | string | Stable and URL-safe, <kind>:<identifier>. Clients hold this. |
kind* | "display" | "window" | "camera" | "microphone" | "systemAudio" | |
name* | string | What the user calls it. |
model* | string | What the hardware calls itself, or the owning application for a window. |
capabilities* | object | |
capabilities.video | object | |
capabilities.video.width* | integer | Native pixels. |
capabilities.video.height* | integer | |
capabilities.video.maxFrameRate* | number | The highest advertised rate; the signal's real rate can be lower. |
capabilities.audio | object | |
capabilities.audio.sampleRate* | number | |
capabilities.audio.channels* | integer | |
armed* | boolean | Whether the capture session is live. |
active | object | What the device is actually delivering; present only while armed. |
active.video | object | |
active.video.width* | integer | |
active.video.height* | integer | |
active.video.maxFrameRate* | number | |
active.audio | object | |
active.audio.sampleRate* | number | |
active.audio.channels* | integer | |
framesSeen | integer | Frames or audio buffers delivered since arming; present only while armed. |
Takes
Create reserves the paths and writes the manifest. Start is the cue. Stop finalises.
POST/takesplanned
Snapshot the armed set, reserve every path, write the manifest. Not recording.
| field | type | |
|---|---|---|
name | string | Names the take folder. Defaults to a timestamp. |
destination | string | A folder, relative to the output root. |
files | object | Map of stream id → relative path, for clients that want to name files. |
codec | "hevc" | "prores422" | The one global codec setting for this take. |
expectedDuration | number | Seconds. Used for the disk pre-flight; defaults to 1800. |
overwrite | boolean | Allow a destination that already exists. Otherwise 409. |
- 409
- the destination already exists and overwrite is not true; or a take is already active
- 507
- the disk pre-flight refused the take
{ "id": "tk_7f3a", "state": "created", "files": { "camera:0x2300000fd9009c": "ep12/cam-facetime.mov", "microphone:Scarlett_2i2": "ep12/mic-mv7.wav" } }Every path relative to the output root.
{ "id": 7, "method": "POST", "path": "/takes", "body": { "name": "ep12" } }POST/takes/{id}/startplanned
The cue. Every armed stream joins at once.
- 409
- the take is not in state created
{ "id": "tk_7f3a", "state": "recording", "started": "2026-09-11T14:02:17.004Z" }{ "id": 7, "method": "POST", "path": "/takes/tk_7f3a/start", "body": null }POST/takes/{id}/stopplanned
Finalise every writer and the manifest.
{ "id": "tk_7f3a", "state": "complete", "stopped": "2026-09-11T14:14:40.501Z" }{ "id": 7, "method": "POST", "path": "/takes/tk_7f3a/stop", "body": null }POST/takes/{id}/joinplanned
Start one stream’s writer now. Arms it first if it is cold.
| field | type | |
|---|---|---|
stream* | string | The stream id. |
- 409
- the take is not recording, or the stream is already joined
{ "id": "tk_7f3a", "stream": "window:11597", "path": "ep12/window-keynote.mov", "started": "2026-09-11T14:06:17.021Z", "timecode": "14:06:17:00" }{ "id": 7, "method": "POST", "path": "/takes/tk_7f3a/join", "body": { "stream": "window:11597" } }POST/takes/{id}/leaveplanned
Finalise that stream’s file. The stream stays armed; the take continues.
| field | type | |
|---|---|---|
stream* | string | The stream id. |
{ "id": "tk_7f3a", "stream": "window:11597", "stopped": "2026-09-11T14:08:31.115Z", "frames": 8045 }{ "id": 7, "method": "POST", "path": "/takes/tk_7f3a/leave", "body": { "stream": "window:11597" } }POST/takes/{id}/markersplanned
Append { t, label }. Rheocles stamps t; the label is yours.
| field | type | |
|---|---|---|
label* | string | Never interpreted by Rheocles. |
- 409
- the take is not recording
{ "t": 38.7, "label": "cold-open out" }{ "id": 7, "method": "POST", "path": "/takes/tk_7f3a/markers", "body": { "label": "cold-open out" } }GET/takes/{id}planned
The manifest — live while recording, from disk afterwards.
- 404
- no such take
{ "take": { "id": "tk_7f3a", "state": "recording", … }, "streams": [ … ], "markers": [ … ] }The full shape is on Takes and the manifest.
{ "id": 7, "method": "GET", "path": "/takes/tk_7f3a", "body": null }GET/takesplanned
Recent takes.
[ { "id": "tk_7f3a", "name": "ep12", "state": "complete", "created": "2026-09-11T14:02:09.412Z", "path": "ep12" }, … ]{ "id": 7, "method": "GET", "path": "/takes", "body": null }Record
POST/recordplanned
Create and start together. The one-click form; what the popover’s button does.
| field | type | |
|---|---|---|
… | | The same body as POST /takes. |
{ "id": "tk_7f3a", "state": "recording", "files": { … } }{ "id": 7, "method": "POST", "path": "/record", "body": { "name": "ep12" } }Events
State, levels, drift, joins, errors — pushed as they happen.
GET/eventsplanned
Server-sent events. One-way; every event type below.
A browser EventSource cannot set an Authorization header. How the token travels on this route from a browser — a query parameter, or fetch with a readable stream — is the reference’s to pin. Native clients set the header.
event: leveldata: { "stream": "microphone:Scarlett_2i2", "peakDb": -19.4, "ts": 1789178960251 }Preview
GET/preview/{stream}planned
Low-rate preview frames for one stream, on demand. One at a time.
For a popover, not a monitor wall. Opening a second preview closes the first. The frame format and rate are the reference’s to pin.
- 404
- no such stream
{ "id": 7, "method": "GET", "path": "/preview/camera:0x2300000fd9009c", "body": null }WebSocket
Everything above, full duplex, on port 7448.
WS/planned
One socket carries every command and every event.
Text frames, one JSON object each. The first frame must be { "auth": "<token>" }; until then every command answers 401 and no event is delivered. A command frame is the HTTP request as an object — id is anything you like and is echoed back, method, path, and the optional query and body are exactly the HTTP request’s — and the reply carries the HTTP status and body. Events arrive as objects with an event key and no id.
→ { "auth": "<token>" }← { "id": null, "status": 200, "body": { "authenticated": true } }→ { "id": 7, "method": "POST", "path": "/takes/tk_7f3a/start", "query": {}, "body": null }← { "id": 7, "status": 200, "body": { "id": "tk_7f3a", "state": "recording", … } }← { "event": "state", "take": "tk_7f3a", "state": "recording" }Event types
Delivered on GET /events as data: lines, and on the WebSocket as objects with an event key and no id. Shapes are planned until the YAML pins them.
| event | when | data |
|---|---|---|
streampinned | A stream's armed state changed. | {"event":"stream","stream":{"id":"microphone:BuiltInMicrophoneDevice","kind":"microphone","name":"MacBook Pro Microphone","model":"Digital Mic","capabilities":{"audio":{"sampleRate":48000,"channels":1}},"armed":true,"active":{"audio":{"sampleRate":48000,"channels":1}},"framesSeen":0}} |
stateplanned | a take changes state | { "take": "tk_7f3a", "state": "recording", "at": "2026-09-11T14:02:17.004Z" } |
joinplanned | a stream starts writing | { "take": "tk_7f3a", "stream": "window:11597", "t": 240.017, "timecode": "14:06:17:00" } |
leaveplanned | a stream’s file is finalised | { "take": "tk_7f3a", "stream": "window:11597", "t": 374.111, "frames": 8045 } |
levelsplanned | an armed audio stream’s meter ticks | { "stream": "microphone:Scarlett_2i2", "peakDb": -19.4 } |
driftplanned | a recording stream’s drift is re-measured | { "take": "tk_7f3a", "stream": "camera:0x2300000fd9009c", "driftMs": -3, "frames": 21540 } |
markerplanned | a marker lands | { "take": "tk_7f3a", "t": 38.7, "label": "cold-open out" } |
errorplanned | something fails; the take may now be incomplete | { "take": "tk_7f3a", "stream": "display:56A96CFC-7F21", "reason": "disk full", "state": "incomplete" } |