Skip to main content
The Recordings API enables capturing terminal sessions in asciicast format for playback and sharing.

Recording Architecture

Storage Options

Recordings support three storage backends (priority order):
  1. Cloudflare R2 (Recommended): Global CDN with edge caching
  2. S3-Compatible: Any S3-compatible object storage
  3. PostgreSQL: Database storage (default fallback)

Configuration

R2_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key
R2_SECRET_ACCESS_KEY=your-secret
R2_BUCKET=rexec-recordings
R2_PUBLIC_URL=https://recordings.yourdomain.com

Format: Asciicast v2

Recordings use the asciicast v2 format:
{"version": 2, "width": 120, "height": 30, "timestamp": 1642248000, "duration": 45.2, "title": "Demo"}
[0.123, "o", "$ "]
[1.456, "o", "ls -la\r\n"]
[1.789, "o", "total 24\r\ndrwxr-xr-x..."]
Each line after header: [time_in_seconds, event_type, data]

Start Recording

Begin recording a terminal session.
curl -X POST https://api.rexec.sh/api/recordings/start \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "container_id": "abc123",
    "title": "Production Deploy"
  }'
POST /api/recordings/start

Request

container_id
string
required
Container ID to record (Docker ID or name)
title
string
Recording title (default: “Recording YYYY-MM-DD HH:MM”)

Response

recording_id
string
UUID of the recording
started_at
string
ISO 8601 timestamp
container_id
string
Resolved Docker container ID
message
string
Confirmation message
Response Example
{
  "recording_id": "550e8400-e29b-41d4-a716-446655440000",
  "started_at": "2024-01-15T14:30:00Z",
  "container_id": "abc123def456",
  "message": "Recording started"
}

Error Responses

409 Conflict
{
  "error": "already recording this terminal"
}

Stop Recording

Stop recording and save the session.
curl -X POST https://api.rexec.sh/api/recordings/abc123/stop \
  -H "Authorization: Bearer $TOKEN"
POST /api/recordings/:containerId/stop

Path Parameters

containerId
string
required
Container ID (Docker ID or name)

Response

recording_id
string
UUID of the saved recording
duration_ms
number
Recording duration in milliseconds
duration
string
Human-readable duration (e.g., “2m 35s”)
events_count
number
Total number of recorded events
size_bytes
number
Recording size in bytes
share_token
string
Public share token (22 characters)
storage_type
string
Where recording is stored: “r2”, “s3”, or “database”
cdn_url
string
Public CDN URL (R2/S3 only)
Response Example
{
  "recording_id": "550e8400-e29b-41d4-a716-446655440000",
  "duration_ms": 155000,
  "duration": "2m 35s",
  "events_count": 342,
  "size_bytes": 45312,
  "share_token": "xK9mP2nVqL8sR4tYwZ",
  "message": "Recording saved",
  "storage_type": "r2",
  "cdn_url": "https://recordings.yourdomain.com/550e8400-e29b-41d4-a716-446655440000.cast"
}

Error Responses

404 Not Found
{
  "error": "no active recording for this terminal"
}
403 Forbidden
{
  "error": "not authorized"
}

Get Recording Status

Check if a container is currently being recorded. GET /api/recordings/:containerId/status

Response

{
  "recording": true,
  "recording_id": "550e8400-e29b-41d4-a716-446655440000",
  "started_at": "2024-01-15T14:30:00Z",
  "duration_ms": 65000,
  "events_count": 142
}

List Recordings

Retrieve all recordings for the authenticated user.
curl https://api.rexec.sh/api/recordings \
  -H "Authorization: Bearer $TOKEN"
GET /api/recordings

Response

recordings
array
Array of recording objects
Response Example
{
  "recordings": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "title": "Production Deploy",
      "duration_ms": 155000,
      "duration": "2m 35s",
      "size_bytes": 45312,
      "is_public": false,
      "share_token": "xK9mP2nVqL8sR4tYwZ",
      "share_url": "/r/xK9mP2nVqL8sR4tYwZ",
      "created_at": "2024-01-15T14:30:00Z",
      "storage_type": "r2",
      "cdn_url": "https://recordings.yourdomain.com/550e8400.cast"
    }
  ]
}

Get Recording

Retrieve metadata for a specific recording. GET /api/recordings/:id

Path Parameters

id
string
required
Recording UUID

Authorization

  • Requires authentication if recording is private
  • Public recordings accessible without auth

Response

Same structure as single item in List Recordings.

Get Recording by Token

Retrieve recording metadata using public share token. GET /api/recordings/shared/:token

Path Parameters

token
string
required
Share token (22 characters)

Response

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "title": "Production Deploy",
  "duration_ms": 155000,
  "duration": "2m 35s",
  "created_at": "2024-01-15T14:30:00Z",
  "cdn_url": "https://recordings.yourdomain.com/550e8400.cast"
}

Stream Recording

Download the recording file (asciicast format).
curl https://api.rexec.sh/api/recordings/550e8400/stream \
  -H "Authorization: Bearer $TOKEN" \
  -o recording.cast
