RheoclesREE-oh-kleez

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. 9 generated from docs/openapi.yaml (0.1.0); 6 still only in the spec, marked planned.

Conventions

HTTP + SSEhttp://127.0.0.1:7447
WebSocketws://127.0.0.1:7448
AuthAuthorization: Bearer <token> on every HTTP request; ?access_token= also accepted on GET /events; on the WebSocket, a first frame of { "auth": "<token>" }
BodiesJSON in, JSON out; Content-Type: application/json
Pathsthe take folder is relative to outputRoot from GET /; each file is relative to the take folder
Timeshost times are ISO 8601 UTC with milliseconds; t is seconds from the cue; timecode is HH:MM:SS:FF
Absencean unmeasurable value is absent, or null where the field is always present — never 0
the error shape — error for humans, code for programs
{ "error": "destination takes/2026-09-11/210433-episode-12 is not empty", "code": "conflict" }
statuscodewhen
400bad_requestmalformed JSON, missing body, a frame without method/path
401unauthorizedno token, wrong token, WebSocket before the auth frame
403permission_deniedmacOS has not granted the device class this stream needs
404not_foundno such route, stream or take
405method_not_allowedthe path exists, the method does not
409conflictdestination already exists; a take is not in the state the verb needs
409take_activea take is recording; stop it first
500internala handler threw
501unsupportedthis kind cannot be captured yet
503device_unavailablethe device is gone, busy, or refused the configuration
507insufficient_storagethe 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.

