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.
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,000ms (24 h) — comfortably beyond any plausible recording, while still rejecting absurd values a bare zodnumber()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, anddownload_caption_modelreturn a background-job receipt immediately. Pollget_agent_job_status, then readget_agent_job_result. On failure it is{ "error": { "code", "message", "hint" } }withisError: true. Validation failures always useINVALID_ARGS(orNOT_FOUNDfor an unloadable project manifest — seeopen_project); themessagenames the parameter, what was expected, and what was received, andhintis an actionable next step. SeeREADME.md§5 for the full error shape and code table. - Rectangles (
sourceRect) default to logical pixels of the source recording. Readrecording.width/recording.heightfromget_timelinefor 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_AVAILABLEotherwise.
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
{
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
{
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:
{
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
{
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
{ id: string; startTime: number; endTime: number; type: 'remove' }SpeedSegment
{ id: string; startTime: number; endTime: number;
speed: number; rampIn: number; rampOut: number }CaptionSegment
{ id: string; startTime: number; endTime: number;
text: string; confidence: number; isUserEdited: boolean }MusicTrack
{ trackId: string; filePath: string; name: string;
volume: number; fadeInMs: number; fadeOutMs: number;
loop: boolean; durationMs: number }MaskSegment
{
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
{
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
{
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
{
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
{
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:
{
version: string;
projectOpen: boolean;
project: { id: string; name: string; path: string } | null;
license: LicenseStatus;
export: ExportStatusSnapshot;
}Errors: none (always succeeds).
Example
// 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.
// 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
{ 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.
// 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_ARGS—pathfails one of the path-safety checks above.NOT_FOUND— no loadable project atpath: either nothing is readable there, orproject.jsondoes not parse as an object with a stringidandname.
// 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.
// 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_ARGS—pathfails a path-safety check above, names an unsupported extension, or does not resolve to an existing regular file.
// 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:
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 editingFour things to know before you call any of them.
- Nothing records silently.
start_recordingruns 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. - Recording and editing are free to try; export is the paywall. This is the
same D-059 policy used by the app UI.
start_recordingdoes not require a subscription;export_videodoes. - There is no
durationparameter and no auto-stop. You own the clock: sleep, then callstop_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 callstop_recording, do not start a recording. - 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
TIMEOUTotherwise. 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:
{
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.
// 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:
{
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.
// 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:
{
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;trackCursorwas requested without Accessibility; ortrackKeyboardwithout Input Monitoring. Thehintnames the System Settings pane and the parameter you can turn off instead.INVALID_STATE— a recording is already running (get_recording_status, thenstop_recordingorcancel_recording).NOT_FOUND— the display/window/iOS/microphone id does not exist right now. Thehinttells the caller to enumerate again; a microphone is never retargeted by a matching device name.INVALID_ARGS— a required id for the chosensource.typeis missing, or thearearect is degenerate/absurd.TIMEOUT— the session never reachedrecording. It may be stalled mid-startup: checkget_recording_status, thencancel_recording.INTERNAL— anything else, including insufficient disk space (Cadre needs ≥ 5 GB of headroom). The original message is preserved.
// 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:
{
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, orcancel_recording).INTERNAL— finalization failed. The captured footage is left on disk — the message says where, andlist_projectsmay still show it. Never assume a failed stop means a lost take.
// 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).
// 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
// 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:
{
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_captionsreturns the fullCaptionTrack(which also carriesgeneratedAtandstyle).
Errors: NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.
// 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", "...": "..." }, "...": "..." }
}
recordingisnullfor 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_zoomwill 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:
{
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.
// 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.
// 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
sourceRectnow produces a frame-centred window of1 / zoomLevelof 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 whoserecordingisnullthe call now fails withINVALID_ARGSrather 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// request
{ "name": "delete_speed", "arguments": { "id": "sp-3d9e..." } }
// result
{ "id": "sp-3d9e...", "deleted": true }Corrected guidance. This page previously said there was no
delete_speedtool and told agents to "re-set_speedthe range to1.0" to remove a speed-up. Under the old push-onlyset_speedthat 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_speedis now the correct way to undo aset_speed. Re-setting the range to1.0also 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.
// 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.
// 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.
// 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.
// 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_ARGS — endTime <= startTime; or the SVG is empty, over
200,000 characters, not a standalone <svg>…</svg> document, or contains
nothing but stripped content after sanitisation.
// 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.
// 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.
// 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:
hyfor an SVG is exact:width × frameAspect ÷ viewBoxAspect ÷ 2.hyfor text is estimated from the pill geometry:fontSizeFrac × (1.25 × lines + 2 × paddingFrac) ÷ 2, wherelinescounts explicit\nbreaks only — automatic word wrapping is not simulated, so a long wrapping string anchored tobottomsits slightly higher than it should. Pass an explicitpositionwhen 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.
// 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:
{
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).
// 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:
{
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.
{ "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.
{ "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:
{ language: string; modelUsed: string; generatedAt: string;
segments: CaptionSegment[];
style: { fontFamily; fontSize; fontWeight; color; backgroundColor;
backgroundOpacity; position; marginBottom } } | nullErrors: NO_PROJECT_OPEN, EDITOR_NOT_AVAILABLE.
// 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.
// 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.
// 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_DOWNLOADED—modelSizeis not downloaded locally. The hint points atlist_caption_models/download_caption_model; this replaces what used to be a bareINTERNALfailure.BUSY— a caption generation is already running (thecaption:generateIPC 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).
// 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:
{ 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_ARGS—updateshas no recognised field, or a field is out of range / not a valid hex colour.NO_PROJECT_OPEN,EDITOR_NOT_AVAILABLE.
// 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
{ 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.
// 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_FOUND—modelIdis not one of the known catalogue ids; the hint lists the valid ones.INVALID_ARGS—modelIdfails the bare-identifier shape check.INTERNAL— the download itself failed (network error, integrity check failure).
// 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, orprototype, 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") andrgb(...)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 ofzoomAnimation) 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 } |
// 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 | 0–1 |
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.
// 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 | 0–4 |
no* | System audio gain. |
micGain |
number | 0–4 |
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.
// 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.
// 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:
{
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.
// 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_ARGS—outputPathfails a path check above, uses an extension other than.mp4/.mov, or resolves inside the project'srecording/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'sexports/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.
// 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.
// 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.
// 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_recordingand its six siblings are now real tools.) - No agent-settable recording duration or auto-stop: the agent sleeps and calls
stop_recordingitself. - No camera-layout, no split-point, no playhead, no
music.updateas MCP tools — though these exist as bridge commands (split.add/remove,playhead.set,music.update) and may be surfaced in a later version. (delete_speedwas in this list; it is now a real tool. Masks/highlights and overlays were too —add_mask/update_mask/delete_maskandadd_text_overlay/add_svg_overlay/update_overlay/delete_overlayare now real tools too.) - Reads are constrained the same way writes are:
export_video'soutputPath(write) andset_music'sfilePath(read, to probe duration) are the only file paths most tools ever touch.import_video'spathis the one exception — it reads a caller-chosen video file — but only to copy it into a new, Cadre-managed.screencraftproject directory; there is still no general-purpose file read or write.