GET /api/recordings/:id/stream

Response

  • Content-Type: application/x-asciicast
  • Content-Disposition: attachment; filename="{title}.cast"
  • Cache-Control: public, max-age=31536000, immutable

Behavior by Storage Type

Redirects to global CDN URL:
302 Found
Location: https://recordings.yourdomain.com/550e8400.cast

Stream Recording by Token

Public access to recording file using share token. GET /api/recordings/shared/:token/stream

Path Parameters

token
string
required
Share token

Response

Same as Stream Recording (no authentication required).

Update Recording

Update recording settings (title, visibility).
curl -X PATCH https://api.rexec.sh/api/recordings/550e8400 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "is_public": true,
    "title": "Updated Title"
  }'
PATCH /api/recordings/:id

Request

is_public
boolean
Make recording publicly accessible
title
string
Update recording title

Response

{
  "message": "recording updated"
}

Delete Recording

Permanently delete a recording.
curl -X DELETE https://api.rexec.sh/api/recordings/550e8400 \
  -H "Authorization: Bearer $TOKEN"
DELETE /api/recordings/:id

Behavior

  1. Deletes from external storage (R2/S3) if applicable
  2. Removes database record
  3. Invalidates share token

Response

{
  "message": "recording deleted"
}

Recording Event Capture

The recording system captures three event types:

Output Events

recordingHandler.AddEvent(containerID, "o", outputData, 0, 0)
  • Type: "o"
  • Data: Terminal output bytes (base64 encoded in JSON)
  • When: Every terminal output chunk

Input Events (Optional)

recordingHandler.AddEvent(containerID, "i", inputData, 0, 0)
  • Type: "i"
  • Data: User input
  • When: User types or pastes

Resize Events

recordingHandler.AddEvent(containerID, "r", "", cols, rows)
  • Type: "r"
  • Data: Empty string
  • Cols/Rows: New terminal dimensions
  • When: Terminal window resized

Event Structure

type RecordingEvent struct {
    Time int64  `json:"t"`           // Milliseconds since start
    Type string `json:"e"`           // "o", "i", or "r"
    Data string `json:"d"`           // Event data
    Cols int    `json:"c,omitempty"` // For resize
    Rows int    `json:"r,omitempty"` // For resize
}

Playback Integration

Asciinema Player

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" type="text/css" href="/asciinema-player.css" />
</head>
<body>
  <asciinema-player 
    src="https://api.rexec.sh/api/recordings/550e8400/stream"
    cols="120" 
    rows="30"
    autoplay
    loop
  ></asciinema-player>
  <script src="/asciinema-player.min.js"></script>
</body>
</html>

Best Practices

Performance

  • Use R2/S3 storage for production (offloads server bandwidth)
  • Enable CDN for global playback performance
  • Set appropriate cache headers (1 year for immutable recordings)

Storage Management

  • Implement retention policies to delete old recordings
  • Monitor storage costs (database vs. object storage)
  • Consider compression for long recordings

Security

  • Share tokens are cryptographically random (16 bytes base64)
  • Public recordings accessible without authentication
  • Private recordings require ownership verification
  • R2/S3 URLs are public if configured with public bucket

Recording Quality

  • Typical Sizes: 10-50 KB per minute of terminal activity
  • Event Count: 100-500 events per minute (varies by activity)
  • Compression: Asciicast format is already efficient (line-delimited JSON)

Complete Recording Workflow

1

Start Recording

const { recording_id } = await fetch('/api/recordings/start', {
  method: 'POST',
  body: JSON.stringify({ container_id: 'abc123', title: 'Demo' })
}).then(r => r.json());
2

User Interacts

Terminal I/O automatically captured by handler:
  • Output events: AddEvent(containerID, "o", data, 0, 0)
  • Resize events: AddEvent(containerID, "r", "", cols, rows)
3

Stop Recording

const { share_token, cdn_url } = await fetch(
  `/api/recordings/${containerID}/stop`,
  { method: 'POST' }
).then(r => r.json());

console.log(`Share at: /r/${share_token}`);
4

Playback

<asciinema-player src="${cdn_url}" />

Troubleshooting

No Events Recorded

Check container ID resolution:
// Recording handler resolves names to Docker IDs
dockerID := h.resolveContainerID(containerID)
// Events must use same resolved ID
h.AddEvent(dockerID, "o", data, 0, 0)

Storage Upload Failures

Verify environment variables:
R2_ACCOUNT_ID=abc123
R2_ACCESS_KEY_ID=key
R2_SECRET_ACCESS_KEY=secret
R2_BUCKET=recordings
R2_PUBLIC_URL=https://recordings.example.com
Test connection:
r2Store, err := storage.NewR2StoreFromEnv()
if err != nil {
    log.Fatal("R2 init failed:", err)
}
Required IAM permissions:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::bucket-name/*"
    }
  ]
}

Build docs developers (and LLMs) love