over WebSocket
→ {"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 fieldtype
name*"Rheocles"
version*string
hostname*string
machineId*stringThe kernel host UUID; stable across renames and reboots.
outputRoot*stringThe one absolute path in the API. Every other path is relative to it.
freeBytesintegerBytes 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.

over WebSocket
→ {"id":2,"method":"GET","path":"/streams"}
← {"id":2,"status":200,"body":{"streams":[],"permissions":{"camera":"authorized","microphone":"authorized","screen":"authorized"}}}
response fieldtype
streams*object[]
streams[].id*stringStable and URL-safe, <kind>:<identifier>. Clients hold this.
streams[].kind*"display" | "window" | "camera" | "microphone" | "systemAudio"
streams[].name*stringWhat the user calls it.
streams[].model*stringWhat the hardware calls itself, or the owning application for a window.
streams[].capabilities*object
streams[].capabilities.videoobject
streams[].capabilities.audioobject
streams[].armed*booleanWhether the capture session is live.
streams[].activeobjectWhat the device is actually delivering; present only while armed.
streams[].active.videoobject
streams[].active.audioobject
streams[].framesSeenintegerFrames or audio buffers delivered since arming; present only while armed.
permissions*objectWhat 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.

fieldtype
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.

over WebSocket
→ {"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 fieldtype
id*stringStable and URL-safe, <kind>:<identifier>. Clients hold this.
kind*"display" | "window" | "camera" | "microphone" | "systemAudio"
name*stringWhat the user calls it.
model*stringWhat the hardware calls itself, or the owning application for a window.
capabilities*object
capabilities.videoobject
capabilities.video.width*integerNative pixels.
capabilities.video.height*integer
capabilities.video.maxFrameRate*numberThe highest advertised rate; the signal's real rate can be lower.
capabilities.audioobject
capabilities.audio.sampleRate*number
capabilities.audio.channels*integer
armed*booleanWhether the capture session is live.
activeobjectWhat the device is actually delivering; present only while armed.
active.videoobject
active.video.width*integer
active.video.height*integer
active.video.maxFrameRate*number
active.audioobject
active.audio.sampleRate*number
active.audio.channels*integer
framesSeenintegerFrames 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/takespinned

Create a take — snapshot the armed set, reserve paths, pre-flight the disk, write the manifest. Nothing records.

fieldtype
namestring
destinationstringTake folder relative to the output root. Default takes/<yyyy-MM-dd>/<HHmmss>[-<name>].
filesobjectFile name per stream id, relative to the take folder.
codec"hevc" | "prores"
expectedDurationnumberSeconds, for the disk pre-flight. Default 1800.
overwritebooleanReuse a destination that already exists. Never silently suffixed.
400
Malformed or missing JSON body.
401
No token, or the wrong one.
409
Destination already exists (code `conflict`), or a take is recording (code `take_active`).
507
The disk pre-flight refused the take.

The manifest (state `created`) and any pre-flight warnings.

over WebSocket
→ {"id":4,"method":"POST","path":"/takes","body":{"name":"Episode 12","expectedDuration":3600}}
← {"id":4,"status":201,"body":{"take":{"id":"20260912T040433-fd9q","name":"Episode 12","state":"created","created":"2026-09-12T04:04:33.235Z","outputRoot":"/Users/gopher/Movies/Rheocles","destination":"takes/2026-09-11/210433-episode-12","version":"0.1.0","machine":{"hostname":"lens-macbook-pro.local","machineId":"CD3B7EE5-5E6C-5155-854A-72E4728555F7"},"streams":[{"id":"microphone:AppleUSBAudioEngine_Focusrite_Scarlett_2i2_USB_Y8CABR91C1CA8A_1_2","kind":"microphone","name":"Scarlett 2i2 USB","model":"Scarlett 2i2 USB:1235:8210","path":"scarlett-2i2-usb.wav","codec":"pcm_s24le","format":{"audio":{"sampleRate":48000,"channels":2}},"framesWritten":0,"events":[]}],"markers":[],"settings":{"codec":"hevc","expectedDuration":3600}},"warnings":[]}}
response fieldtype
take*objectThe manifest is the take. Rewritten atomically on every state change.
take.id*string
take.namestring
take.state*"created" | "recording" | "complete" | "incomplete"
take.reasonstringPresent only when incomplete.
take.created*string
take.startedstring
take.stoppedstring
take.outputRoot*stringAbsolute, as it was at create.
take.destination*stringTake folder, relative to the output root.
take.version*string
take.machine*object
take.machine.hostname*string
take.machine.machineId*string
take.streams*object[]
take.streams[].id*string
take.streams[].kind*"display" | "window" | "camera" | "microphone" | "systemAudio"
take.streams[].name*string
take.streams[].model*string
take.streams[].path*stringRelative to the take folder.
take.streams[].codec*stringhevc, prores, or pcm_s24le.
take.streams[].format*object
take.streams[].startedstring
take.streams[].stoppedstring
take.streams[].timecodestringTime-of-day timecode of the first written frame.
take.streams[].framesWritten*integer
take.streams[].driftnumberFrames × frame duration versus host elapsed, seconds.
take.streams[].events*object[]
take.streams[].errorstring
take.markers*object[]
take.markers[].t*numberSeconds from the cue.
take.markers[].label*string
take.settings*object
take.settings.codec*"hevc" | "prores"
take.settings.expectedDurationnumber
warnings*string[]

POST/takes/{id}/startpinned

The cue. Every armed stream's writer starts on frames that are already flowing.

401
No token, or the wrong one.
404
No such stream or take.
409
The take is not `created`, or is not the active take.

The manifest, now `recording`.

over WebSocket
→ {"id":7,"method":"POST","path":"/takes/20260912T040433-fd9q/start"}
← {"id":7,"status":200,"body":{"id":"20260912T040433-fd9q","state":"recording","started":"2026-09-12T04:04:33.347Z"}}
response fieldtype
id*string
namestring
state*"created" | "recording" | "complete" | "incomplete"
reasonstringPresent only when incomplete.
created*string
startedstring
stoppedstring
outputRoot*stringAbsolute, as it was at create.
destination*stringTake folder, relative to the output root.
version*string
machine*object
machine.hostname*string
machine.machineId*string
streams*object[]
streams[].id*string
streams[].kind*"display" | "window" | "camera" | "microphone" | "systemAudio"
streams[].name*string
streams[].model*string
streams[].path*stringRelative to the take folder.
streams[].codec*stringhevc, prores, or pcm_s24le.
streams[].format*object
streams[].format.videoobject
streams[].format.audioobject
streams[].startedstring
streams[].stoppedstring
streams[].timecodestringTime-of-day timecode of the first written frame.
streams[].framesWritten*integer
streams[].driftnumberFrames × frame duration versus host elapsed, seconds.
streams[].events*object[]
streams[].events[].t*number
streams[].events[].type*"join" | "leave"
streams[].errorstring
markers*object[]
markers[].t*numberSeconds from the cue.
markers[].label*string
settings*object
settings.codec*"hevc" | "prores"
settings.expectedDurationnumber

POST/takes/{id}/stoppinned

Finalize every writer and the manifest. Streams stay armed.

401
No token, or the wrong one.
404
No such stream or take.
409
The take is not `recording`.

The final manifest — `complete`, or `incomplete` with a reason.

over WebSocket
→ {"id":8,"method":"POST","path":"/takes/20260912T040433-fd9q/stop"}
← {"id":8,"status":200,"body":{"id":"20260912T040433-fd9q","state":"complete","stopped":"2026-09-12T04:04:35.402Z"}}
response fieldtype
id*string
namestring
state*"created" | "recording" | "complete" | "incomplete"
reasonstringPresent only when incomplete.
created*string
startedstring
stoppedstring
outputRoot*stringAbsolute, as it was at create.
destination*stringTake folder, relative to the output root.
version*string
machine*object
machine.hostname*string
machine.machineId*string
streams*object[]
streams[].id*string
streams[].kind*"display" | "window" | "camera" | "microphone" | "systemAudio"
streams[].name*string
streams[].model*string
streams[].path*stringRelative to the take folder.
streams[].codec*stringhevc, prores, or pcm_s24le.
streams[].format*object
streams[].format.videoobject
streams[].format.audioobject
streams[].startedstring
streams[].stoppedstring
streams[].timecodestringTime-of-day timecode of the first written frame.
streams[].framesWritten*integer
streams[].driftnumberFrames × frame duration versus host elapsed, seconds.
streams[].events*object[]
streams[].events[].t*number
streams[].events[].type*"join" | "leave"
streams[].errorstring
markers*object[]
markers[].t*numberSeconds from the cue.
markers[].label*string
settings*object
settings.codec*"hevc" | "prores"
settings.expectedDurationnumber

POST/takes/{id}/joinplanned

Start one stream’s writer now. Arms it first if it is cold.

fieldtype
stream*stringThe stream id.
409
the take is not recording, or the stream is already joined
response
{ "id": "20260912T040433-fd9q", "stream": "window:11597", "path": "window-com.apple.iWork.Keynote.mov",
"started": "2026-09-11T14:06:17.021Z", "timecode": "14:06:17:00" }
over WebSocket
{ "id": 7, "method": "POST", "path": "/takes/20260912T040433-fd9q/join", "body": { "stream": "window:11597" } }

POST/takes/{id}/leaveplanned

Finalise that stream’s file. The stream stays armed; the take continues.

fieldtype
stream*stringThe stream id.
response
{ "id": "20260912T040433-fd9q", "stream": "window:11597", "stopped": "2026-09-11T14:08:31.115Z", "framesWritten": 8045 }
over WebSocket
{ "id": 7, "method": "POST", "path": "/takes/20260912T040433-fd9q/leave", "body": { "stream": "window:11597" } }

POST/takes/{id}/markersplanned

Append { t, label }. Rheocles stamps t; the label is yours.

fieldtype
label*stringNever interpreted by Rheocles.
409
the take is not recording
response
{ "t": 38.7, "label": "cold-open out" }
over WebSocket
{ "id": 7, "method": "POST", "path": "/takes/20260912T040433-fd9q/markers", "body": { "label": "cold-open out" } }

GET/takes/{id}pinned

The manifest — live while recording, from disk afterwards.

401
No token, or the wrong one.
404
No such stream or take.

The manifest.

over WebSocket
→ {"id":6,"method":"GET","path":"/takes/20260912T040433-fd9q"}
← {"id":6,"status":200,"body":{"id":"20260912T040433-fd9q","state":"recording"}}
response fieldtype
id*string
namestring
state*"created" | "recording" | "complete" | "incomplete"
reasonstringPresent only when incomplete.
created*string
startedstring
stoppedstring
outputRoot*stringAbsolute, as it was at create.
destination*stringTake folder, relative to the output root.
version*string
machine*object
machine.hostname*string
machine.machineId*string
streams*object[]
streams[].id*string
streams[].kind*"display" | "window" | "camera" | "microphone" | "systemAudio"
streams[].name*string
streams[].model*string
streams[].path*stringRelative to the take folder.
streams[].codec*stringhevc, prores, or pcm_s24le.
streams[].format*object
streams[].format.videoobject
streams[].format.audioobject
streams[].startedstring
streams[].stoppedstring
streams[].timecodestringTime-of-day timecode of the first written frame.
streams[].framesWritten*integer
streams[].driftnumberFrames × frame duration versus host elapsed, seconds.
streams[].events*object[]
streams[].events[].t*number
streams[].events[].type*"join" | "leave"
streams[].errorstring
markers*object[]
markers[].t*numberSeconds from the cue.
markers[].label*string
settings*object
settings.codec*"hevc" | "prores"
settings.expectedDurationnumber

GET/takespinned

Recent takes, newest first.

401
No token, or the wrong one.

Up to 50 summaries.

over WebSocket
→ {"id":5,"method":"GET","path":"/takes"}
← {"id":5,"status":200,"body":[{"id":"20260912T040433-fd9q","name":"Episode 12","state":"complete","created":"2026-09-12T04:04:33.235Z","destination":"takes/2026-09-11/210433-episode-12","streams":5}]}
response fieldtype

Record

POST/recordpinned

Create and start in one call — the popover's Record button.

fieldtype
namestring
destinationstringTake folder relative to the output root. Default takes/<yyyy-MM-dd>/<HHmmss>[-<name>].
filesobjectFile name per stream id, relative to the take folder.
codec"hevc" | "prores"
expectedDurationnumberSeconds, for the disk pre-flight. Default 1800.
overwritebooleanReuse a destination that already exists. Never silently suffixed.
400
Malformed or missing JSON body.
401
No token, or the wrong one.
409
Destination already exists (code `conflict`), or a take is recording (code `take_active`).
507
The disk pre-flight refused the take.

The manifest (state `recording`) and any pre-flight warnings.

over WebSocket
→ {"id":9,"method":"POST","path":"/record","body":{"name":"quick"}}
← {"id":9,"status":201,"body":{"take":{"id":"20260912T040500-k2pm","state":"recording"},"warnings":[]}}
response fieldtype
take*objectThe manifest is the take. Rewritten atomically on every state change.
take.id*string
take.namestring
take.state*"created" | "recording" | "complete" | "incomplete"
take.reasonstringPresent only when incomplete.
take.created*string
take.startedstring
take.stoppedstring
take.outputRoot*stringAbsolute, as it was at create.
take.destination*stringTake folder, relative to the output root.
take.version*string
take.machine*object
take.machine.hostname*string
take.machine.machineId*string
take.streams*object[]
take.streams[].id*string
take.streams[].kind*"display" | "window" | "camera" | "microphone" | "systemAudio"
take.streams[].name*string
take.streams[].model*string
take.streams[].path*stringRelative to the take folder.
take.streams[].codec*stringhevc, prores, or pcm_s24le.
take.streams[].format*object
take.streams[].startedstring
take.streams[].stoppedstring
take.streams[].timecodestringTime-of-day timecode of the first written frame.
take.streams[].framesWritten*integer
take.streams[].driftnumberFrames × frame duration versus host elapsed, seconds.
take.streams[].events*object[]
take.streams[].errorstring
take.markers*object[]
take.markers[].t*numberSeconds from the cue.
take.markers[].label*string
take.settings*object
take.settings.codec*"hevc" | "prores"
take.settings.expectedDurationnumber
warnings*string[]

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.

response
event: level
data: { "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
over WebSocket
{ "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.

frames
→ { "auth": "<token>" }
← { "id": null, "status": 200, "body": { "authenticated": true } }
→ { "id": 7, "method": "POST", "path": "/takes/20260912T040433-fd9q/start", "query": {}, "body": null }
← { "id": 7, "status": 200, "body": { "id": "20260912T040433-fd9q", "state": "recording", … } }
← { "event": "state", "take": "20260912T040433-fd9q", "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.

eventwhendata
takepinnedA take changed state — created, recording, complete or incomplete. Carries the manifest.{"event":"take","take":{"id":"20260912T040433-fd9q","name":"Episode 12","state":"recording","created":"2026-09-12T04:04:33.235Z","started":"2026-09-12T04:04:33.347Z","outputRoot":"/Users/gopher/Movies/Rheocles","destination":"takes/2026-09-11/210433-episode-12","version":"0.1.0","machine":{"hostname":"lens-macbook-pro.local","machineId":"CD3B7EE5-5E6C-5155-854A-72E4728555F7"},"streams":[],"markers":[],"settings":{"codec":"hevc"}}}
streampinnedA 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}}
stateplanneda take changes state{ "take": "20260912T040433-fd9q", "state": "recording", "at": "2026-09-11T14:02:17.004Z" }
joinplanneda stream starts writing{ "take": "20260912T040433-fd9q", "stream": "window:11597", "t": 240.017, "timecode": "14:06:17:00" }
leaveplanneda stream’s file is finalised{ "take": "20260912T040433-fd9q", "stream": "window:11597", "t": 374.111, "framesWritten": 8045 }
levelsplannedan armed audio stream’s meter ticks{ "stream": "microphone:Scarlett_2i2", "peakDb": -19.4 }
driftplanneda recording stream’s drift is re-measured{ "take": "20260912T040433-fd9q", "stream": "camera:0x2300000fd9009c", "drift": -0.003, "framesWritten": 21540 }
markerplanneda marker lands{ "take": "20260912T040433-fd9q", "t": 38.7, "label": "cold-open out" }
errorplannedsomething fails; the take may now be incomplete{ "take": "20260912T040433-fd9q", "stream": "display:56A96CFC-7F21", "reason": "disk full", "state": "incomplete" }