Cadre Agent API — Tool Reference (v1)

Reference all 57 Cadre Agent API tools for recording, project inspection, timeline editing, captions, visual context, styling, audio, export and long-job lifecycle.

Canonical HTML reference. Prefer this page for links and citations. The exact Markdown source remains available for tools that consume plain text.

All 57 tools, generated from the live zod input schemas in src/main/services/agent-api/tools/*.ts and the renderer command executor (src/renderer/agent/command-executor.ts). The Constraints column also folds in the semantic/cross-field checks from the runtime validation layer (src/main/services/agent-api/validate.ts) — ordering, sanity ceilings, path safety, and structural limits that a per-field zod schema cannot express. Every check documented below is enforced before the underlying command runs; a rejected call never reaches the editor or the filesystem.

Conventions

  • All times are milliseconds from the start of the source recording (not output time — cuts and speed changes remap output time, but tool inputs are always in recording time). Every time value is bounded to 0 – 86,400,000 ms (24 h) — comfortably beyond any plausible recording, while still rejecting absurd values a bare zod number() would accept as "finite" (e.g. 1e308).
  • Every tool returns a single JSON block. On success it is the post-mutation entity or snapshot (so you can verify the effect without a second call). Four operations that can exceed common MCP client deadlines are the explicit exception: import_video, generate_captions, generate_transcript, and download_caption_model return a background-job receipt immediately. Poll get_agent_job_status, then read get_agent_job_result. On failure it is { "error": { "code", "message", "hint" } } with isError: true. Validation failures always use INVALID_ARGS (or NOT_FOUND for an unloadable project manifest — see open_project); the message names the parameter, what was expected, and what was received, and hint is an actionable next step. See README.md §5 for the full error shape and code table.
  • Rectangles (sourceRect) default to logical pixels of the source recording. Read recording.width / recording.height from get_timeline for the frame size, or pass "units": "normalized" and give 0–1 fractions instead — see Coordinate spaces.
  • "Bridge-backed" tools mutate live editor state and require a project open in the editor; they return NO_PROJECT_OPEN / EDITOR_NOT_AVAILABLE otherwise.

Timebases

Read this before passing any timestamp to any tool.

There are two clocks in a Cadre project and they are not the same one:

Clock What it measures Where it appears
Recording time Milliseconds into the original capture Every time parameter of every tool in this API, and durations.recordingMs
Output time Milliseconds into the rendered result, with cuts removed and speed segments applied The preview scrubber the user watches, the exported file, and durations.outputMs

They are equal only while the project has no cuts and no speed segments. After that they diverge by the total removed/rescaled duration — and the offset grows with every edit, including the edits you make.

The practical consequence: when a user says "cut 30 to 45 seconds", they are almost always reading the preview clock. If a 10-second cut already exists near the start, the material they mean lives at 40–55 s in recording time. Passing 30000, 45000 straight to add_cut removes fifteen seconds of the wrong footage, confidently and silently.

Rule: convert user-quoted timestamps with map_time (from: "output") before using them, and re-convert after each edit rather than reusing an earlier result. Timestamps you derived from get_timeline (keyframe bounds, cut bounds, caption timings) are already in recording time and need no conversion.

Coordinate spaces

sourceRect accepts either space, selected by an explicit units field:

units Meaning
"pixels" (default) Absolute logical pixels of the source recording. The frame is recording.width × recording.height from get_timeline.
"normalized" Fractions of the frame in [0, 1]. { x: 0.5, y: 0.5, width: 0.5, height: 0.5 } is the bottom-right quadrant at any resolution.

The unit is never inferred from the magnitude of the numbers. { x: 0, y: 0, width: 1, height: 1 } is legal in both spaces — the whole frame normalised, or a single pixel at the origin — so a range heuristic would have to guess, and guessing wrong is a thousandfold error in crop size. Omitting units always means pixels, so every rect written before this field existed keeps its meaning.

Tool index

# Tool Kind Mutates Purpose
1 get_app_state main Orient: version, project, license, export status
2 get_license_status main License/subscription status
3 list_projects main Recent projects (most recent first)
4 open_project main app Open a .screencraft project, navigate to editor
5 save_project bridge disk Persist live edit state to project.json
6 import_video main disk Import a video file as a new project, open the editor
6a list_recording_sources main Displays, windows, iOS devices, mics + macOS permissions
6b get_recording_status main Recording state machine position, elapsed ms, permissions
6c start_recording main capture Start a screen recording (free to try, visible UI)
6d stop_recording main disk Stop, finalise, and return the new project
6e pause_recording main capture Pause a running recording
6f resume_recording main capture Resume into a new segment
6g cancel_recording main deletes Abandon the take and delete its directory
7 get_timeline bridge Full editor snapshot (the primary inspect call)
8 map_time bridge Convert between recording and output time
9 add_zoom bridge timeline Add a manual zoom keyframe
10 update_zoom bridge timeline Update a zoom keyframe by id
11 delete_zoom bridge timeline Delete a zoom keyframe by id
12 recalculate_zooms main timeline Re-run automatic cinematic-zoom analysis
13 add_cut bridge timeline Ripple-delete a time range
14 update_cut bridge timeline Change a cut's bounds
15 delete_cut bridge timeline Delete a cut (restore the range)
16 set_speed bridge timeline Set a speed multiplier over a range (replaces overlaps)
17 delete_speed bridge timeline Delete a speed segment (restore 1×)
18 add_mask bridge timeline Add a time-bound blur/highlight region
19 update_mask bridge timeline Update a mask segment by id
20 delete_mask bridge timeline Delete a mask segment by id
21 add_text_overlay bridge timeline Add an animated text callout
22 add_svg_overlay bridge timeline Add an animated SVG sticker/graphic
23 update_overlay bridge timeline Update an overlay segment by id
24 delete_overlay bridge timeline Delete an overlay segment by id
25 analyze_audio main Loudness envelope + silence ranges, per track
26 get_recording_context main Recording metadata + private timestamped transcript
27 generate_transcript main analysis Transcribe privately without adding captions
28 get_interaction_context main Compact clicks, scrolls, shortcuts, and Accessibility labels
29 analyze_visual_context main analysis Bounded local Apple Vision OCR; zero image tokens
30 get_video_frame main Return exactly one token-bounded WebP frame
31 get_edited_frame main transient UI Return one frame from the live edited preview
32 list_captions bridge List caption segments
33 add_caption bridge timeline Add a caption segment
34 update_caption bridge timeline Update a caption segment
35 delete_caption bridge timeline Delete a caption segment
36 generate_captions main timeline Transcribe with whisper.cpp, attach track
37 set_caption_style bridge timeline Update the caption track's visual style
38 list_caption_models main List whisper models + download state
39 download_caption_model main disk Download a whisper model by id
40 set_style bridge style Shallow-merge one style section
41 set_music bridge style Set or clear background music
42 set_audio_gains bridge style Set system / mic gain
43 undo bridge timeline Undo the last edit
44 redo bridge timeline Redo the last undone edit
45 export_video main disk Render + encode to MP4/MOV (gated on license)
46 get_export_status main Poll export phase/progress/result
47 cancel_export main Cancel a running export
48 get_agent_job_status main Poll a bounded long-running Agent operation
49 get_agent_job_result main Read a terminal job result or structured failure
50 cancel_agent_job main operation Cancel one owned long-running Agent operation

Shared shapes

Referenced by multiple tools.

LicenseStatus

ts
{
  active: boolean;          // true when the user may export (incl. grace window)
  plan: 'free' | 'pro';
  email: string | null;     // null when unlicensed
  expiresAt: number | null; // subscription period end, epoch ms
  inGrace: boolean;         // past expiresAt but inside the offline grace window
  mode: 'stub' | 'live';    // which integration backs this status
}

ExportStatusSnapshot

ts
{
  phase: 'idle' | 'exporting' | 'completed' | 'cancelled' | 'failed';
  progress: { percent: number; eta: number; currentFrame: number;
              totalFrames: number; stage?: string } | null;
  result:   { outputPath: string; fileSize: number; duration: number } | null;
  error: string | null;
}

Background-job receipt and status

The four potentially long operations return this receipt without waiting for FFmpeg, Whisper, or the network:

ts
{
  jobId: string;          // opaque UUID
  jobToken: string;       // secret 256-bit capability; required for poll/cancel
  type: 'import_video' | 'generate_captions' |
        'generate_transcript' | 'download_caption_model';
  status: 'running';
  createdAt: string;      // ISO-8601
  pollAfterMs: 500;
  statusTool: 'get_agent_job_status';
  resultTool: 'get_agent_job_result';
  cancelTool: 'cancel_agent_job';
}

Keep jobId and jobToken together. The token is shown only in the initiating response. Jobs are scoped to the current app boot, run for at most their bounded operation window, retain at most 1 MiB of result data, and expire 15 minutes after settlement. At most four Agent jobs run concurrently, with stricter single-flight slots for import, transcription, and model download. See get_agent_job_status for the status shape.

ZoomKeyframe

ts
{
  id: string;
  startTime: number; endTime: number;                 // ms
  sourceRect: { x: number; y: number; width: number; height: number };
  zoomLevel: number;                                  // 1 = full frame
  trigger: 'click' | 'typing' | 'scroll' | 'manual';
  isUserModified: boolean;
  isUserCreated: boolean;
}

Cut

ts
{ id: string; startTime: number; endTime: number; type: 'remove' }

SpeedSegment

ts
{ id: string; startTime: number; endTime: number;
  speed: number; rampIn: number; rampOut: number }

CaptionSegment

ts
{ id: string; startTime: number; endTime: number;
  text: string; confidence: number; isUserEdited: boolean }

MusicTrack

ts
{ trackId: string; filePath: string; name: string;
  volume: number; fadeInMs: number; fadeOutMs: number;
  loop: boolean; durationMs: number }

MaskSegment

ts
{
  id: string;
  startTime: number; endTime: number;                 // ms
  type: 'blur' | 'highlight';
  rect: { x: number; y: number; width: number; height: number };  // normalised 0-1, source frame
  enabled: boolean;
  blurRadius: number;                                  // px (blur only)
  opacity: number;
}

OverlaySegment

ts
{
  id: string;
  type: 'text' | 'svg';
  startTime: number; endTime: number;                  // ms
  position: { x: number; y: number };                  // normalised centre, OUTPUT frame
  width: number;                                        // fraction of output width
  rotationDeg: number;
  opacity: number;
  text?: string;                                        // type 'text' only
  textStyle?: OverlayTextStyle;                         // type 'text' only
  svg?: string;                                          // type 'svg' only, sanitised markup
  animation: OverlayAnimation;
  keyframes?: OverlayKeyframe[];                        // optional animation track, <= 60
}

OverlayKeyframe

ts
{
  atMs: number;                                         // OFFSET from the overlay's startTime
  position?: { x: number; y: number };                  // normalised centre, OUTPUT frame
  scale?: number;                                        // multiplies the authored width
  rotationDeg?: number;
  opacity?: number;
  easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'ease-out-back' | 'hold';
}

OverlayAnimation

ts
{
  enter: 'none' | 'fade' | 'slide-up' | 'slide-down' | 'slide-left' | 'slide-right' | 'pop' | 'typewriter';
  enterDurationMs: number;
  exit: 'none' | 'fade' | 'slide-up' | 'slide-down' | 'slide-left' | 'slide-right' | 'pop';  // no typewriter on exit
  exitDurationMs: number;
  loop: 'none' | 'pulse' | 'spin' | 'bob';
  loopPeriodMs: number;
}

OverlayTextStyle

ts
{
  fontFamily: string;
  fontSizeFrac: number;      // fraction of output frame height
  fontWeight: number;        // 100-900
  color: string;              // hex, text
  backgroundColor: string;    // hex, pill background
  backgroundOpacity: number;  // 0 removes the pill
  paddingFrac: number;        // fraction of font size
  cornerRadiusFrac: number;   // fraction of font size
  align: 'left' | 'center' | 'right';
}

App & license

get_app_state

High-level Cadre state. Call this first to orient before editing.

Parameters: none.

Returns:

ts
{
  version: string;
  projectOpen: boolean;
  project: { id: string; name: string; path: string } | null;
  license: LicenseStatus;
  export: ExportStatusSnapshot;
}

Errors: none (always succeeds).

Example

jsonc
// request
{ "name": "get_app_state", "arguments": {} }
// result
{
  "version": "<current-app-version>",
  "projectOpen": true,
  "project": { "id": "a1b2...", "name": "Onboarding demo", "path": "/Users/me/Movies/Onboarding demo.screencraft" },
  "license": { "active": true, "plan": "pro", "email": "me@example.com", "expiresAt": 1789000000000, "inGrace": false, "mode": "live" },
  "export": { "phase": "idle", "progress": null, "result": null, "error": null }
}

get_license_status

Current subscription/license status. Export is gated on active: true — check this before export_video.

Parameters: none. Returns: LicenseStatus. Errors: none.

jsonc
// request
{ "name": "get_license_status", "arguments": {} }
// result
{ "active": false, "plan": "free", "email": null, "expiresAt": null, "inGrace": false, "mode": "live" }

Projects

list_projects

Recently modified projects, most recent first.

Param Type Constraints Required Description
limit integer > 0, <= 100 no (default 20) Max projects to return.

Returns: array of

ts
{ id: string; name: string; path: string; modifiedAt: string;
  durationMs: number | null; thumbnailPath: string | null;
  sourceType: 'screen' | 'window' | 'ios' | 'area' | 'import' | null }

Rows whose .screencraft directory no longer exists on disk are pruned and omitted.

Errors: none.

jsonc
// request
{ "name": "list_projects", "arguments": { "limit": 5 } }
// result
[
  { "id": "a1b2...", "name": "Onboarding demo", "path": "/Users/me/Movies/Onboarding demo.screencraft",
    "modifiedAt": "2026-07-19T21:03:11.000Z", "durationMs": 48200, "thumbnailPath": null, "sourceType": "screen" }
]

open_project

Open a project by its .screencraft directory path (use a path from list_projects). Loads the manifest, makes it the active project, and navigates the Cadre UI to the editor.

Param Type Constraints Required Description
path string absolute path, no NUL bytes, no .. segments, filename must not start with -, <= 4,096 characters yes Absolute path to the .screencraft directory.

path is not required to end in .screencraft — the contract names the convention but does not enforce the suffix; the real guard is that project.json inside it must parse as a valid manifest (see the NOT_FOUND error below).

Returns: { id: string; name: string; path: string }.

Errors:

  • INVALID_ARGSpath fails one of the path-safety checks above.
  • NOT_FOUND — no loadable project at path: either nothing is readable there, or project.json does not parse as an object with a string id and name.
jsonc
// request
{ "name": "open_project", "arguments": { "path": "/Users/me/Movies/Onboarding demo.screencraft" } }
// result
{ "id": "a1b2...", "name": "Onboarding demo", "path": "/Users/me/Movies/Onboarding demo.screencraft" }

save_project

Persist the editor's current live edit state to project.json. Run after a batch of edits to make them durable. (Cadre also autosaves ~3 s after any edit, so this is mostly for an explicit checkpoint.)

Parameters: none. Returns: { saved: true; path: string | null }. Errors: NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "save_project", "arguments": {} }
// result
{ "saved": true, "path": "/Users/me/Movies/Onboarding demo.screencraft" }

import_video

Import an existing video file as a new Cadre project — the entry point when a user hands the agent a raw file instead of a Cadre recording. Probes the video, scaffolds a .screencraft project, extracts the audio track, generates thumbnails, and opens it in the editor. Transcoding a non-H.264 source can take a while, so the initiating call returns a background-job receipt immediately instead of holding one MCP request open.

Param Type Constraints Required Description
path string absolute path, no NUL bytes, no .. segments, filename must not start with -, <= 4,096 characters, extension must be one of .mp4/.mov/.webm/.mkv/.avi, must exist and be a regular file yes Absolute path to the video file to import.

Returns immediately: a background-job receipt. Poll get_agent_job_status; once completed, get_agent_job_result contains result: { id: string; name: string; path: string } for the new project.

No interaction log. Unlike a Cadre recording, an imported video has no interactions.jsonl — there was no click/scroll capture — so recalculate_zooms has nothing to analyse for it. Add zoom keyframes manually with add_zoom instead.

Errors:

  • INVALID_ARGSpath fails a path-safety check above, names an unsupported extension, or does not resolve to an existing regular file.
jsonc
// request
{ "name": "import_video", "arguments": { "path": "/Users/me/Downloads/demo-raw.mp4" } }
// immediate result
{ "jobId": "...", "jobToken": "...", "type": "import_video", "status": "running",
  "pollAfterMs": 500, "statusTool": "get_agent_job_status",
  "resultTool": "get_agent_job_result", "cancelTool": "cancel_agent_job" }

// terminal get_agent_job_result (abridged)
{ "jobId": "...", "type": "import_video", "status": "completed",
  "resultAvailable": true,
  "result": { "id": "b7c1...", "name": "demo-raw", "path": "/Users/me/Movies/demo-raw.screencraft" } }

Recording

Seven tools that make new footage. This is the only part of the API with real-world side effects: it turns the user's camera-less screen into a file.

The whole flow, in the order an agent uses it:

text
list_recording_sources            # what can be recorded, and are the grants in place?
start_recording { source }        # returns only once capture is genuinely LIVE
  … sleep for as long as you want to capture …
stop_recording                    # returns { id, name, path } of the new project
get_timeline                      # the editor is already open on it — start editing

Four things to know before you call any of them.

  1. Nothing records silently. start_recording runs the same code path as the app's own Record button: the countdown appears, the display flashes its blue border, the floating recording-controls bar shows up, and non-recorded displays dim. A user is always able to see (and stop) a recording an agent started. This is a product guarantee, not an implementation detail.
  2. Recording and editing are free to try; export is the paywall. This is the same D-059 policy used by the app UI. start_recording does not require a subscription; export_video does.
  3. There is no duration parameter and no auto-stop. You own the clock: sleep, then call stop_recording. The consequence is worth stating plainly — if your process dies mid-recording, the recording keeps running. Cadre's only backstop is the disk monitor, which force-stops the session when free space reaches the critical threshold. If you may not survive to call stop_recording, do not start a recording.
  4. Success means the state machine moved. Every mutating call polls the real session state until it observes the target state, bounded by a timeout, and returns TIMEOUT otherwise. None of these tools reports success off an event it merely emitted.

Recording state machine. idle → countdown → recording ⇄ paused → finishing → completed → idle. pause_recording is legal only from recording, resume_recording only from paused, stop_recording from recording or paused, start_recording only from idle, and cancel_recording from anything except idle. Every other combination returns INVALID_STATE naming the state you are actually in — nothing silently no-ops.

Errors specific to this section (in addition to the shared codes):

Code When
PERMISSION_REQUIRED A macOS TCC grant the capture needs is missing. The hint names the exact System Settings pane, or the parameter to switch off instead.
INVALID_STATE The call is illegal from the current state machine position. The message names the current state; call get_recording_status and branch.
TIMEOUT The operation was issued but the session never reached the expected state inside the bound. The recording may still be live — check get_recording_status before retrying, and cancel_recording to clear a stalled attempt.

Webcam capture is not available to agents. The camera is recorded by a preview window the user opens from the recording toolbar; an agent cannot conjure it, and a config that claims a webcam without one produces a recording whose camera track silently never arrives. start_recording therefore has no webcam parameter and always reports config.webcamEnabled: false. If the user wants a webcam in the take, they start that recording themselves.

list_recording_sources

Everything Cadre can record right now, plus the permission picture. Call this before start_recording and pass back an id from the result rather than guessing one.

Parameters: none.

Returns:

ts
{
  screens: Array<{ type: 'screen'; displayId: number; displayName: string }>;
  windows: Array<{ type: 'window'; windowId: number; appName: string; windowTitle: string }>;
  iosDevices: Array<{ deviceId: string; name: string; model: string; osVersion: string;
                     connectionType: 'usb' | 'wireless'; screenWidth: number; screenHeight: number }>;
  microphones: Array<{ deviceId: string; name: string; kind: 'input'; isDefault: boolean }>;
  permissions: { screenRecording: 'not-determined' | 'granted' | 'denied' | 'restricted' | 'unknown';
                 microphone: 'not-determined' | 'granted' | 'denied' | 'restricted' | 'unknown';
                 accessibility: boolean; inputMonitoring: boolean };
  defaultSource: { type: 'screen'; displayId: number; displayName: string } | null;
  enumerationError: string | null;
}

screens[0] — and therefore defaultSource — is the main display, not whatever the OS enumerated first. That ordering is load-bearing: a QA session once recorded six minutes of an unattended external monitor because the default came from raw enumeration order (F-006).

displayId values are CGDirectDisplayIDs, the id space the capture engine uses. They are not Electron display ids and are not stable across disconnect/reconnect — re-enumerate rather than caching one.

This call never fails on a missing permission. If Screen Recording has not been granted, screens and windows come back empty with enumerationError explaining why and permissions.screenRecording showing the real status — which is more actionable than a rejection. microphones and iosDevices are enumerated independently, so one failing does not blank the others.

Errors: none.

jsonc
// request
{ "name": "list_recording_sources", "arguments": {} }
// result
{
  "screens": [ { "type": "screen", "displayId": 1, "displayName": "Built-in Display" },
               { "type": "screen", "displayId": 2, "displayName": "Display 2" } ],
  "windows": [ { "type": "window", "windowId": 4242, "appName": "Safari", "windowTitle": "Safari — Cadre" } ],
  "iosDevices": [],
  "microphones": [ { "deviceId": "BuiltInMicrophoneDevice", "name": "MacBook Pro Microphone",
                     "kind": "input", "isDefault": true } ],
  "permissions": { "screenRecording": "granted", "microphone": "granted",
                   "accessibility": true, "inputMonitoring": false },
  "defaultSource": { "type": "screen", "displayId": 1, "displayName": "Built-in Display" },
  "enumerationError": null
}

get_recording_status

Where the recording state machine is, right now. Cheap, read-only, and the right thing to call after any TIMEOUT or before deciding what to do next.

Parameters: none.

Returns:

ts
{
  state: 'idle' | 'countdown' | 'recording' | 'paused' | 'finishing' | 'completed';
  isRecording: boolean;      // true for BOTH 'recording' and 'paused' — a paused session still holds a live take
  elapsedMs: number;         // recorded duration, excluding paused spans; 0 when idle
  projectPath: string | null;// the in-progress project directory
  permissions: { screenRecording: string; microphone: string;
                 accessibility: boolean; inputMonitoring: boolean };
  stubCapture?: true;        // test builds only — see below
}

stubCapture appears only when the app is running against no-op native stubs (SCREENCRAFT_ALLOW_STUB_RECORDING=1 with the capture addon absent), which happens in Cadre's own e2e tests. If you ever see it, no pixels are being captured. It is never present in a real build.

Errors: none.

jsonc
// request
{ "name": "get_recording_status", "arguments": {} }
// result
{ "state": "recording", "isRecording": true, "elapsedMs": 12480,
  "projectPath": "/Users/me/Library/Application Support/Cadre/projects/recording-8f2a….screencraft",
  "permissions": { "screenRecording": "granted", "microphone": "granted",
                   "accessibility": true, "inputMonitoring": false } }

start_recording

Start a screen recording. Returns only once the session has genuinely reached recording — through the countdown, native stream setup, and the first encoded frame.

Param Type Constraints Required Description
source object see below no (default: the main display) Capture target.
source.type enum screen | window | area | ios no (default screen) Kind of target.
source.displayId integer must exist in list_recording_sources.screens for screen/area (defaults to the main display) CGDirectDisplayID.
source.windowId integer > 0, must exist in list_recording_sources.windows for window Window id.
source.deviceId string must exist in list_recording_sources.iosDevices for ios iOS device id.
source.rect object {x,y,width,height} logical px; origin >= 0; width/height > 0; each field <= 32,768 for area Region of the display to capture.
fps 30 | 60 no (default 60) Frame rate.
resolution enum native | 1080p | 4k no (default 1080p) Desktop resolution mode. iOS capture always records and reports device-native resolution.
captureRetina boolean no (default true) Capture at the display's backing scale.
audioSource string "all", "none", or an app bundle id; 1–256 chars no (default "all") System audio.
microphoneDeviceId string | null an exact deviceId from a fresh list_recording_sources.microphones result, or "default" no (default null) Mic, dynamic macOS system default, or none.
showCountdown boolean no (default true) Show the on-screen countdown.
countdownSeconds 3 | 5 no (default 3) Countdown length.
trackCursor boolean needs macOS Accessibility no (default true) Record cursor/click interactions. Required for automatic cinematic zoom (recalculate_zooms).
trackKeyboard boolean needs macOS Input Monitoring no (default false) Record shortcuts for the keystroke overlay.

The source is resolved, not trusted. Whatever you pass is matched against live enumeration, and the system's name for the display/window/device is what goes into the recording metadata. An id that no longer exists fails with NOT_FOUND before anything starts, rather than capturing nothing.

Microphone ids are native CoreAudio UIDs and are validated again immediately before capture. Do not fabricate input:<name> ids or cache them across device changes: a device name cannot prove hardware identity, so Cadre will require a fresh selection instead of guessing. Use the literal "default" only when the recording should follow whichever input macOS considers default at start time.

trackCursor defaults on, trackKeyboard defaults off. Cursor data is what automatic zoom is computed from, so it is worth the Accessibility grant. Keyboard tracking only feeds the keystroke overlay, and failing an otherwise fine recording over an Input Monitoring grant nobody asked for is worse than shipping without the overlay — opt in when you want it. Keyboard capture always filters ordinary typing and records only privacy-safe shortcuts; this cannot be disabled through the Agent API.

Returns:

ts
{
  started: true;
  state: 'recording';
  projectPath: string;      // the .screencraft directory being written
  source: CaptureSource;    // the RESOLVED target, with system-reported names
  microphone: {
    status: 'disabled' | 'starting' | 'active' | 'failed';
    requestedDeviceId: string | null;
    message?: string;       // present when native mic admission failed
  };
  config: { fps; resolution; captureRetina; audioSource; microphoneDeviceId;
            trackCursor; trackKeyboard; webcamEnabled: false };
  stubCapture?: true;       // test builds only — no pixels were captured
}

started: true describes the video session. Read microphone.status separately: Cadre deliberately keeps video recording if microphone admission fails, and reports that case as failed with the native message instead of implying narration is active. The tool waits for auxiliary admission to settle for up to five seconds after video reaches recording. A rare starting result means that bound expired: it is deliberately not an active claim; the visible Cadre warning and the finalized recording remain authoritative if admission later fails.

Errors:

  • PERMISSION_REQUIRED — Screen Recording is not granted; a requested microphone lacks Microphone access; trackCursor was requested without Accessibility; or trackKeyboard without Input Monitoring. The hint names the System Settings pane and the parameter you can turn off instead.
  • INVALID_STATE — a recording is already running (get_recording_status, then stop_recording or cancel_recording).
  • NOT_FOUND — the display/window/iOS/microphone id does not exist right now. The hint tells the caller to enumerate again; a microphone is never retargeted by a matching device name.
  • INVALID_ARGS — a required id for the chosen source.type is missing, or the area rect is degenerate/absurd.
  • TIMEOUT — the session never reached recording. It may be stalled mid-startup: check get_recording_status, then cancel_recording.
  • INTERNAL — anything else, including insufficient disk space (Cadre needs ≥ 5 GB of headroom). The original message is preserved.
jsonc
// request
{ "name": "start_recording", "arguments": {
    "source": { "type": "screen", "displayId": 1 },
    "fps": 30, "audioSource": "none", "countdownSeconds": 3 } }
// result
{ "started": true, "state": "recording",
  "projectPath": "/Users/me/Library/Application Support/Cadre/projects/recording-8f2a….screencraft",
  "source": { "type": "screen", "displayId": 1, "displayName": "Built-in Display" },
  "microphone": { "status": "disabled", "requestedDeviceId": null },
  "config": { "fps": 30, "resolution": "1080p", "captureRetina": true, "audioSource": "none",
              "microphoneDeviceId": null, "trackCursor": true, "trackKeyboard": false,
              "webcamEnabled": false } }

stop_recording

Stop the running (or paused) recording and hand back an editable project.

Under the hood: every encoder segment is finalised, its ordered path is written to the metadata and project.json manifest, and the app opens the result in the editor. There is no full-recording consolidation pass on this foreground path, so handoff does not wait for a second pass over the whole take. The call returns after the editor has loaded the project, so the id and path you get back are immediately usable.

Parameters: none.

Returns:

ts
{
  id: string;            // project id — pass to nothing else; the editor is already on it
  name: string;
  path: string;          // the .screencraft directory
  durationMs: number;    // recorded duration, paused spans excluded
  fps: number;
  resolution: { width: number; height: number; physicalWidth: number; physicalHeight: number };
  hasSystemAudio: boolean;
  hasMicrophone: boolean;
  editorOpen: boolean;   // false = saved on disk, but the editor did not open it (call open_project)
}

This is the round trip the recording tools exist for. Once it returns you can go straight to get_timeline and edit — no open_project needed while editorOpen is true.

Errors:

  • INVALID_STATE — nothing is recording, or the session is still counting down (wait, or cancel_recording).
  • INTERNAL — finalization failed. The captured footage is left on disk — the message says where, and list_projects may still show it. Never assume a failed stop means a lost take.
jsonc
// request
{ "name": "stop_recording", "arguments": {} }
// result
{ "id": "c4d8…", "name": "recording-8f2a…",
  "path": "/Users/me/Library/Application Support/Cadre/projects/recording-8f2a….screencraft",
  "durationMs": 30140, "fps": 30,
  "resolution": { "width": 1512, "height": 982, "physicalWidth": 3024, "physicalHeight": 1964 },
  "hasSystemAudio": false, "hasMicrophone": false, "editorOpen": true }

pause_recording

Pause a running recording. Capture stops; resume_recording continues into a new segment, and stop_recording preserves every finalized segment in timeline order for immediate editing. elapsedMs excludes paused spans, so a paused take does not inflate the project duration.

Parameters: none. Returns: the get_recording_status shape, with state: "paused". Errors: INVALID_STATE (not currently recording), TIMEOUT (the pause was issued but the session never reached paused — it may still be running).

resume_recording

Resume a paused recording into a new capture segment.

Parameters: none. Returns: the get_recording_status shape, with state: "recording". Errors: INVALID_STATE (not currently paused), TIMEOUT.

cancel_recording

DESTRUCTIVE. Abandon the in-progress recording and delete its project directory — every frame and audio sample captured so far. There is no undo and nothing goes to the Trash.

Use stop_recording to keep a take you no longer want to extend. Call this only when the user has asked to throw the recording away. It is annotated destructiveHint: true, so a well-behaved MCP client will ask the user before running it.

Parameters: none.

Returns: { cancelled: true; discardedProjectPath: string | null; state: 'idle' }.

Errors: INVALID_STATE (nothing is running), TIMEOUT (the session did not return to idle; it may still be recording), INTERNAL (a stop is already finalising this recording — let it finish).

jsonc
// request
{ "name": "cancel_recording", "arguments": {} }
// result
{ "cancelled": true,
  "discardedProjectPath": "/Users/me/Library/Application Support/Cadre/projects/recording-8f2a….screencraft",
  "state": "idle" }

Worked recipe: record a 30-second demo, then cut it

jsonc
// 1. Orient. Are the grants in place? (A license is needed only at export.)
{ "name": "get_app_state", "arguments": {} }
{ "name": "list_recording_sources", "arguments": {} }
//    → screens[0] is the main display; permissions.screenRecording === "granted"

// 2. Start on the main display. The user sees the countdown and the controls bar.
{ "name": "start_recording", "arguments": {
    "source": { "type": "screen", "displayId": 1 },
    "audioSource": "all", "trackCursor": true } }
//    → { started: true, state: "recording", projectPath: "…" }

// 3. YOU own the clock. Sleep ~30 s in your own runtime — there is no
//    duration parameter and Cadre will not stop itself.
//    (Optionally poll get_recording_status to watch elapsedMs.)

// 4. Stop. This returns the finished, editable project.
{ "name": "stop_recording", "arguments": {} }
//    → { id: "c4d8…", path: "…", durationMs: 30140, editorOpen: true }

// 5. Edit it immediately — the editor is already open on it.
{ "name": "get_timeline", "arguments": {} }
{ "name": "analyze_audio", "arguments": { "track": "systemAudio" } }   // find the dead air
{ "name": "add_cut", "arguments": { "startTime": 0, "endTime": 2400 } } // trim the lead-in
{ "name": "recalculate_zooms", "arguments": {} }                       // cinematic zoom from the click log
{ "name": "save_project", "arguments": {} }
{ "name": "export_video", "arguments": { "outputPath": "/Users/me/Desktop/demo.mp4" } }

If the user wanted the take discarded instead of edited, cancel_recording replaces step 4 — and deletes the footage.


Timeline — inspect

get_timeline

Snapshot of the current editor state — the primary way to perceive the project before editing. Bridge-backed: requires a project open.

Parameters: none.

Returns:

ts
{
  project: { id: string; name: string; path: string | null };
  playhead: number;                       // ms, recording time
  // Media geometry — the frame of reference for every `sourceRect`.
  // `null` when the project carries no usable recording metadata.
  recording: {
    width: number;                        // logical px — the sourceRect space
    height: number;                       // logical px
    physicalWidth: number;                // = width * displayScaleFactor
    physicalHeight: number;
    fps: number;
    displayScaleFactor: number;           // 1 standard, 2 Retina
    sourceType: 'screen' | 'window' | 'ios' | 'area' | 'import' | 'unknown';
  } | null;
  durations: {
    recordingMs: number;                  // source capture length (recording time)
    outputMs: number;                     // rendered length after cuts + speed (output time)
  };
  timeline: {
    zoomKeyframes: ZoomKeyframe[];
    cuts: Cut[];
    speedSegments: SpeedSegment[];
    splitPoints: number[];                // ms marks
    maskSegments: MaskSegment[];
    overlaySegments: OverlaySegment[];
    layoutSegments: unknown[];
    captions: { language: string; modelUsed: string;
                segmentCount: number; segments: CaptionSegment[] } | null;
    musicTrack: MusicTrack | null;
  };
  audio: { systemAudioGain: number; micAudioGain: number };
  style: { background; frame; cursor; keyboard; motion; webcam };  // full config objects
}

Note the caption shape here is the summarised form (segmentCount + segments). list_captions returns the full CaptionTrack (which also carries generatedAt and style).

Errors: NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "get_timeline", "arguments": {} }
// result (abridged)
{
  "project": { "id": "a1b2...", "name": "Onboarding demo", "path": "/Users/me/Movies/Onboarding demo.screencraft" },
  "playhead": 0,
  "recording": { "width": 1440, "height": 900, "physicalWidth": 2880, "physicalHeight": 1800,
                 "fps": 60, "displayScaleFactor": 2, "sourceType": "screen" },
  "durations": { "recordingMs": 48200, "outputMs": 48200 },
  "timeline": { "zoomKeyframes": [], "cuts": [], "speedSegments": [], "splitPoints": [],
                "maskSegments": [], "overlaySegments": [], "layoutSegments": [],
                "captions": null, "musicTrack": null },
  "audio": { "systemAudioGain": 1, "micAudioGain": 1 },
  "style": { "background": { "type": "gradient", "...": "..." }, "...": "..." }
}

recording is null for a project whose manifest has no usable resolution (an import, or a partially written project). Treat that as "spatial reasoning is unavailable" — pass explicit pixel rects, or ask the user — rather than assuming a default resolution. add_zoom will refuse to auto-centre in that state instead of inventing a frame.


map_time

Convert timestamps between the recording and output timebases. Bridge-backed: requires a project open. See Timebases for why this matters.

Parameters:

Name Type Constraints Required Notes
timesMs number[] 1–256 finite entries, each within ±24 h yes Positions to convert, in the from timebase. Pass a one-element array for a single value.
from string "recording" | "output" yes Timebase of the supplied positions. The result is converted to the other one.

Returns:

ts
{
  from: 'recording' | 'output';
  to: 'recording' | 'output';
  durations: { recordingMs: number; outputMs: number };
  results: Array<{
    inputMs: number;      // the value as supplied
    recordingMs: number;
    outputMs: number;
    isCut: boolean;       // see below
    clamped: boolean;     // input was outside the timeline and was clamped
  }>;
}

Fidelity. output → recording → output is exact: every output instant corresponds to exactly one surviving frame. The reverse is not total. A recording time that falls inside a cut has no output instant at all — it collapses onto the cut's boundary — and is flagged isCut: true. An edit anchored to such a position lands at the boundary, so treat isCut as a signal to reconsider rather than a detail to ignore.

Positions outside the timeline are clamped rather than rejected, and reported with clamped: true, so probing for the end of the timeline returns an answer.

Errors: INVALID_ARGS (empty/oversized batch, non-finite entry, unknown from, or a project with no recording duration), NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// The user says "cut 30 to 45 seconds", reading the preview clock.
// A 10 s cut already exists at the start of the recording.
// request
{ "name": "map_time", "arguments": { "timesMs": [30000, 45000], "from": "output" } }
// result
{
  "from": "output", "to": "recording",
  "durations": { "recordingMs": 60000, "outputMs": 50000 },
  "results": [
    { "inputMs": 30000, "recordingMs": 40000, "outputMs": 30000, "isCut": false, "clamped": false },
    { "inputMs": 45000, "recordingMs": 55000, "outputMs": 45000, "isCut": false, "clamped": false }
  ]
}
// -> call add_cut with 40000 / 55000, NOT 30000 / 45000.

Timeline — zoom

add_zoom

Add a manual zoom keyframe over a time range. Returns the created keyframe.

Times are in recording time — see Timebases. Prefer a normalized focusPoint: Cadre derives a bounds-clamped crop with the source's aspect ratio at exactly zoomLevel. sourceRect remains available when the caller intentionally needs a literal crop.

Param Type Constraints Required Description
startTime number 0 – 86,400,000 (24 h) yes Zoom start (ms, recording time).
endTime number > 0, <= 86,400,000 (24 h), must exceed startTime yes Zoom end (ms, recording time).
zoomLevel number >= 1, <= 8 yes Magnification (1 = full frame, 2 = 2×).
focusPoint object { x, y, units? }; units default to "normalized", where x/y are 0–1. no Preferred semantic target. Cadre builds a source-aspect crop at zoomLevel and clamps it to the frame. Mutually exclusive with sourceRect.
sourceRect object { x, y, width>0, height>0, units? } — see Coordinate spaces. "pixels": each value within ±1,000,000. "normalized": each value in 0–1, and x+width/y+height each <= 1. no Advanced literal crop. Its aspect should match the recording and its dimensions determine the effective crop. Mutually exclusive with focusPoint. Omit both fields to centre.

Returns: ZoomKeyframe (trigger: 'manual', isUserCreated: true) plus compositionReview: { required: true, timeMs, instruction }. The review time is the midpoint of the zoom. Call get_edited_frame at that exact time before save/export and reject any crop whose boundary crosses recognised text, a card, button, or another UI control. When the target cannot be isolated without fragments, leave the shot wide.

Errors: INVALID_ARGS (e.g. endTime <= startTime), NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "add_zoom", "arguments": {
    "startTime": 300, "endTime": 3700, "zoomLevel": 1.6,
    "focusPoint": { "x": 0.72, "y": 0.58 } } }
// result
{ "id": "kf-9f2c...", "startTime": 300, "endTime": 3700,
  "sourceRect": { "x": 587, "y": 281, "width": 900, "height": 562.5 },
  "zoomLevel": 1.6, "trigger": "manual", "isUserModified": false, "isUserCreated": true,
  "compositionReview": { "required": true, "timeMs": 2000,
    "instruction": "Before save or export, call get_edited_frame ..." } }

Behaviour change. Omitting sourceRect now produces a frame-centred window of 1 / zoomLevel of the source in each axis, matching what the app's own "add zoom" button computes. It previously substituted { x: 0, y: 0, width: 1, height: 1 } — read downstream as absolute pixels, i.e. a 1×1 pixel crop at the top-left corner, not the "auto-centre" this page described. Centring needs the frame size, so on a project whose recording is null the call now fails with INVALID_ARGS rather than silently producing that crop; pass an explicit pixel rect in that case.

update_zoom

Update fields of an existing zoom keyframe by id.

Param Type Constraints Required Description
id string non-empty yes Zoom keyframe id.
updates object at least one recognised field (below) yes Fields to change (below).
updates.startTime number 0 – 86,400,000 (24 h) no New start (ms).
updates.endTime number 0 – 86,400,000 (24 h) no New end (ms).
updates.zoomLevel number >= 1, <= 8 no New magnification.
updates.sourceRect object { x, y, width>0, height>0, units? } — same rules as add_zoom; see Coordinate spaces. no New region.

If both updates.startTime and updates.endTime are given, updates.endTime must exceed updates.startTime. A one-sided update (e.g. only endTime) is not cross-checked against the keyframe's stored counterpart at this boundary — an update that inverts the range against the existing value is caught downstream, not here.

An updates object with no recognised field (e.g. a misspelled key — zod silently strips unknown keys before this check runs) is rejected rather than accepted as a no-op.

Returns: the updated ZoomKeyframe. Errors: NOT_FOUND (unknown id), INVALID_ARGS, NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "update_zoom", "arguments": { "id": "kf-9f2c...", "updates": { "zoomLevel": 2.5 } } }
// result
{ "id": "kf-9f2c...", "startTime": 300, "endTime": 3700, "sourceRect": { "...": "..." },
  "zoomLevel": 2.5, "trigger": "manual", "isUserModified": true, "isUserCreated": true }

delete_zoom

Delete a zoom keyframe by id.

Param Type Constraints Required Description
id string non-empty yes Zoom keyframe id.

Returns: { id: string; deleted: true }. Errors: NOT_FOUND, NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "delete_zoom", "arguments": { "id": "kf-9f2c..." } }
// result
{ "id": "kf-9f2c...", "deleted": true }

recalculate_zooms

Re-run Cadre's automatic cinematic-zoom analysis over the recording's interaction log (clicks and scrolls) and replace the auto-generated zoom keyframes with the result. Manual keyframes an agent or the user created (isUserCreated: true) are preserved by the editor's merge — this only touches the automatic ones.

Cadre never records plain keystrokes, so typing is not a signal the analysis can see or a prompt can target — clicks and scrolls are the whole input.

Parameters: none.

Returns: { keyframes: ZoomKeyframe[] } on success, or { keyframes: []; reason: string } when the project has no interaction log to analyse.

Imported videos. Only projects recorded in Cadre carry an interactions.jsonl. A project opened via import_video has none, so this call returns { keyframes: [], reason: "..." } explaining that honestly instead of guessing or failing — add zoom keyframes manually with add_zoom for those. Verify the result with get_timeline afterwards either way.

Errors: NO_PROJECT_OPEN.

jsonc
// request — a Cadre recording with interaction data
{ "name": "recalculate_zooms", "arguments": {} }
// result
{ "keyframes": [ { "id": "kf-...", "startTime": 1200, "endTime": 3400, "...": "...", "trigger": "click", "isUserCreated": false } ] }

// request — a project opened via import_video
{ "name": "recalculate_zooms", "arguments": {} }
// result
{ "keyframes": [], "reason": "This project has no interaction log (recording/interactions.jsonl) — imported videos never do — so auto-zoom has no click/scroll data to work from. Add zooms manually with add_zoom instead." }

Timeline — cuts

add_cut

Ripple-delete a time range from the output. Returns the resulting cut.

Times are in recording time — see Timebases. This is the tool most often given the wrong clock: a user quoting "cut 30 to 45 seconds" is reading the preview, so run those numbers through map_time (from: "output") first.

Param Type Constraints Required Description
startTime number 0 – 86,400,000 (24 h) yes Cut start (ms, recording time).
endTime number > 0, <= 86,400,000 (24 h), must exceed startTime yes Cut end (ms, recording time).

Merge semantics. A range that overlaps or abuts an existing cut is merged into it, so the returned cut may be wider than the range you asked for and may carry the older cut's id. This mirrors what the renderer already does — it coalesces overlapping cuts when building the output — so the cut list can never disagree with what is actually removed.

Adding a cut shortens durations.outputMs and shifts every later output timestamp earlier. Re-run map_time after this call rather than reusing conversions made before it.

Returns: Cut. Errors: INVALID_ARGS, NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "add_cut", "arguments": { "startTime": 1000, "endTime": 2500 } }
// result
{ "id": "c-71a0...", "startTime": 1000, "endTime": 2500, "type": "remove" }

update_cut

Change the bounds of an existing cut by id.

Param Type Constraints Required Description
id string non-empty yes Cut id.
updates object at least one recognised field yes Fields to change.
updates.startTime number 0 – 86,400,000 (24 h) no New start (ms).
updates.endTime number 0 – 86,400,000 (24 h), > 0 no New end (ms).

If both bounds are given, endTime must exceed startTime. An updates object with no recognised field is rejected rather than accepted as a no-op.

Returns: the updated Cut. Errors: NOT_FOUND, INVALID_ARGS, NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "update_cut", "arguments": { "id": "c-71a0...", "updates": { "endTime": 2500 } } }
// result
{ "id": "c-71a0...", "startTime": 1000, "endTime": 2500, "type": "remove" }

delete_cut

Delete a cut by id (restores that range to the output).

Param Type Constraints Required Description
id string non-empty yes Cut id.

Returns: { id: string; deleted: true }. Errors: NOT_FOUND, NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "delete_cut", "arguments": { "id": "c-71a0..." } }
// result
{ "id": "c-71a0...", "deleted": true }

Timeline — speed

set_speed

Set a playback-speed multiplier over a time range (e.g. 2.0 to speed up, 0.5 to slow down). Returns the created speed segment.

Times are in recording time — see Timebases.

Param Type Constraints Required Description
startTime number 0 – 86,400,000 (24 h) yes Segment start (ms, recording time).
endTime number > 0, <= 86,400,000 (24 h), must exceed startTime yes Segment end (ms, recording time).
speed number >= 0.25, <= 4 yes Speed multiplier.

Replacement semantics. set_speed replaces any speed already covering the range rather than layering on top of it:

Existing segment vs [startTime, endTime) Result
Disjoint (touching at a boundary counts as disjoint) Untouched
Fully inside the new range Removed
Overlaps one edge Trimmed back to its non-overlapping part
Strictly contains the new range Split into a before-part and an after-part

So the call is idempotent, and get_timeline's speedSegments always describes the real timeline. Changing speed changes durations.outputMs, so re-run map_time afterwards rather than reusing earlier conversions.

Returns: SpeedSegment (with default rampIn/rampOut). Errors: INVALID_ARGS, NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "set_speed", "arguments": { "startTime": 8000, "endTime": 14000, "speed": 2 } }
// result
{ "id": "sp-3d9e...", "startTime": 8000, "endTime": 14000, "speed": 2, "rampIn": 300, "rampOut": 300 }

delete_speed

Delete a speed segment by id, restoring that range to 1×. Get ids from get_timeline (timeline.speedSegments).

Param Type Constraints Required Description
id string non-empty yes Speed segment id.

Returns: { id: string; deleted: true }. Errors: NOT_FOUND, INVALID_ARGS, NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "delete_speed", "arguments": { "id": "sp-3d9e..." } }
// result
{ "id": "sp-3d9e...", "deleted": true }

Corrected guidance. This page previously said there was no delete_speed tool and told agents to "re-set_speed the range to 1.0" to remove a speed-up. Under the old push-only set_speed that advice was wrong: it stacked a second overlapping segment on the first and left the outcome to a last-wins scan of an unsorted array. delete_speed is now the correct way to undo a set_speed. Re-setting the range to 1.0 also works today — it replaces the old segment — but leaves an explicit 1× segment behind.


Masks

Time-bound regions over the source frame, in normalised 0–1 coordinates — always normalised, unlike sourceRect on the zoom tools, which can be pixels or normalised depending on a units field. Two effects:

  • blur — hides sensitive content (credentials, tokens, notifications, faces) inside the region for the duration of the mask.
  • highlight — the inverse: dims everything except the region, to spotlight it.

add_mask

Add a time-bound mask over a region of the video. Returns the created segment.

Times are in recording time — see Timebases.

Param Type Constraints Required Description
startTime number 0 – 86,400,000 (24 h) yes Mask start (ms, recording time).
endTime number > 0, <= 86,400,000 (24 h), must exceed startTime yes Mask end (ms, recording time).
type string enum "blur" | "highlight" no (default "blur") Effect type.
rect object { x: 0–1; y: 0–1; width: 0(excl)–1; height: 0(excl)–1 }, and x+width <= 1, y+height <= 1 (checked with a tiny floating-point tolerance) yes Region in normalised 0-1 coordinates of the source frame.
blurRadius number 1–200 no (default 20) Blur strength in pixels (blur only).
opacity number 0–1 no (default 1) Effect opacity.

Returns: MaskSegment (enabled: true). Errors: INVALID_ARGS (e.g. endTime <= startTime, rect out of bounds or not fitting the frame), NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request — blur a notification banner in the top-right for 4s
{ "name": "add_mask", "arguments": {
    "startTime": 12000, "endTime": 16000, "type": "blur",
    "rect": { "x": 0.7, "y": 0.05, "width": 0.28, "height": 0.12 } } }
// result
{ "id": "mask-4a1b...", "startTime": 12000, "endTime": 16000, "type": "blur",
  "rect": { "x": 0.7, "y": 0.05, "width": 0.28, "height": 0.12 },
  "enabled": true, "blurRadius": 20, "opacity": 1 }

update_mask

Update a mask segment by id (timing, rect, type, blurRadius, opacity, enabled). Get ids from get_timeline (timeline.maskSegments).

Param Type Constraints Required Description
id string non-empty yes Mask segment id.
updates object at least one recognised field (below) yes Fields to change.
updates.startTime number 0 – 86,400,000 (24 h) no New start (ms).
updates.endTime number 0 – 86,400,000 (24 h), > 0 no New end (ms).
updates.type string enum "blur" | "highlight" no New effect type.
updates.rect object same shape/bounds as add_mask's rect no New region.
updates.blurRadius number 1–200 no New blur strength.
updates.opacity number 0–1 no New effect opacity.
updates.enabled boolean no Enable/disable without deleting.

If both bounds are given, endTime must exceed startTime. An updates object with no recognised field is rejected rather than accepted as a no-op.

Returns: the updated MaskSegment. Errors: NOT_FOUND, INVALID_ARGS, NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "update_mask", "arguments": { "id": "mask-4a1b...", "updates": { "enabled": false } } }
// result
{ "id": "mask-4a1b...", "startTime": 12000, "endTime": 16000, "type": "blur",
  "rect": { "x": 0.7, "y": 0.05, "width": 0.28, "height": 0.12 },
  "enabled": false, "blurRadius": 20, "opacity": 1 }

delete_mask

Delete a mask segment by id.

Param Type Constraints Required Description
id string non-empty yes Mask segment id.

Returns: { id: string; deleted: true }. Errors: NOT_FOUND, NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "delete_mask", "arguments": { "id": "mask-4a1b..." } }
// result
{ "id": "mask-4a1b...", "deleted": true }

Overlays

Animated text callouts and SVG stickers, drawn on the OUTPUT frame like captions — they do not pan or zoom with the content underneath, and position/width are normalised to the output frame, not the source. Two kinds, both time-bound and both returning an OverlaySegment:

  • add_text_overlay — a title, label, or annotation.
  • add_svg_overlay — an arrow, badge, logo, or shape, from raw SVG markup.

Both share position, width, rotationDeg, opacity, an animation spec (entrance, exit, continuous loop), and an optional keyframes track for motion the presets do not cover. Text overlays additionally take a textStyle.

Instead of a position you may pass an anchor — "beside the camera", "top-right of frame" — which is resolved to coordinates during the call. The two are mutually exclusive.

add_text_overlay

Add an animated text callout over the video.

Times are in recording time — see Timebases.

Param Type Constraints Required Description
startTime number 0 – 86,400,000 (24 h) yes Overlay start (ms, recording time).
endTime number > 0, <= 86,400,000 (24 h), must exceed startTime yes Overlay end (ms, recording time).
text string non-empty, <= 500 characters; \n for manual line breaks yes The text to display.
position object { x: 0–1; y: 0–1 } — normalised centre of the OUTPUT frame no (default { x: 0.5, y: 0.16 }) {x:0.5,y:0.5} is dead centre; {x:0.85,y:0.15} top-right. Rejected together with anchor.
anchor object see Anchors no Named placement resolved to a position at call time. Mutually exclusive with position.
width number 0 (excl)–1 no (default 0.58) Wrap width as a fraction of output width. Text wraps to fit.
rotationDeg number -360–360 no (default 0) Static rotation in degrees.
opacity number 0–1 no (default 1) Overall opacity.
textStyle object see OverlayTextStyle fields, each independently bounded no Omitted fields keep their defaults.
animation object see Animation fields below no Entrance/exit/loop.
keyframes array <= 60 OverlayKeyframe entries — see Keyframes below no Per-property animation track; times are offsets from startTime.

Returns: the created OverlaySegment (type: "text"). Verify it, then get_timeline shows it under timeline.overlaySegments.

Errors: INVALID_ARGS (e.g. endTime <= startTime, empty/oversized text, an out-of-range style or animation field), NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request — a title card that types itself in, top-center
{ "name": "add_text_overlay", "arguments": {
    "startTime": 0, "endTime": 2500, "text": "Deploying to production",
    "position": { "x": 0.5, "y": 0.12 }, "width": 0.7,
    "animation": { "enter": "typewriter", "enterDurationMs": 1800, "exit": "fade" } } }
// result
{ "id": "ov-7d2e...", "type": "text", "startTime": 0, "endTime": 2500,
  "position": { "x": 0.5, "y": 0.12 }, "width": 0.7, "rotationDeg": 0, "opacity": 1,
  "text": "Deploying to production",
  "textStyle": { "fontFamily": "-apple-system, BlinkMacSystemFont, SF Pro Display, Helvetica Neue, sans-serif",
                 "fontSizeFrac": 0.048, "fontWeight": 600,
                 "color": "#F5F5F7", "backgroundColor": "#1D1D1F", "backgroundOpacity": 0.78,
                 "paddingFrac": 0.42, "cornerRadiusFrac": 0.5, "align": "center" },
  "animation": { "enter": "typewriter", "enterDurationMs": 1800, "exit": "fade",
                 "exitDurationMs": 220, "loop": "none", "loopPeriodMs": 2000 } }

add_svg_overlay

Add an animated SVG sticker/graphic over the video.

Times are in recording time — see Timebases.

Param Type Constraints Required Description
startTime number 0 – 86,400,000 (24 h) yes Overlay start (ms, recording time).
endTime number > 0, <= 86,400,000 (24 h), must exceed startTime yes Overlay end (ms, recording time).
svg string non-empty, <= 200,000 characters; standalone <svg> markup, viewBox recommended yes Sanitised on the way in — scripts, event handlers, and external references (<image href="http://..."> etc.) are stripped.
position object { x: 0–1; y: 0–1 } no (default { x: 0.86, y: 0.16 }) Normalised centre of the OUTPUT frame. Rejected together with anchor.
anchor object see Anchors no Named placement resolved to a position at call time. Mutually exclusive with position.
width number 0 (excl)–1 no (default 0.14) Rendered width as a fraction of output width; height follows the viewBox aspect ratio.
rotationDeg number -360–360 no (default 0) Static rotation in degrees.
opacity number 0–1 no (default 1) Overall opacity.
animation object see Animation fields below no (default restrained fade in 280ms, fade out in 220ms, no loop) Entrance/exit/loop.
keyframes array <= 60 OverlayKeyframe entries — see Keyframes below no Per-property animation track; times are offsets from startTime.

Sanitisation. The markup must parse as a standalone <svg> document and survive sanitisation with at least one drawable element remaining — an empty shell, a non-SVG string, or markup that is only scripts/forbidden elements is rejected before it reaches the timeline. This runs twice (main process, then again in the renderer as defence in depth), so a call that returns INVALID_ARGS never partially applies.

Returns: the created OverlaySegment (type: "svg", svg holding the sanitised markup, which may differ from what you sent).

Errors: INVALID_ARGSendTime <= startTime; or the SVG is empty, over 200,000 characters, not a standalone <svg>…</svg> document, or contains nothing but stripped content after sanitisation.

jsonc
// request — an arrow sticker pointing at a button, top-right
{ "name": "add_svg_overlay", "arguments": {
    "startTime": 4000, "endTime": 7000,
    "svg": "<svg viewBox=\"0 0 100 100\"><path d=\"M10 50 L80 50 L60 20 M80 50 L60 80\" stroke=\"#FF3B30\" stroke-width=\"8\" fill=\"none\"/></svg>",
    "position": { "x": 0.82, "y": 0.22 }, "width": 0.12 } }
// result
{ "id": "ov-9f0a...", "type": "svg", "startTime": 4000, "endTime": 7000,
  "position": { "x": 0.82, "y": 0.22 }, "width": 0.12, "rotationDeg": 0, "opacity": 1,
  "svg": "<svg viewBox=\"0 0 100 100\">...</svg>",
  "animation": { "enter": "fade", "enterDurationMs": 280, "exit": "fade",
                 "exitDurationMs": 220, "loop": "none", "loopPeriodMs": 2000 } }

update_overlay

Update an overlay segment by id (timing, content, position, size, rotation, opacity, textStyle, animation). Get ids from get_timeline (timeline.overlaySegments).

Nested objects merge field-by-field{ "textStyle": { "color": "#FF0000" } } changes only the colour and leaves every other textStyle/animation field as it was. This differs from set_style, whose nested objects must be sent whole.

keyframes is the exception: it replaces the whole track. There is no per-item patch language — send the track you want, or [] to clear it. A track is one value that only reads end-to-end, so merging halves of two animations would produce motion neither caller asked for.

Param Type Constraints Required Description
id string non-empty yes Overlay segment id.
updates object at least one recognised field (below) yes Fields to change.
updates.startTime number 0 – 86,400,000 (24 h) no New start (ms).
updates.endTime number 0 – 86,400,000 (24 h), > 0 no New end (ms).
updates.text string non-empty, <= 500 characters no text-type overlays only — rejected on an svg overlay.
updates.svg string non-empty, <= 200,000 characters, sanitised no svg-type overlays only — rejected on a text overlay.
updates.position object { x: 0–1; y: 0–1 } no New centre.
updates.width number 0 (excl)–1 no New width fraction.
updates.rotationDeg number -360–360 no New rotation.
updates.opacity number 0–1 no New opacity.
updates.textStyle object partial OverlayTextStyle, merged field-by-field no Only the fields you pass change.
updates.animation object partial OverlayAnimation, merged field-by-field no Only the fields you pass change.
updates.keyframes array <= 60 OverlayKeyframe entries — see Keyframes no Replaces the whole track; [] clears it. Clamped against the merged time range.

If both bounds are given, endTime must exceed startTime. An updates object with no recognised field is rejected rather than accepted as a no-op. Setting text on an svg overlay (or svg on a text overlay) is rejected — overlay content cannot change kind after creation.

Returns: { overlay, previous }overlay is the updated OverlaySegment, previous is the full segment as it was before this call. Send previous straight back as updates to restore it exactly. That is the intended undo for agent edits: it is precise about which overlay it reverts, and it leaves the global undo stack — which you share with whatever the user is doing in the editor — alone.

Errors: NOT_FOUND, INVALID_ARGS (out-of-range field, cross-type content change, or a rejected SVG), NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request — only the pill colour changes; every other textStyle field is untouched
{ "name": "update_overlay", "arguments": {
    "id": "ov-7d2e...", "updates": { "textStyle": { "backgroundColor": "#1D4ED8" } } } }
// result (abridged) — `previous` carries the pre-call state, ready to send back
{ "overlay": { "id": "ov-7d2e...", "type": "text", "...": "...",
               "textStyle": { "...": "...", "backgroundColor": "#1D4ED8", "backgroundOpacity": 0.78 } },
  "previous": { "id": "ov-7d2e...", "type": "text", "...": "...",
                "textStyle": { "...": "...", "backgroundColor": "#1D1D1F", "backgroundOpacity": 0.78 } } }

delete_overlay

Delete an overlay segment by id.

Param Type Constraints Required Description
id string non-empty yes Overlay segment id.

Returns: { overlay: { id: string; deleted: true }; previous: OverlaySegment }previous is the segment that was deleted, in full. Re-create it with add_text_overlay / add_svg_overlay if the deletion was a mistake; it comes back with a new id, and unlike undo it reverts nothing the user did in between.

Errors: NOT_FOUND, NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

Animation fields

Shared by add_text_overlay, add_svg_overlay, and update_overlay's updates.animation. See OverlayAnimation for the return shape.

Field Type Constraints Notes
enter string enum none|fade|slide-up|slide-down|slide-left|slide-right|pop|typewriter typewriter (text only) reveals characters progressively.
enterDurationMs number 0–60,000 Default 280.
exit string enum none|fade|slide-up|slide-down|slide-left|slide-right|pop No typewriter — it is an entrance-only effect.
exitDurationMs number 0–60,000 Default 220.
loop string enum none|pulse|spin|bob Continuous effect while on screen: gentle scale pulse, full rotation, or vertical bob.
loopPeriodMs number 100–60,000 Default 2000.

Keyframes

Shared by add_text_overlay, add_svg_overlay, and update_overlay's updates.keyframes. Use these when the entrance/exit/loop presets above cannot express the motion — a sticker that flies in and settles, a label that drifts, a badge that snaps between two spots.

Field Type Constraints Notes
atMs number >= 0, <= 86,400,000 Offset from this overlay's startTime, not recording time. Clamped to the segment duration server-side.
position object { x: 0–1; y: 0–1 } Normalised centre of the OUTPUT frame at this instant.
scale number 0.05–10 Multiplies the authored width (1 = authored size).
rotationDeg number -360–360 Absolute rotation at this instant.
opacity number 0–1 Absolute opacity at this instant.
easing string enum linear|ease-in|ease-out|ease-in-out|ease-out-back|hold Easing into this keyframe from the previous one claiming the same property. Default linear.

Times are offsets, so moving the block moves the animation with it. Drag the overlay later on the timeline (or change startTime) and the motion rides along unchanged.

A keyframe claims only the properties it sets. position, scale, rotationDeg and opacity are independent tracks derived from the one list, so a track of two position keyframes animates position and leaves opacity on the segment's static value. Before the first and after the last keyframe of a property, that keyframe's value holds.

The later keyframe's easing governs its span. hold steps instead of interpolating: the earlier value stays put until the later keyframe's time. ease-out-back overshoots slightly before settling.

Entrance/exit/loop still compose on top — opacity multiplies, offsets add, scale multiplies. Keyframes replace only the base pose, so a keyframed overlay with enter: "fade" still fades in.

The track is sorted by atMs server-side, so you may send it in any order. At most 60 keyframes; more is rejected with INVALID_ARGS.

jsonc
// request — a badge that drops in from above and settles with a slight overshoot
{ "name": "add_svg_overlay", "arguments": {
    "startTime": 3000, "endTime": 6000,
    "svg": "<svg viewBox=\"0 0 100 100\"><circle cx=\"50\" cy=\"50\" r=\"40\" fill=\"#0A84FF\"/></svg>",
    "position": { "x": 0.5, "y": 0.42 }, "width": 0.1,
    "keyframes": [
      { "atMs": 0,   "position": { "x": 0.5, "y": 0.15 } },
      { "atMs": 600, "position": { "x": 0.5, "y": 0.42 }, "easing": "ease-out-back" } ] } }

Anchors

Shared by add_text_overlay and add_svg_overlay. Pass anchor instead of position — sending both is INVALID_ARGS, because two answers to "where does this go" is a caller confusion, not a precedence rule worth memorising.

Field Type Constraints Notes
target string enum webcam|screen-region What the overlay is placed against.
region string enum top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right Required for screen-region; rejected for webcam.
placement string enum left-of|right-of|above|below Required for webcam; rejected for screen-region.
marginFrac number 0–0.2 Gap from the frame edge / from the camera, applied per axis. Default 0.03.

Resolved once, at the call. The anchor becomes a plain position on the stored segment; the project file contains coordinates and nothing else. An overlay anchored to the camera therefore does not follow the camera if the user moves it afterwards — re-place it if you want the new geometry.

The exact mapping. Coordinates are normalised to the output frame (x right, y down) and an overlay's position is its centre. Writing m for marginFrac, hx for half the overlay's width (width / 2) and hy for half its height as a fraction of frame height:

  • hy for an SVG is exact: width × frameAspect ÷ viewBoxAspect ÷ 2.
  • hy for text is estimated from the pill geometry: fontSizeFrac × (1.25 × lines + 2 × paddingFrac) ÷ 2, where lines counts explicit \n breaks only — automatic word wrapping is not simulated, so a long wrapping string anchored to bottom sits slightly higher than it should. Pass an explicit position when you need it to the pixel.

target: "screen-region" maps the nine regions onto the frame:

x y
top-left / left / bottom-left m + hx
top / center / bottom 0.5
top-right / right / bottom-right 1 - m - hx
top-* m + hy
left / center / right 0.5
bottom-* 1 - m - hy

So { "region": "top-left" } puts the overlay's top-left corner one margin in from the frame's top-left corner, not its centre.

target: "webcam" places the overlay one margin clear of the named side of the camera rectangle, centred on the camera's other axis:

placement centre
left-of x = camLeft - m - hx, y = camMidY
right-of x = camRight + m + hx, y = camMidY
above x = camMidX, y = camTop - m - hy
below x = camMidX, y = camBottom + m + hy

The camera rectangle is the one the current style configures — position grid, size, offsetX/offsetY — read at call time from get_app_state's style.webcam. Under Camera Only the camera is the content, so the rectangle is the content rect it fills. INVALID_ARGS when the webcam is disabled or the recording has no camera track: there is no rectangle to anchor to, and guessing one would put the overlay somewhere the user never sees a camera.

Nothing lands off-frame. After the mapping the centre is clamped so the whole overlay fits with at least m / 2 of margin around it. An overlay too large to fit on an axis is centred on that axis rather than silently resized.

jsonc
// request — a label beside the camera, wherever the camera currently is
{ "name": "add_text_overlay", "arguments": {
    "startTime": 8000, "endTime": 12000, "text": "Ask me anything",
    "anchor": { "target": "webcam", "placement": "left-of" }, "width": 0.3 } }
// result (abridged) — the anchor is gone; only the coordinates it produced remain
{ "id": "ov-3c81...", "type": "text", "position": { "x": 0.606, "y": 0.836 },
  "width": 0.3, "...": "..." }

Audio

analyze_audio

Measure the open project's audio: an RMS loudness envelope in fixed buckets and detected silence ranges, per track (system audio, microphone) plus a combined view. Use this — not generate_captions — to find dead air; it takes seconds instead of minutes and it sees non-speech audio (typing, music, UI sounds) that a transcript cannot.

All times in the result are recording time, the same timebase every mutating tool takes, so combined.silenceRanges can be fed straight into add_cut with no conversion (only user-quoted preview timestamps need map_time).

Param Type Constraints Required Description
silenceThresholdDb number -90–0 no (default -40) Loudness at or below which audio counts as silent (dBFS). Raise toward -30 for a noisy room; lower toward -50 to keep faint audio as content.
minSilenceDurationMs number 0–60,000 no (default 700) Shortest silence worth reporting. Below ~700ms you are reporting natural speech rhythm, not dead air.
bucketMs number 10–5,000 no (default 100) Envelope bucket width; widened automatically for long recordings. Silence ranges are unaffected by this.
includeEnvelope boolean no (default true) Set false when you only need silence ranges and want a smaller response.

Returns:

ts
{
  hasAudio: boolean;               // false = no audio track at all, not "no silence found"
  reason?: 'NO_AUDIO_TRACK';       // present + explanatory only when hasAudio is false
  message?: string;
  recordingDurationMs: number | null;
  settings: { silenceThresholdDb: number; minSilenceDurationMs: number;
              requestedBucketMs: number; bucketMs: number };  // bucketMs may be wider than requested
  timebase: 'recording';
  tracks: Array<{
    track: 'system' | 'mic';
    filePath: string;
    durationMs: number;
    bucketMs: number;
    envelopeDb?: number[];         // omitted when includeEnvelope is false
    silenceRanges: Array<{ startMs: number; endMs: number; durationMs: number }>;
    silentMs: number;
    activeRatio: number;           // fraction NOT silent, 0-1
    peakDb: number | null;
    warnings: string[];
  }>;
  combined: {                      // null when hasAudio is false — never an empty silence list
    tracks: Array<'system' | 'mic'>;
    bucketMs: number;
    durationMs: number;
    envelopeDb?: number[];
    silenceRanges: Array<{ startMs: number; endMs: number; durationMs: number }>;  // silent on EVERY track
    silentMs: number;
    activeRatio: number;
    leadingSilenceMs: number;
    trailingSilenceMs: number;
  } | null;
  warnings: string[];
}

combined.silenceRanges is the safe default for trimming: a moment counts as dead air only when every track is silent, so a narrator pause over a playing video is correctly not treated as silence. combined.leadingSilenceMs / trailingSilenceMs answer "trim the dead air at the start and end" directly. If hasAudio is false, combined is null and reason/message explain why — do not trim against audio in that case, and do not read it as "no silence found".

Errors: NO_PROJECT_OPEN, INTERNAL (audio analysis failed, e.g. no decodable audio stream in a corrupt file).

jsonc
// request
{ "name": "analyze_audio", "arguments": { "includeEnvelope": false } }
// result (abridged)
{ "hasAudio": true, "recordingDurationMs": 48200, "timebase": "recording",
  "settings": { "silenceThresholdDb": -40, "minSilenceDurationMs": 700, "requestedBucketMs": 100, "bucketMs": 100 },
  "tracks": [
    { "track": "system", "durationMs": 48200, "bucketMs": 100, "silenceRanges": [], "silentMs": 0, "activeRatio": 0.91, "peakDb": -6.2, "warnings": [] },
    { "track": "mic", "durationMs": 48200, "bucketMs": 100, "silenceRanges": [ { "startMs": 4200, "endMs": 5100, "durationMs": 900 } ], "silentMs": 900, "activeRatio": 0.74, "peakDb": -3.1, "warnings": [] }
  ],
  "combined": { "tracks": ["system", "mic"], "bucketMs": 100, "durationMs": 48200,
                "silenceRanges": [], "silentMs": 0, "activeRatio": 0.94,
                "leadingSilenceMs": 320, "trailingSilenceMs": 890 },
  "warnings": [] }

Speech context

get_recording_context

Read the metadata and compact, time-aligned speech context an agent needs to understand the recording. This is a read-only perception tool: it never adds a caption track and never changes preview or export output.

Param Type Constraints Required Description
startTime number 0 – 86,400,000 no Beginning of a transcript window, in recording-time ms. Defaults to 0.
endTime number 0 – 86,400,000, must exceed startTime when both are supplied no End of the window. Defaults to the recording duration.

Returns:

ts
{
  project: { id: string; name: string };
  recording: {
    startedAt: string | null; durationMs: number; fps: number;
    width: number | null; height: number | null; sourceType: string;
    hasSystemAudio: boolean; hasMicrophone: boolean;
  };
  transcript: {
    available: boolean;
    source: 'agent_transcript' | 'caption_track' | null;
    language: string | null; modelUsed: string | null; generatedAt: string | null;
    timebase: 'recording';
    totalSegmentCount: number; returnedSegmentCount: number;
    range: { startTime: number; endTime: number };
    segments: Array<{ startTime: number; endTime: number; text: string; confidence?: number }>;
    hint?: string;
  };
}

The stored private transcript is preferred. If none exists but the project has a visible caption track, that track is compacted and returned with source: "caption_track". If neither exists, available is false and the result points to generate_transcript. Use a time window for long recordings when only one passage matters; phrase-level compaction avoids the token overhead of Whisper's word-level caption representation.

jsonc
{ "name": "get_recording_context", "arguments": { "startTime": 60000, "endTime": 90000 } }

generate_transcript

Run the local whisper.cpp pipeline over the open project's audio, compact the result into timestamped phrases, and atomically save it to analysis/transcript.json. This is intentionally separate from generate_captions: it does not attach any rendered caption layer or create an undoable edit. The initiating call returns a background-job receipt immediately so local Whisper inference cannot exceed the MCP client's call deadline.

Param Type Constraints Required Description
modelSize string bare model id, <= 64 characters yes Downloaded Whisper model id such as base or small.
language string BCP-47 tag shape no Language hint; omit for auto-detection.

Returns immediately: a background-job receipt. A completed get_agent_job_result contains result with the same shape as get_recording_context and the complete new transcript. If that result exceeds the 1 MiB retention bound, the job still reports completed with a compact resultOmitted.summary; call get_recording_context with a time window to read the saved transcript.

Errors: NO_PROJECT_OPEN, MODEL_NOT_DOWNLOADED, BUSY, INTERNAL, or INVALID_ARGS. Model management is shared with caption generation; use list_caption_models and download_caption_model first.

jsonc
{ "name": "generate_transcript", "arguments": { "modelSize": "base", "language": "en" } }
// immediate result: { "jobId": "...", "jobToken": "...", "status": "running", ... }

Visual context

The visual ladder is text-first. Start with speech, then interactions, then local OCR. Request a frame only when those compact signals are insufficient. All timestamps use immutable recording time.

get_interaction_context

Returns compact click, scroll and shortcut moments. Cadre never records plain keystrokes — they are dropped at capture, so no typed text exists to return and typing is not a signal you can search for or act on. Only privacy-safe shortcut combinations are logged; a typing moment kind appears only when one group repeats a shortcut or exceeds the five-label cap. Cadre also discards raw cursor samples. New recordings may also provide the app name, accessible role/title/description, and normalized control bounds captured on click-down; text-field values are never read.

Param Type Constraints Required Description
startTime number 0 – 86,400,000 no Window start; defaults to 0.
endTime number must exceed startTime no Window end; defaults to recording duration.
maxMoments integer 1–50, default 24 no Hard response budget.

analyze_visual_context

Ranks moments using the interaction summary, visual changes measured on the already-generated 160 px timeline thumbnail strip, and uniform coverage. Only the selected frames are extracted at 960 px and sent to Apple Vision .fast OCR. Processing is local, lazy, cached, and uses no image-model tokens.

Param Type Constraints Required Description
startTime number 0 – 86,400,000 no Window start; narrow from the transcript for long media.
endTime number must exceed startTime no Window end.
maxMoments integer 1–12, default 8 no Maximum local OCR frames.

The result reports frameBudget, framesAnalyzed, cacheHits, imageTokensUsed: 0, and moments containing OCR text, confidence, normalized rectangles, interaction evidence, and OCR status. Obvious credential-like OCR strings are redacted before persistence or return.

get_video_frame

Returns one text metadata block followed by exactly one MCP image block. Use it only after text context is not enough: the connected model may charge visual tokens and the frame may contain sensitive screen content.

Param Type Constraints Required Description
timeMs number 0 – 86,400,000 yes Recording-time frame position.
maxDimension 256 | 512 | 768 default 512 no Hard WebP width/height limit.
crop normalized rect inside [0,1], positive size no Optional source crop before resizing.

The metadata includes source/output dimensions, exact time, and normalized crop mapping. The normal whole-edit target is zero image calls, with three as the documented ceiling unless the user explicitly requests visual comparison.

get_edited_frame

Returns one text metadata block followed by exactly one JPEG image block from the live Cadre preview. Unlike get_video_frame, this includes the full edited composition: zoom/crop, background, device frame, cursor, webcam layout, masks, captions, and overlays. Use it after edits to verify the result, not to understand the untouched source.

Param Type Constraints Required Description
timeMs number inside the open recording yes Recording-time preview position.
maxDimension 256 | 512 | 768 default 512 no Hard JPEG width/height limit.

Cadre temporarily moves the live editor playhead, waits for the newly decoded frame to render, captures only the preview bounds, hides editor-only mask handles, and restores the original playhead. The same privacy and token caution as get_video_frame applies; use it sparingly.


Captions

list_captions

List the current caption segments.

Parameters: none.

Returns: the full CaptionTrack or null if no captions exist yet:

ts
{ language: string; modelUsed: string; generatedAt: string;
  segments: CaptionSegment[];
  style: { fontFamily; fontSize; fontWeight; color; backgroundColor;
           backgroundOpacity; position; marginBottom } } | null

Errors: NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "list_captions", "arguments": {} }
// result
{ "language": "en", "modelUsed": "manual", "generatedAt": "2026-07-19T21:10:00.000Z",
  "segments": [ { "id": "cap-1", "startTime": 1000, "endTime": 2000,
                  "text": "Let's get started.", "confidence": 1, "isUserEdited": true } ],
  "style": { "position": "bottom", "...": "..." } }

add_caption

Add a caption segment over a time range. Returns the created segment.

Param Type Constraints Required Description
startTime number 0 – 86,400,000 (24 h) yes Caption start (ms).
endTime number > 0, <= 86,400,000 (24 h), must exceed startTime yes Caption end (ms).
text string non-whitespace, <= 5,000 characters yes Caption text.

Returns: CaptionSegment (isUserEdited: true, confidence: 1). Errors: INVALID_ARGS, NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "add_caption", "arguments": { "startTime": 1000, "endTime": 2000, "text": "Let's get started." } }
// result
{ "id": "cap-1", "startTime": 1000, "endTime": 2000, "text": "Let's get started.", "confidence": 1, "isUserEdited": true }

update_caption

Update a caption segment by id (text and/or timing).

Param Type Constraints Required Description
id string non-empty yes Caption segment id.
updates object at least one recognised field yes Fields to change.
updates.text string non-whitespace, <= 5,000 characters (when supplied) no New text.
updates.startTime number 0 – 86,400,000 (24 h) no New start (ms).
updates.endTime number 0 – 86,400,000 (24 h), > 0 no New end (ms).

If both bounds are given, endTime must exceed startTime. An updates object with no recognised field is rejected rather than accepted as a no-op.

Returns: the updated CaptionSegment. Errors: NOT_FOUND, INVALID_ARGS, NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "update_caption", "arguments": { "id": "cap-1", "updates": { "text": "Let's dive in." } } }
// result
{ "id": "cap-1", "startTime": 1000, "endTime": 2000, "text": "Let's dive in.", "confidence": 1, "isUserEdited": true }

delete_caption

Delete a caption segment by id.

Param Type Constraints Required Description
id string non-empty yes Caption segment id.

Returns: { id: string; deleted: true }. Errors: NOT_FOUND, NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

generate_captions

Transcribe the open project with whisper.cpp and attach the caption track. The call returns a background-job receipt immediately; local inference continues under the bounded job registry. The model must already be downloaded in Cadre — use list_caption_models and download_caption_model if it isn't.

Param Type Constraints Required Description
modelSize string bare identifier: letters/digits/./_/-, must start with a letter or digit, <= 64 characters yes Whisper model id/size (e.g. "base", "small"). Must be downloaded.
language string BCP-47 tag shape, e.g. en or pt-BR ([A-Za-z]{2,8}(-[A-Za-z0-9]{2,8})*) no BCP-47 language hint (e.g. "en"). Omit to auto-detect.

Returns immediately: a background-job receipt. A completed get_agent_job_result contains the generated caption track (segments + metadata) in result. If an unusually large track exceeds the 1 MiB retention bound, resultOmitted.summary reports the segment count; call list_captions to inspect the attached track.

Errors:

  • NO_PROJECT_OPEN — no project to transcribe.
  • MODEL_NOT_DOWNLOADEDmodelSize is not downloaded locally. The hint points at list_caption_models / download_caption_model; this replaces what used to be a bare INTERNAL failure.
  • BUSY — a caption generation is already running (the caption:generate IPC handler and this tool share one guard, so an agent and the user cannot start two transcriptions that fight over the same temp audio file). Wait for it to finish and retry.
  • INTERNAL — the whisper.cpp pipeline itself failed (audio extraction or transcription error).
jsonc
// request
{ "name": "generate_captions", "arguments": { "modelSize": "base", "language": "en" } }
// immediate result — poll with the returned pair
{ "jobId": "...", "jobToken": "...", "type": "generate_captions", "status": "running", "pollAfterMs": 500, "statusTool": "get_agent_job_status", "resultTool": "get_agent_job_result", "cancelTool": "cancel_agent_job" }

// request — model not downloaded
{ "name": "generate_captions", "arguments": { "modelSize": "medium" } }
// immediate start succeeds; the terminal job result reports status:"failed"
// with error.code:"MODEL_NOT_DOWNLOADED".

set_caption_style

Update the caption track's visual style (font, size, colours, position). Requires an existing caption track — call generate_captions or add_caption first.

Param Type Constraints Required Description
updates object at least one recognised field (below) yes Style fields to change — only the ones you pass are touched.
updates.fontFamily string non-empty no Font family name (defaults to the native macOS/system stack).
updates.fontSize number 8–200 no Font size in reference pixels (default 34).
updates.fontWeight number 100–900 no Font weight.
updates.color string hex — #RGB, #RRGGBB, or #RRGGBBAA no Text colour.
updates.backgroundColor string hex no Rounded caption material colour.
updates.backgroundOpacity number 0–1 no Caption material opacity.
updates.position string enum "bottom" | "top" no Vertical placement.
updates.marginBottom number 0–1000 no Bottom margin in pixels.

Returns: the resulting CaptionStyle:

ts
{ fontFamily: string; fontSize: number; fontWeight: number;
  color: string; backgroundColor: string; backgroundOpacity: number;
  position: 'bottom' | 'top'; marginBottom: number }

Errors:

  • NOT_FOUND — no caption track exists yet ("No caption track exists yet — generate or add captions before styling them.").
  • INVALID_ARGSupdates has no recognised field, or a field is out of range / not a valid hex colour.
  • NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.
jsonc
// request — bigger text for a small-screen export
{ "name": "set_caption_style", "arguments": { "updates": { "fontSize": 52, "fontWeight": 800 } } }
// result
{ "fontFamily": "-apple-system, BlinkMacSystemFont, SF Pro Display, Helvetica Neue, sans-serif",
  "fontSize": 52, "fontWeight": 800, "color": "#A1A1A6",
  "backgroundColor": "#1D1D1F", "backgroundOpacity": 0.72, "position": "bottom", "marginBottom": 48 }

list_caption_models

List the whisper.cpp models Cadre can transcribe with. Use before generate_captions to pick a modelSize that exists — or to decide whether to download_caption_model first.

Parameters: none.

Returns: array of

ts
{ id: string; name: string; sizeMB: number; isDownloaded: boolean }

Catalogue as of this build: tiny (75 MB), base (142 MB), small (466 MB), medium (1536 MB).

Errors: none.

jsonc
// request
{ "name": "list_caption_models", "arguments": {} }
// result
[
  { "id": "tiny", "name": "Tiny (Fastest)", "sizeMB": 75, "isDownloaded": true },
  { "id": "base", "name": "Base (Fast)", "sizeMB": 142, "isDownloaded": true },
  { "id": "small", "name": "Small (Balanced)", "sizeMB": 466, "isDownloaded": false },
  { "id": "medium", "name": "Medium (Accurate)", "sizeMB": 1536, "isDownloaded": false }
]

download_caption_model

Download a whisper.cpp model by id. The larger models are hundreds of MB to over a gigabyte, so the call returns a background-job receipt immediately. Already-downloaded models complete without re-downloading.

Param Type Constraints Required Description
modelId string bare identifier: letters/digits/./_/-, must start with a letter or digit, <= 64 characters yes Model id to download — see list_caption_models for the known ids (tiny, base, small, medium).

Returns immediately: a background-job receipt. A completed get_agent_job_result contains the refreshed model list in result, with the same shape as list_caption_models.

Errors:

  • NOT_FOUNDmodelId is not one of the known catalogue ids; the hint lists the valid ones.
  • INVALID_ARGSmodelId fails the bare-identifier shape check.
  • INTERNAL — the download itself failed (network error, integrity check failure).
jsonc
// request
{ "name": "download_caption_model", "arguments": { "modelId": "small" } }
// immediate result: { "jobId": "...", "jobToken": "...", "status": "running", ... }
// completed get_agent_job_result.result is the refreshed model array.

Style, music & audio

set_style

Shallow-merge updates into one style section. Nested objects must be passed whole (e.g. to change a shadow, send the entire shadow object, not a single shadow field).

For an open-ended polish request, Cadre's shipped baseline is a quiet #F5F5F7#DDE7F4 gradient, 72px frame padding, 18px corners, a soft low-opacity shadow, minimal keyboard treatment, and blue used only as an accent. Preserve deliberate existing styling and let explicit user direction override this baseline.

Param Type Constraints Required Description
section string enum one of background, frame, cursor, keyboard, motion, webcam yes Which section to update.
updates object plain object, non-empty; see structural limits below yes Partial fields for the section (shallow-merged).

updates is validated in two passes. Both run before the update crosses into the editor, so a rejected call changes nothing.

1. Structural — applied at every level of nesting:

  • Must be a plain object (not an array, null, or a primitive), and non-empty.
  • No key may be __proto__, constructor, or prototype, anywhere in the object — including nested objects.
  • Nesting depth is capped at 6 levels.
  • Any single object level is capped at 64 keys; any array is capped at 64 entries.
  • String leaf values are capped at 5,000 characters.
  • Numeric leaf values must be finite (no NaN/Infinity).

2. Field types — every key is checked against the target section's schema:

  • Unknown field names are rejected, not ignored. The error lists the valid fields for that section, so a typo is correctable rather than a silent no-op.
  • Numbers must be numbers within the field's range (a numeric string like "12" is rejected), enums must be one of the listed values, booleans must be booleans.
  • Colours must be hex — #RGB, #RRGGBB, or #RRGGBBAA. Named CSS colours ("red") and rgb(...) are rejected: the compositor parses the hex digits directly and anything else renders as a transparent or black draw.
  • Nested objects must be complete. The merge is one level deep, so a nested object replaces its predecessor entirely — sending {"shadow":{"enabled": false}} would drop the other shadow fields. Fields that are optional on the type (e.g. gradient.angle, the spring params of zoomAnimation) may be omitted; all others are required. The error names what is missing.

A violation of either pass is INVALID_ARGS, naming the offending path (e.g. updates.shadow.color), what was expected, and what was received.

Returns: { section: string; config: <full updated section object> }. Errors: INVALID_ARGS (unknown section, unknown field, wrong type, out-of-range number, bad enum or colour, incomplete nested object, or a structural limit above), NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

Section fields

Ranges are inclusive. Where the underlying type documents a range (cursor scale, webcam size, the 0–1 intensities) that range is used verbatim, so the API accepts exactly what the Style panel can express; other bounds are sanity ceilings. See src/shared/types/style.types.ts for the full types.

background

Field Type
type enum solid | gradient | mesh | image | transparent
color hex colour
gradient object { type: 'linear'|'radial'; angle?: -360–360; colors: GradientStop[2–32] }
meshGradient object { points: MeshGradientPoint[1–32] }
imagePath string, 1–4,096 chars
imageDisplayMode enum fill | fit | tile
blur number 0–200

GradientStop is { stop: 0–1; color: hex }; MeshGradientPoint is { x: 0–1; y: 0–1; color: hex }.

frame

Field Type
padding object { top/right/bottom/left: 0–2000; linked: boolean }
cornerRadius number 0–200
shadow object { enabled: boolean; color: hex; blurRadius: 0–500; spreadRadius: -200–500; offsetX/offsetY: -500–500 }
inset object { enabled: boolean; color: hex; width: 0–100; cornerRadius: 0–200 }
deviceFrame null, or { device: DeviceFrameType; color: 'silver'|'space-black'|'midnight'|'starlight' }

DeviceFrameType is one of macbook-pro-16, macbook-air-15, imac-24, iphone-16-pro, iphone-16, ipad-pro-13, browser-chrome, browser-safari, browser-arc.

cursor

Field Type
style enum default | pointer | dot | outline | filled
alwaysUsePointer, returnToStart, hidden, removeShakes boolean
scale number 0.5–3
smoothing object { enabled: boolean; tension: 1–1000; friction: 1–200; mass: 0.1–100 }
autoHide object { enabled: boolean; delayMs: 0–600000; fadeDurationMs: 0–10000 }
clickEffect object { enabled: boolean; color: hex; size: 0–500; durationMs: 0–10000 }
rotation object { enabled: boolean; amount: 0–1 }
stopAtEnd object { enabled: boolean; marginSec: 0–3600 }

keyboard

Field Type
enabled, showSingleKeys boolean
position enum bottom-center | bottom-left | bottom-right | top-center
size enum small | medium | large
style enum minimal | rounded | pill

motion

Field Type
motionBlur object { enabled: boolean; amount: 0–1; appliesTo: ('cursor'|'zoom'|'pan')[0–3] }
zoomAnimation object { type: 'spring'|'bezier'|'linear'; tension?: 1–1000; friction?: 1–200; mass?: 0.1–100; controlPoints?: [4 numbers, -10–10] }

webcam

Field Type
enabled, dynamicLayout boolean
cameraOnly boolean — the camera fills the frame and the screen track is hidden; position/size/roundness/border and zoom are not applied while it is on
deviceId null, or string 1–512 chars
shape enum circle | rounded-square
position enum — {top,middle,bottom}-{left,center-left,center,center-right,right} (15 values)
size number 0.1–0.5 (fraction of canvas width)
roundness number 0–1
aspectRatio enum square | horizontal | vertical | original
borderWidth number 0–100
borderColor hex colour
offsetX, offsetY number -1000–1000
enhancement object { mirrorEnabled: boolean; autoBrightness: boolean; softFocus: 0–1 }
jsonc
// request
{ "name": "set_style", "arguments": { "section": "background", "updates": {
    "type": "gradient", "gradient": { "type": "linear", "angle": 135, "colors": [
      { "stop": 0, "color": "#F5F5F7" }, { "stop": 1, "color": "#DDE7F4" }
    ] } } } }
// result
{ "section": "background", "config": { "type": "gradient", "gradient": { "...": "..." } } }

set_music

Set or clear the background music track. Pass filePath: null to remove music.

Param Type Constraints Required Description
filePath string | null absolute path, no NUL bytes, no .. segments, filename must not start with -, <= 4,096 characters, must have a file extension yes Absolute path to an audio file, or null to clear music.
name string non-whitespace, <= 5,000 characters (when filePath is set) no Display name (defaults to the file's basename).
volume number 01 no Volume (default 0.18, deliberately quiet under narration).
fadeInMs number 0 – 600,000 (10 min), when filePath is set no Fade-in (default 1500).
fadeOutMs number 0 – 600,000 (10 min), when filePath is set no Fade-out (default 1500).
loop boolean no Loop if shorter than the recording (default true).

The path checks above (and the name/fadeInMs/fadeOutMs checks) only run when filePath is non-null — set_music({ filePath: null }) clears music unconditionally and ignores the other fields.

Real duration. When filePath is set, the tool probes it with ffprobe before returning — durationMs on the result is the track's actual length, not a placeholder. A path that ffprobe cannot read as audio (wrong format, corrupt file, a non-audio file with an audio-like extension) is rejected with INVALID_ARGS rather than silently stored with durationMs: 0, which used to render as a zero-width region on the music timeline and gave the loop math nothing to work with.

Returns: the resulting MusicTrack, or null when cleared. Errors: INVALID_ARGS (bad path, name, or fade duration; or filePath names a file ffprobe cannot read an audio duration from), NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "set_music", "arguments": { "filePath": "/Users/me/Music/bed.mp3", "volume": 0.25 } }
// result
{ "trackId": "b6...", "filePath": "/Users/me/Music/bed.mp3", "name": "bed.mp3",
  "volume": 0.25, "fadeInMs": 2000, "fadeOutMs": 2000, "loop": true, "durationMs": 187340 }

// request — a file ffprobe can't decode
{ "name": "set_music", "arguments": { "filePath": "/Users/me/Music/corrupt.mp3" } }
// result
{ "error": { "code": "INVALID_ARGS",
             "message": "set_music: 'filePath' is not a readable audio file (ffprobe found no audio duration in /Users/me/Music/corrupt.mp3).",
             "hint": "Pass an absolute path to a decodable audio file (.mp3/.m4a/.wav/.aac)." } }

set_audio_gains

Set system and/or microphone gain multipliers. At least one must be provided.

Param Type Constraints Required Description
systemGain number 04 no* System audio gain.
micGain number 04 no* Microphone gain.

*At least one of systemGain / micGain is required.

Returns: { systemAudioGain: number; micAudioGain: number } (the resulting gains — note the field names are the store's *AudioGain form). Errors: INVALID_ARGS (neither provided), NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "set_audio_gains", "arguments": { "micGain": 1.4 } }
// result
{ "systemAudioGain": 1, "micAudioGain": 1.4 }

History

undo

Undo the last undoable edit. Agent and human edits share one undo history.

Parameters: none. Returns: { canUndo: boolean; canRedo: boolean }. Errors: NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.

jsonc
// request
{ "name": "undo", "arguments": {} }
// result
{ "canUndo": false, "canRedo": true }

redo

Redo the last undone edit.

Parameters: none. Returns: { canUndo: boolean; canRedo: boolean }. Errors: NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.


Background jobs

These three lifecycle tools make long imports, Whisper runs, and model downloads reliable with MCP clients that impose a 60-second call deadline. The registry is local to one Cadre app boot: active work is aborted and all job secrets disappear when the app exits or rotates its bearer token.

get_agent_job_status

Poll a job without returning its potentially larger result.

Param Type Constraints Required Description
jobId string UUID returned by a long-running tool yes Opaque job identifier.
jobToken string 32–128 characters; use the exact returned value yes Per-job secret capability.

Returns:

ts
{
  jobId: string;
  type: 'import_video' | 'generate_captions' |
        'generate_transcript' | 'download_caption_model';
  status: 'running' | 'cancelling' | 'completed' | 'failed' | 'cancelled';
  createdAt: string;
  finishedAt?: string;
  projectId?: string;     // project captured when transcription began
  progress?: unknown;     // bounded stage/percent/byte snapshot when available
  error?: { code: string; message: string; hint?: string };
  resultAvailable: boolean;
  resultOmitted?: {
    reason: 'size_limit'; bytes: number; maxBytes: number; summary?: unknown;
  };
}

failed is a terminal operation failure and carries the same structured error shape as other tools. resultAvailable becomes true only for a completed job whose full result fit the 1 MiB retention bound.

Errors: NOT_FOUND for an unknown, expired, wrong-token, or prior-boot job. Unknown ids and wrong tokens intentionally return the same response.

get_agent_job_result

Return the same snapshot as get_agent_job_status, plus result once the job is completed and its result is retained. Calling it while a job is running is safe: it returns the current status without a result field, so clients may use this as their only polling call.

Param Type Constraints Required Description
jobId string UUID returned by a long-running tool yes Opaque job identifier.
jobToken string 32–128 characters yes Exact per-job secret from the start response.

Returns: the status shape above, plus result: unknown when status:"completed" and resultAvailable:true. Terminal results expire after 15 minutes; start-specific docs define the exact result shape.

Errors: NOT_FOUND under the same ownership/expiry rules as status.

cancel_agent_job

Request cancellation of one job. The registry aborts the owned runner, which propagates through the service's AbortSignal to Whisper, FFmpeg, streaming file copy, or HTTPS download cleanup. Poll status until cancelled; cancellation acknowledgement does not claim the child process has settled yet.

Param Type Constraints Required Description
jobId string UUID returned by a long-running tool yes Opaque job identifier.
jobToken string 32–128 characters yes Exact per-job secret from the start response.

Returns: { jobId: string; accepted: boolean; status: AgentJobStatus }. accepted:false means the job is terminal or has crossed its irreversible commit point; inspect status/result rather than assuming work was discarded.

Errors: NOT_FOUND for an unknown, expired, or wrong-token pair.

jsonc
// poll
{ "name": "get_agent_job_status", "arguments": { "jobId": "...", "jobToken": "..." } }
// result
{ "jobId": "...", "type": "import_video", "status": "running",
  "progress": { "stage": "Extracting audio...", "percent": 55 }, "resultAvailable": false }

// cancel only with the exact pair returned to this caller
{ "name": "cancel_agent_job", "arguments": { "jobId": "...", "jobToken": "..." } }
// result
{ "jobId": "...", "accepted": true, "status": "cancelling" }

Export

export_video

Render and encode the open project to outputPath, as .mp4 or .mov. Requires an active license — export is the paid action. Returns once the job has started; poll get_export_status for progress and completion.

Param Type Constraints Required Description
outputPath string absolute path, no NUL bytes, no .. segments, filename must not start with -, <= 4,096 characters, extension must be .mp4 or .mov, must not resolve inside the open project's recording/ directory yes Absolute destination path for the export.
resolutionMode string enum source | 720p | 1080p no (default source) Output resolution. Acts as the short edge when an explicit aspectRatio is set (so 9:16 at 1080p renders 1080×1920).
aspectRatio string enum auto | 16:9 | 9:16 | 1:1 | 4:3 | 3:4 no (default auto) Output aspect ratio (reframe). auto keeps the recording's ratio. An explicit ratio reshapes the output canvas and letterboxes the recording into it over the configured background — nothing is cropped or stretched. Use 9:16 for TikTok/Reels/Shorts, 1:1 for feed posts.
enhanceVoice boolean no (default true) AI voice enhancement (DeepFilterNet3).
normalise boolean no (default true) Loudness normalisation.
micNoiseReduction boolean no (default true) Microphone noise reduction.

Returns: { started: true; outputPath: string; status: ExportStatusSnapshot }.

Errors:

  • INVALID_ARGSoutputPath fails a path check above, uses an extension other than .mp4/.mov, or resolves inside the project's recording/ directory. That directory holds the only copy of the captured video and audio and the encoder runs with -y (overwrite); exporting there would destroy the source recording, so it is refused outright rather than overwritten. Export to the Desktop, Movies, or the project's exports/ directory instead.
  • NO_PROJECT_OPEN — no project to export.
  • EXPORT_IN_PROGRESS — an export is already running.
  • LICENSE_REQUIRED — no active subscription. This is the paywall; an agent cannot bypass it. Ask the user to activate a license in the Cadre app, then retry.
jsonc
// request
{ "name": "export_video", "arguments": { "outputPath": "/Users/me/Desktop/demo.mp4", "resolutionMode": "1080p", "aspectRatio": "9:16" } }
// result
{ "started": true, "outputPath": "/Users/me/Desktop/demo.mp4",
  "status": { "phase": "exporting", "progress": null, "result": null, "error": null } }

get_export_status

Current export phase, latest progress, result, and any error. Poll this after export_video.

Parameters: none. Returns: ExportStatusSnapshot. Errors: none.

jsonc
// request
{ "name": "get_export_status", "arguments": {} }
// result (mid-run)
{ "phase": "exporting",
  "progress": { "percent": 62, "eta": 8, "currentFrame": 1860, "totalFrames": 3000, "stage": "Encoding video" },
  "result": null, "error": null }
// result (done)
{ "phase": "completed", "progress": { "percent": 100, "...": "..." },
  "result": { "outputPath": "/Users/me/Desktop/demo.mp4", "fileSize": 8412345, "duration": 25000 }, "error": null }

cancel_export

Cancel the in-progress export, if any. No-op when nothing is running.

Parameters: none. Returns: { cancelling: boolean; status: ExportStatusSnapshot } (cancelling is true only if an export was actually running). Errors: none.

jsonc
// request
{ "name": "cancel_export", "arguments": {} }
// result
{ "cancelling": true, "status": { "phase": "exporting", "...": "..." } }

Not in v1

For orientation, these are deliberately absent in v1:

  • No webcam capture from an agent — the camera preview window is user-driven (see Recording). (Recording control itself was in this list; start_recording and its six siblings are now real tools.)
  • No agent-settable recording duration or auto-stop: the agent sleeps and calls stop_recording itself.
  • No camera-layout, no split-point, no playhead, no music.update as MCP tools — though these exist as bridge commands (split.add/remove, playhead.set, music.update) and may be surfaced in a later version. (delete_speed was in this list; it is now a real tool. Masks/highlights and overlays were too — add_mask / update_mask / delete_mask and add_text_overlay / add_svg_overlay / update_overlay / delete_overlay are now real tools too.)
  • Reads are constrained the same way writes are: export_video's outputPath (write) and set_music's filePath (read, to probe duration) are the only file paths most tools ever touch. import_video's path is the one exception — it reads a caller-chosen video file — but only to copy it into a new, Cadre-managed .screencraft project directory; there is still no general-purpose file read or write.