Recordings
The Recordings endpoints let you capture everything that happens in a terminal — output, input, and resizes — then list, inspect, delete, and export those captures over TermFlow's local REST API. Exports come in four formats, including asciinema .cast.
All paths below are relative to the API base http://127.0.0.1:42031/api (the default production port). Every route returns JSON unless you export to text or html.
In the default localhost-only mode these endpoints are unauthenticated — safe because the server is bound to 127.0.0.1 and never leaves your machine. A bearer token is enforced only when you turn on Expose on local network in Settings → Connections; then every route (except /health and /api/health, which are always exempt) requires the Authorization: Bearer <token> header, where the token is the authToken shown in Settings → Connections. See API overview & auth for the full model.
Endpoint summary
| Method | Path | Purpose |
|---|---|---|
POST | /api/recordings/start | Begin recording a terminal |
POST | /api/recordings/stop/:id | Stop an active recording |
GET | /api/recordings | List saved recordings |
GET | /api/recordings/:id | Get one recording (full, with events) |
GET | /api/recordings/:id/info | Get metadata + counts, no events |
DELETE | /api/recordings/:id | Delete a recording |
POST | /api/recordings/:id/export | Export as json / text / html / asciinema |
GET | /api/recordings/status/:terminalId | Is a given terminal being recorded? |
GET | /api/recordings/active | List all in-progress recording ids |
Note the two different id parameters: :id is a recording id (a UUID), while :terminalId on the status route is a terminal id (a term_… value).
How recording works
A recording is keyed by its own id, not by the terminal. You start one against a live terminal, events stream into an in-memory buffer, and when you stop it the recording is flushed and persisted to disk as a JSON file. Only then does it appear in GET /api/recordings.
Starting a second recording on a terminal that is already being recorded returns HTTP 409. Stop the first one, or check GET /api/recordings/status/:terminalId before starting.
The recording object
GET /api/recordings/:id returns the full recording — the same shape written to the on-disk file. Its top-level keys are snake_case:
{
"id": "9b1c7e42-3f8a-4d6b-8a21-1e5c9f0b7d33",
"terminal_id": "term_a1b2c3",
"start_time": "2026-07-04T10:15:00Z",
"end_time": "2026-07-04T10:17:32.480Z",
"events": [
{ "event_type": "output", "data": "$ ", "timestamp": 0 },
{ "event_type": "input", "data": "ls\r", "timestamp": 1840 },
{ "event_type": "output", "data": "README.md\r\n", "timestamp": 1902 }
],
"metadata": {
"title": null,
"shell_type": "bash",
"initial_size": { "cols": 80, "rows": 24 }
},
"size": 20544,
"compressed": true
}
| Field | Type | Meaning |
|---|---|---|
id | string (UUID) | The recording's unique id. Use it wherever a :id is required. |
terminal_id | string | The terminal that was recorded. |
start_time | ISO 8601 | When recording began. |
end_time | ISO 8601 | null | When it stopped. null while still active. |
events | array | Ordered event stream (always present on this route). |
metadata | object | Descriptive info (see the metadata table). |
size | integer | Total recorded payload in bytes (sum of event data lengths). |
compressed | boolean | Reflects whether gzip compression was requested (default true). |
Event (events[]) entries:
| Field | Type | Meaning |
|---|---|---|
event_type | string | One of output, input, resize. |
data | string | The payload (terminal bytes for output/input). |
timestamp | integer | Milliseconds since start_time. |
Metadata (metadata) fields:
| Field | Type | Notes |
|---|---|---|
title | string | null | Optional label; null unless set. |
shell_type | string | null | The shell captured from the terminal at start. |
initial_size | { cols, rows } | Terminal dimensions at capture start. |
GET /:id returns the raw on-disk object with snake_case keys (terminal_id, start_time, end_time). The list and info routes return camelCase top-level keys (terminalId, startTime, endTime) instead, but the nested metadata object always keeps its snake_case fields (shell_type, initial_size).
Start a recording
POST /api/recordings/start
Body parameters
| Field | Type | Required | Notes |
|---|---|---|---|
terminal_id | string | yes | The terminal to record. |
options | object | no | Capture options — see below. |
options object:
| Field | Type | Default | Notes |
|---|---|---|---|
include_input | boolean | true | Capture keystrokes / input events. |
include_output | boolean | true | Capture terminal output. |
include_resize | boolean | true | Capture resize events. |
compression | string | gzip | none or gzip. |
auto_stop | boolean | false | Stored on the recording; auto-stop is not yet enforced. |
The options object is parsed as a whole — include every field when you provide it, or omit options entirely to accept the defaults above.
Response — HTTP 201
{
"recordingId": "9b1c7e42-3f8a-4d6b-8a21-1e5c9f0b7d33",
"terminalId": "term_a1b2c3",
"status": "recording"
}
Returns HTTP 404 with {"error": "Terminal not found"} if the terminal id does not exist, and HTTP 409 if that terminal is already being recorded.
Example
curl -X POST http://127.0.0.1:42031/api/recordings/start \
-H "Content-Type: application/json" \
-d '{ "terminal_id": "term_a1b2c3" }'
Stop a recording
POST /api/recordings/stop/:id
:id is the recording id returned by start. Flushes buffered events and persists the file.
Response
{
"recordingId": "9b1c7e42-3f8a-4d6b-8a21-1e5c9f0b7d33",
"status": "stopped",
"size": 20544,
"eventCount": 148
}
Returns HTTP 404 with {"error": "Recording not found or not active"} if the id is unknown or already stopped.
curl -X POST http://127.0.0.1:42031/api/recordings/stop/9b1c7e42-3f8a-4d6b-8a21-1e5c9f0b7d33
List recordings
GET /api/recordings
Returns saved recordings, newest first. Events are omitted for performance — each entry carries an eventCount instead.
This route takes no query parameters and returns every saved recording.
Response
{
"recordings": [
{
"id": "9b1c7e42-3f8a-4d6b-8a21-1e5c9f0b7d33",
"terminalId": "term_a1b2c3",
"startTime": "2026-07-04T10:15:00Z",
"endTime": "2026-07-04T10:17:32.480Z",
"size": 20544,
"eventCount": 148
}
]
}
Each entry carries only id, terminalId, startTime, endTime, size, and eventCount. Use GET /api/recordings/:id/info for compressed, duration, and metadata, or GET /api/recordings/:id for the full event stream.
curl http://127.0.0.1:42031/api/recordings
Get a recording
GET /api/recordings/:id
Returns the complete recording, including the full events array, in the recording-object shape above (snake_case keys). Returns HTTP 404 if the id is unknown.
curl http://127.0.0.1:42031/api/recordings/9b1c7e42-3f8a-4d6b-8a21-1e5c9f0b7d33
Recording info
GET /api/recordings/:id/info
A metadata-and-counts view that never includes events. Ideal when you just need the size, timing, duration, or compressed flag.
Response
{
"id": "9b1c7e42-3f8a-4d6b-8a21-1e5c9f0b7d33",
"terminalId": "term_a1b2c3",
"startTime": "2026-07-04T10:15:00Z",
"endTime": "2026-07-04T10:17:32.480Z",
"eventCount": 148,
"size": 20544,
"compressed": true,
"duration": 152480,
"metadata": { "title": null, "shell_type": "bash", "initial_size": { "cols": 80, "rows": 24 } }
}
duration is milliseconds (endTime − startTime), or null when the recording has no endTime. Note the mixed casing: top-level keys are camelCase, but the nested metadata object keeps its snake_case fields. Returns HTTP 404 for an unknown id.
Delete a recording
DELETE /api/recordings/:id
Removes the recording's JSON file from disk.
Response — HTTP 204 No Content on success, or HTTP 404 if the recording does not exist.
curl -X DELETE http://127.0.0.1:42031/api/recordings/9b1c7e42-3f8a-4d6b-8a21-1e5c9f0b7d33
Export a recording
POST /api/recordings/:id/export
Converts a recording into a downloadable file. The response sets Content-Disposition: attachment with a format-specific filename.
Body parameters
| Field | Type | Required | Notes |
|---|---|---|---|
format | string | yes | json, text, html, or asciinema. |
Any other body fields are currently ignored — metadata and timestamps are handled automatically per format (see below).
Format → output
format | Content-Type | Filename | Contents |
|---|---|---|---|
json | application/json | recording-<id>.json | The full recording object (events + metadata), pretty-printed. |
text | text/plain | recording-<id>.text | Output events only, concatenated, with a #-comment header. |
html | text/html | recording-<id>.html | Self-contained dark-terminal HTML page of the output. |
asciinema | application/json | recording-<id>.cast | asciinema v2 cast (see below). |
An unsupported format returns HTTP 400 with {"error": "Unsupported format"}; an unknown id returns HTTP 404.
Example — export as an asciinema cast
curl -X POST http://127.0.0.1:42031/api/recordings/9b1c7e42-3f8a-4d6b-8a21-1e5c9f0b7d33/export \
-H "Content-Type: application/json" \
-d '{ "format": "asciinema" }' \
-o session.cast
You can then replay it with asciinema play session.cast.
Format details
json— the whole recording object (the same shape asGET /:id), pretty-printed, includingeventsandmetadata.text— onlyoutputevents, in order, concatenated. A#-comment header (id, terminal, start, end, event count) is always prepended.html— a standalone page (inline CSS, monospace, black background) rendering the output events, HTML-escaped, with a metadata block at the top.asciinema— asciinema v2 format: a JSON header line (version 2, width/height frommetadata.initial_size,timestamp, atitle, and anenvwithSHELL/TERM) followed by one[time, "o", data]line per output event. Input and resize events are not included.
Recording status
GET /api/recordings/status/:terminalId
Cheap check for whether a terminal currently has an active recording. :terminalId is a terminal id, not a recording id.
Response
{ "terminalId": "term_a1b2c3", "isRecording": true, "status": "recording" }
status is "recording" or "not_recording".
curl http://127.0.0.1:42031/api/recordings/status/term_a1b2c3
Active recordings
GET /api/recordings/active
Lists the ids of every recording currently in progress across all terminals.
Response
{ "activeRecordings": ["9b1c7e42-3f8a-4d6b-8a21-1e5c9f0b7d33"], "count": 1 }
curl http://127.0.0.1:42031/api/recordings/active
A worked example
# 1. Start
REC=$(curl -s -X POST http://127.0.0.1:42031/api/recordings/start \
-H "Content-Type: application/json" \
-d '{ "terminal_id": "term_a1b2c3" }' | jq -r .recordingId)
# 2. ...do work in the terminal via the execute/input API...
# 3. Stop
curl -s -X POST http://127.0.0.1:42031/api/recordings/stop/$REC
# 4. Export to an asciinema cast
curl -s -X POST http://127.0.0.1:42031/api/recordings/$REC/export \
-H "Content-Type: application/json" \
-d '{ "format": "asciinema" }' -o session.cast
An honest note: a recording only becomes visible to list, get, info, and export after it is stopped and flushed to disk — while active it appears solely in
GET /api/recordings/active. The on-disk file is written as plain JSON; thecompressedflag reflects the requested compression option rather than the file actually being gzipped. Recording is driver-level, independent of the interactive playback viewer in the app.
Next steps
- Record and export a session — a start-to-finish tutorial including playback.
- Session recording — the in-app record button and playback viewer controls.