AUTOMATION Six surfaces
Drive it from whatever already runs your cart.
Cutvey Offload exposes six automation surfaces. They share one command vocabulary and one parser, so anything the URL scheme accepts, the HTTP API accepts, and a saved workspace can do exactly what a script can do and nothing else. A rule enforced on the API path cannot be sidestepped by clicking Run.
01 URL scheme
cutvey-offload://
Invoke a command by opening a URL, from a shell, a launcher such as Keyboard Maestro or Hazel or Alfred, or a watch-folder script. If the app is not running it launches; if it is running the URL reaches the existing instance. The protocol registers itself in packaged builds.
open "cutvey-offload://addTransfers"
| Command | Parameters | Effect |
|---|---|---|
| open | none | Bring the window forward, launching if needed. |
| quit | none | Quit the app. |
| addTransfers | none | Run the current source and destination pairs. The same thing as clicking Add N Transfers. |
| restartTransfer | id required | Re-run a finished or failed transfer. It reports which of "no transfer with that id" and "that transfer is in a state that cannot be restarted" applied, rather than doing nothing quietly the way the button does. A script retrying an overnight failure needs to know whether anything actually happened. |
| setSource | paths required, JSON string array; label optional | Assign the given paths as sources, with an optional label template. |
| setDestination | path required | Assign a path as a destination. |
| applyPreset | name required | Swap the active preset by name, case-insensitively. An unknown name is a soft error in the outcome, never a crash. |
| reset | type = sources, destinations or all | Clear role assignments. S3 buckets are unaffected and have to be removed explicitly. |
| setPreferences | preferences, JSON object, whitelisted keys only | Merge safe preferences. |
| reloadPresets | none | Re-read presets from disk. |
| actions | json, JSON array of command objects | Run several commands in order. |
The setPreferences whitelist
Only these keys are accepted. Anything else, or an invalid value, is rejected and logged rather than written, and rejected keys come back in the invocation's outcome. There is no path to arbitrary preference injection.
| Key | Allowed values |
|---|---|
| mode | fast · verified · secure · pro (the legacy aliases normal and slow are still accepted) |
| queueMode | off · single-source · single-destination · single-transfer |
| folderFormat | string, 500 characters or fewer |
| renameFormat | string, 500 characters or fewer |
| autoLabel | string, 500 characters or fewer |
| notify | boolean, mapping to "notify when transfers finish" |
Individual commands
# setSource?paths=["/Volumes/A","/Volumes/B"]&label=A Roll
open 'cutvey-offload://setSource?paths=%5B%22%2FVolumes%2FA%22%2C%22%2FVolumes%2FB%22%5D&label=A%20Roll'
# setDestination?path=/Volumes/Backup
open 'cutvey-offload://setDestination?path=%2FVolumes%2FBackup'
# setPreferences?preferences={"mode":"pro","queueMode":"off"}
open 'cutvey-offload://setPreferences?preferences=%7B%22mode%22%3A%22pro%22%2C%22queueMode%22%3A%22off%22%7D'
open 'cutvey-offload://reset?type=sources'
open 'cutvey-offload://addTransfers'
Every invocation is logged
Each call is appended to ~/Library/Application Support/Cutvey Offload/logs/api-log.jsonl with a timestamp, the raw URL, the command, and the outcome. Query values are percent-encoded, and the paths, preferences and json parameters are JSON-encoded values before that.
02 Local HTTP API
The same vocabulary, over loopback.
Settings, Automation, Local HTTP API. For scripts that would rather use curl than open. An HTTP request is mapped to a cutvey-offload:// command through the same parser, so the two surfaces cannot drift apart.
It is a network surface, so it is locked down
- Loopback only. The listener binds
127.0.0.1, never all interfaces, and peers that somehow are not the local host are rejected outright. - Off by default. Nothing listens until you flip the toggle.
- Bearer token required. A random 128-bit token is generated on first enable. Every request must present it, as an
Authorizationheader or atokenquery parameter. Missing or wrong is a 401. Regenerate rotates it and invalidates the old one. - No new capability. It dispatches only the existing command set. No arbitrary shell, no arbitrary file read or write.
Endpoints
| GET / | The live dashboard. Opened without a token it returns 401. |
| GET /status | Read-only JSON snapshot. |
| GET | POST /command/<name> | Run any URL-scheme command. |
200 ok · 400 unparseable or unknown command · 401 unauthorized · 404 unknown path · 405 wrong method · 422 the command ran and reported an error. Responses are JSON. The port is configurable and defaults to 8765; a busy port surfaces an error in the Automation tab rather than crashing.
TOKEN="…" # copy it from Settings ▸ Automation ▸ Local HTTP API
# Read-only status
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/status
# Set a destination
curl -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:8765/command/setDestination?path=/Volumes/Backup"
# Two sources with a label, as a POST body
curl -H "Authorization: Bearer $TOKEN" -X POST \
--data '{"paths":["/Volumes/A","/Volumes/B"],"label":"{Counter}_{Source Name}"}' \
http://127.0.0.1:8765/command/setSource
# A chained batch
curl -H "Authorization: Bearer $TOKEN" -X POST \
--data '[{"command":"reset","type":"all"},
{"command":"setDestination","path":"/Volumes/SAMSUNG T7/Backups"},
{"command":"setPreferences","preferences":{"mode":"secure","queueMode":"single-source"}},
{"command":"addTransfers"}]' \
http://127.0.0.1:8765/command/actions
03 Live dashboard
Watch the cart from a laptop across the room.
A single self-contained HTML page, inline CSS and JS with no external requests, in the same dark identity as the app. It reads its bearer token from its own URL, polls /status once a second, and renders live transfer cards with progress and speed and ETA, the mounted volumes with their roles, and the queue tallies. It never mutates state. It only reads.
Open it from Settings, Automation, Local HTTP API, Open Dashboard. Or go straight there.
http://127.0.0.1:8765/?token=<TOKEN>
GET /status
{
"ok": true,
"product": "Cutvey Offload",
"version": "3.3.1",
"mode": "verified",
"queueMode": "singleSource",
"activePreset": "A_FX6",
"shootDay": "2026-07-26",
"counts": { "volumes": 6, "sources": 2, "destinations": 2 },
"aggregate": {
"active": 2, "percent": 44.1,
"speed": 2100000000, "etaSeconds": 42.0,
"filesCopied": 88, "filesTotal": 224
},
"volumes": [ … ],
"transfers": [ … ],
"queue": { "total": 4, "inFlight": 2,
"queued": 2, "done": 0, "failed": 0 }
}
Abridged. Per-transfer entries carry source, destination, destination path, state, phase, bytes, percent, speed, ETA, file counts, errors, warnings and the cascade parent.
04 Event scripts
Eleven lifecycle events, each running a command you wrote.
Settings, Automation. All are off by default. Toggle one on, type a command, and use Test to fire it with a sample payload. A recent-events list shows the last twenty that fired.
These run arbitrary shell commands you provide
Commands run in the background, fire and forget, and are killed after 30 seconds.
| Event | Fires when |
|---|---|
| app-started | The app launches. |
| disk-added | A volume mounts. |
| disk-removed | A volume unmounts. |
| transfer-added | A transfer is queued. |
| transfer-done | A transfer finishes successfully. |
| transfer-failed | A transfer ends with errors. |
| source-complete | Every transfer of a source has finished, which is to say the card is free. |
| all-idle | The whole queue reaches a terminal state. |
| report-created | A camera report was written. |
| report-failed | A camera report could not be written. |
| verification-issue | A file failed verification. Fan-out is capped at 25 events per run, because a failing reader produces thousands of bad files and one shell per failure would take the machine down mid-rescue. The true, uncapped total rides in the payload. |
A desktop notification on completion
osascript -e "display notification \"$CUTVEY_SOURCE_NAME finished ($CUTVEY_STATUS)\" \
with title \"Cutvey Offload\""
A CSV row beside the media after each transfer
Bind this to transfer-done, the event whose payload carries the destination path. It appends one RFC-4180 row, writing the header on first run.
csv="$CUTVEY_DEST_PATH/Cutvey Offload — transfers.csv"
q() { printf '"%s"' "$(printf '%s' "$1" | sed 's/"/""/g')"; }
[ -f "$csv" ] || printf 'Time,Source,Destination,Status\r\n' > "$csv"
printf '%s,%s,%s,%s\r\n' \
"$(q "$(date -u +%Y-%m-%dT%H:%M:%SZ)")" "$(q "$CUTVEY_SOURCE_NAME")" \
"$(q "$CUTVEY_DEST_PATH")" "$(q "$CUTVEY_STATUS")" >> "$csv"
Every fired event is appended to logs/events.jsonl whether or not a script ran, and a ran field records which.
05 Payload variables
Twenty-five fields, and absent means absent.
CUTVEY_EVENT and CUTVEY_PAYLOAD are always present. The remaining 23 are each optional and omitted entirely when they do not apply, because a script has to be able to tell "this event does not carry bytes" from "this event copied zero bytes", and an empty environment variable cannot express that difference.
| Variable | JSON key | Meaning |
|---|---|---|
| CUTVEY_EVENT | event | The event name. Always present. |
| CUTVEY_PAYLOAD | — | The full payload as a JSON object, keys sorted. Always present. |
| CUTVEY_SOURCE_NAME | sourceName | Source or volume display name. |
| CUTVEY_SOURCE_PATH | sourcePath | Source or volume path. |
| CUTVEY_DEST_PATH | destPath | Destination path, or an s3:// URI. |
| CUTVEY_TRANSFER_ID | transferId | Transfer id. |
| CUTVEY_STATUS | status | Terminal status: done, failed, complete and so on. |
| CUTVEY_BYTES_COPIED | bytesCopied | Bytes delivered to the destination. |
| CUTVEY_BYTES_TOTAL | bytesTotal | Bytes the scan expected to deliver. Comparing the two is the cheapest possible "did it all land?" check, which is why both ship. |
| CUTVEY_FILES_COPIED | filesCopied | Files delivered. |
| CUTVEY_FILES_TOTAL | filesTotal | Files the scan expected. |
| CUTVEY_DURATION_SEC | durationSec | Wall-clock seconds, to milliseconds. |
| CUTVEY_PRESET_NAME | presetName | The preset the operator ran, when the app supplied one. |
| CUTVEY_VERIFICATION_MODE | verificationMode | The tier actually used, after any per-source-name override. A script that gates an erase needs the effective mode, not the one the chip was left on. |
| CUTVEY_CHECKSUM_ALGO | checksumAlgo | The checksum algorithm used. |
| CUTVEY_TRANSFER_LOG_JSON_PATH | transferLogJSONPath | The per-transfer JSON log. |
| CUTVEY_TRANSFER_LOG_TEXT_PATH | transferLogTextPath | The human-readable log. |
| CUTVEY_TRANSFER_LOG_DEST_JSON_PATH | transferLogDestJSONPath | The copy of the JSON log written to the destination, when that option is on. |
| CUTVEY_REPORT_HTML_PATH | reportHTMLPath | The camera report's HTML file. |
| CUTVEY_REPORT_PDF_PATH | reportPDFPath | The camera report's PDF file. |
| CUTVEY_FILE_REL | fileRel | On verification-issue: the failing file, destination-relative. |
| CUTVEY_FILE_PATH | filePath | On verification-issue: its full path. |
| CUTVEY_ISSUE_INDEX | issueIndex | On verification-issue: this issue's index within the capped fan-out. |
| CUTVEY_ISSUE_COUNT | issueCount | On verification-issue: the true total, uncapped. |
| CUTVEY_ERROR_MESSAGE | errorMessage | Why it failed. |
One payload, two renderings
The JSON object and the environment map walk a single field table. They used to be two hand-maintained lists, and a field added to one and forgotten in the other does not crash and does not warn: your script reads a variable, gets an empty string, and cheerfully reports that the night's offload moved zero bytes.
Numbers are formatted in the C locale
Deliberately, in the engine. A DIT in Berlin would otherwise be handed 1234,500, which awk, jq, bc and every arithmetic comparison in a shell read as 1234. An infinite or not-a-number duration is dropped rather than printed, because nan in a shell comparison is worse than an absent variable a script can test for.
06 S3 destinations
Offload straight to a bucket.
In the destinations zone, add an S3 destination with a display name, region, bucket, optional prefix, access key ID and secret. The bucket appears as a destination card and flows through the normal transfer engine and queue.
An optional custom endpoint host targets S3-compatible providers such as Backblaze B2, Storj, Wasabi, Seagate Lyve and MinIO, signing against the given host and region. AWS remains the default when none is set. Signing is AWS Signature V4, computed by hand with CryptoKit. There is no AWS SDK in the app.
What is different about a bucket
- Verification maps on. VERIFIED checks the uploaded object's size with a HEAD. SECURE and PRO download the object back and compare its hash, which is slower: it is a full re-read over the network. Sidecars are uploaded as objects.
- Files over 64 MB use S3 multipart upload; smaller files use a single PUT.
- Duplicate detect is by size only, because object stores expose no reliable modification time.
- A bucket cannot be a source, cannot join a cascade for want of a local read path, and cannot be moved to.
Where the secret lives
Bucket configuration is stored in preferences without the secret. The secret access key lives in the macOS Keychain and is handed to the copy engine only in memory, to sign requests. It is never written to preferences, never logged, and never echoed back into the UI after saving. On an unsigned build a stored secret does not survive a rebuild, because the Keychain ties access to a code signature. See the download page.
07 Saved workspaces
One click, and the cart is set up for the day.
Capture the current preset, sources, destinations and queue mode under a name. Run replays it.
The thing that matters about it is that it is not a parallel applier. A workspace stores an actions array and Run feeds it through the same parser a scripted invocation uses. A workspace can do exactly what a script can do and nothing else, and unreadable saved actions surface on the status line rather than doing something approximate.
08 Finder Quick Action
Right-click, Offload with Cutvey.
The app installs a macOS Service and flushes the Services cache so Finder picks it up. Selecting files or folders and choosing it builds a setSource deep link, which is the same link the parser maps straight back to the same command.
It is not a second code path, and the deep-link builder is tested against the same encoding the installed action uses at click time.
The whole surface, in every copy.
No automation tier, no API add-on. Free with any Cutvey subscription, or nineteen dollars per Mac per year on its own.