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 + 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 | the take folder is relative to outputRoot from GET /; each file is relative to the take folder |
| 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 takes/2026-09-11/210433-episode-12 is not empty", "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 is not in the state the verb needs |
409 | take_active | a take is recording; stop it first |
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/takespinned
Create a take — snapshot the armed set, reserve paths, pre-flight the disk, write the manifest. Nothing records.
| field | type | |
|---|---|---|
name | string | |
destination | string | Take folder relative to the output root. Default takes/<yyyy-MM-dd>/<HHmmss>[-<name>]. |
files | object | File name per stream id, relative to the take folder. |
codec | "hevc" | "prores" | |
expectedDuration | number | Seconds, for the disk pre-flight. Default 1800. |
overwrite | boolean | Reuse 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.
→ {"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 field | type | |
|---|---|---|
take* | object | The manifest is the take. Rewritten atomically on every state change. |
take.id* | string | |
take.name | string | |
take.state* | "created" | "recording" | "complete" | "incomplete" | |
take.reason | string | Present only when incomplete. |
take.created* | string | |
take.started | string | |
take.stopped | string | |
take.outputRoot* | string | Absolute, as it was at create. |
take.destination* | string | Take 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* | string | Relative to the take folder. |
take.streams[].codec* | string | hevc, prores, or pcm_s24le. |
take.streams[].format* | object | |
take.streams[].started | string | |
take.streams[].stopped | string | |
take.streams[].timecode | string | Time-of-day timecode of the first written frame. |
take.streams[].framesWritten* | integer | |
take.streams[].drift | number | Frames × frame duration versus host elapsed, seconds. |
take.streams[].events* | object[] | |
take.streams[].error | string | |
take.markers* | object[] | |
take.markers[].t* | number | Seconds from the cue. |
take.markers[].label* | string | |
take.settings* | object | |
take.settings.codec* | "hevc" | "prores" | |
take.settings.expectedDuration | number | |
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`.
→ {"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 field | type | |
|---|---|---|
id* | string | |
name | string | |
state* | "created" | "recording" | "complete" | "incomplete" | |
reason | string | Present only when incomplete. |
created* | string | |
started | string | |
stopped | string | |
outputRoot* | string | Absolute, as it was at create. |
destination* | string | Take 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* | string | Relative to the take folder. |
streams[].codec* | string | hevc, prores, or pcm_s24le. |
streams[].format* | object | |
streams[].format.video | object | |
streams[].format.audio | object | |
streams[].started | string | |
streams[].stopped | string | |
streams[].timecode | string | Time-of-day timecode of the first written frame. |
streams[].framesWritten* | integer | |
streams[].drift | number | Frames × frame duration versus host elapsed, seconds. |
streams[].events* | object[] | |
streams[].events[].t* | number | |
streams[].events[].type* | "join" | "leave" | |
streams[].error | string | |
markers* | object[] | |
markers[].t* | number | Seconds from the cue. |
markers[].label* | string | |
settings* | object | |
settings.codec* | "hevc" | "prores" | |
settings.expectedDuration | number |
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.
→ {"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 field | type | |
|---|---|---|
id* | string | |
name | string | |
state* | "created" | "recording" | "complete" | "incomplete" | |
reason | string | Present only when incomplete. |
created* | string | |
started | string | |
stopped | string | |
outputRoot* | string | Absolute, as it was at create. |
destination* | string | Take 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* | string | Relative to the take folder. |
streams[].codec* | string | hevc, prores, or pcm_s24le. |
streams[].format* | object | |
streams[].format.video | object | |
streams[].format.audio | object | |
streams[].started | string | |
streams[].stopped | string | |
streams[].timecode | string | Time-of-day timecode of the first written frame. |
streams[].framesWritten* | integer | |
streams[].drift | number | Frames × frame duration versus host elapsed, seconds. |
streams[].events* | object[] | |
streams[].events[].t* | number | |
streams[].events[].type* | "join" | "leave" | |
streams[].error | string | |
markers* | object[] | |
markers[].t* | number | Seconds from the cue. |
markers[].label* | string | |
settings* | object | |
settings.codec* | "hevc" | "prores" | |
settings.expectedDuration | number |
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": "20260912T040433-fd9q", "stream": "window:11597", "path": "window-com.apple.iWork.Keynote.mov", "started": "2026-09-11T14:06:17.021Z", "timecode": "14:06:17:00" }{ "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.
| field | type | |
|---|---|---|
stream* | string | The stream id. |
{ "id": "20260912T040433-fd9q", "stream": "window:11597", "stopped": "2026-09-11T14:08:31.115Z", "framesWritten": 8045 }{ "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.
| 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/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.
→ {"id":6,"method":"GET","path":"/takes/20260912T040433-fd9q"}← {"id":6,"status":200,"body":{"id":"20260912T040433-fd9q","state":"recording"}}| response field | type | |
|---|---|---|
id* | string | |
name | string | |
state* | "created" | "recording" | "complete" | "incomplete" | |
reason | string | Present only when incomplete. |
created* | string | |
started | string | |
stopped | string | |
outputRoot* | string | Absolute, as it was at create. |
destination* | string | Take 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* | string | Relative to the take folder. |
streams[].codec* | string | hevc, prores, or pcm_s24le. |
streams[].format* | object | |
streams[].format.video | object | |
streams[].format.audio | object | |
streams[].started | string | |
streams[].stopped | string | |
streams[].timecode | string | Time-of-day timecode of the first written frame. |
streams[].framesWritten* | integer | |
streams[].drift | number | Frames × frame duration versus host elapsed, seconds. |
streams[].events* | object[] | |
streams[].events[].t* | number | |
streams[].events[].type* | "join" | "leave" | |
streams[].error | string | |
markers* | object[] | |
markers[].t* | number | Seconds from the cue. |
markers[].label* | string | |
settings* | object | |
settings.codec* | "hevc" | "prores" | |
settings.expectedDuration | number |
GET/takespinned
Recent takes, newest first.
- 401
- No token, or the wrong one.
Up to 50 summaries.
→ {"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 field | type |
|---|
Record
POST/recordpinned
Create and start in one call — the popover's Record button.
| field | type | |
|---|---|---|
name | string | |
destination | string | Take folder relative to the output root. Default takes/<yyyy-MM-dd>/<HHmmss>[-<name>]. |
files | object | File name per stream id, relative to the take folder. |
codec | "hevc" | "prores" | |
expectedDuration | number | Seconds, for the disk pre-flight. Default 1800. |
overwrite | boolean | Reuse 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.
→ {"id":9,"method":"POST","path":"/record","body":{"name":"quick"}}← {"id":9,"status":201,"body":{"take":{"id":"20260912T040500-k2pm","state":"recording"},"warnings":[]}}| response field | type | |
|---|---|---|
take* | object | The manifest is the take. Rewritten atomically on every state change. |
take.id* | string | |
take.name | string | |
take.state* | "created" | "recording" | "complete" | "incomplete" | |
take.reason | string | Present only when incomplete. |
take.created* | string | |
take.started | string | |
take.stopped | string | |
take.outputRoot* | string | Absolute, as it was at create. |
take.destination* | string | Take 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* | string | Relative to the take folder. |
take.streams[].codec* | string | hevc, prores, or pcm_s24le. |
take.streams[].format* | object | |
take.streams[].started | string | |
take.streams[].stopped | string | |
take.streams[].timecode | string | Time-of-day timecode of the first written frame. |
take.streams[].framesWritten* | integer | |
take.streams[].drift | number | Frames × frame duration versus host elapsed, seconds. |
take.streams[].events* | object[] | |
take.streams[].error | string | |
take.markers* | object[] | |
take.markers[].t* | number | Seconds from the cue. |
take.markers[].label* | string | |
take.settings* | object | |
take.settings.codec* | "hevc" | "prores" | |
take.settings.expectedDuration | number | |
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.
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/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.
| event | when | data |
|---|---|---|
takepinned | A 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"}}} |
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": "20260912T040433-fd9q", "state": "recording", "at": "2026-09-11T14:02:17.004Z" } |
joinplanned | a stream starts writing | { "take": "20260912T040433-fd9q", "stream": "window:11597", "t": 240.017, "timecode": "14:06:17:00" } |
leaveplanned | a stream’s file is finalised | { "take": "20260912T040433-fd9q", "stream": "window:11597", "t": 374.111, "framesWritten": 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": "20260912T040433-fd9q", "stream": "camera:0x2300000fd9009c", "drift": -0.003, "framesWritten": 21540 } |
markerplanned | a marker lands | { "take": "20260912T040433-fd9q", "t": 38.7, "label": "cold-open out" } |
errorplanned | something fails; the take may now be incomplete | { "take": "20260912T040433-fd9q", "stream": "display:56A96CFC-7F21", "reason": "disk full", "state": "incomplete" } |