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"
CommandParametersEffect
opennoneBring the window forward, launching if needed.
quitnoneQuit the app.
addTransfersnoneRun the current source and destination pairs. The same thing as clicking Add N Transfers.
restartTransferid requiredRe-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.
setSourcepaths required, JSON string array; label optionalAssign the given paths as sources, with an optional label template.
setDestinationpath requiredAssign a path as a destination.
applyPresetname requiredSwap the active preset by name, case-insensitively. An unknown name is a soft error in the outcome, never a crash.
resettype = sources, destinations or allClear role assignments. S3 buckets are unaffected and have to be removed explicitly.
setPreferencespreferences, JSON object, whitelisted keys onlyMerge safe preferences.
reloadPresetsnoneRe-read presets from disk.
actionsjson, JSON array of command objectsRun 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.

KeyAllowed values
modefast · verified · secure · pro (the legacy aliases normal and slow are still accepted)
queueModeoff · single-source · single-destination · single-transfer
folderFormatstring, 500 characters or fewer
renameFormatstring, 500 characters or fewer
autoLabelstring, 500 characters or fewer
notifyboolean, 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 Authorization header or a token query 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 /statusRead-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.

EventFires when
app-startedThe app launches.
disk-addedA volume mounts.
disk-removedA volume unmounts.
transfer-addedA transfer is queued.
transfer-doneA transfer finishes successfully.
transfer-failedA transfer ends with errors.
source-completeEvery transfer of a source has finished, which is to say the card is free.
all-idleThe whole queue reaches a terminal state.
report-createdA camera report was written.
report-failedA camera report could not be written.
verification-issueA 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.

VariableJSON keyMeaning
CUTVEY_EVENTeventThe event name. Always present.
CUTVEY_PAYLOADThe full payload as a JSON object, keys sorted. Always present.
CUTVEY_SOURCE_NAMEsourceNameSource or volume display name.
CUTVEY_SOURCE_PATHsourcePathSource or volume path.
CUTVEY_DEST_PATHdestPathDestination path, or an s3:// URI.
CUTVEY_TRANSFER_IDtransferIdTransfer id.
CUTVEY_STATUSstatusTerminal status: done, failed, complete and so on.
CUTVEY_BYTES_COPIEDbytesCopiedBytes delivered to the destination.
CUTVEY_BYTES_TOTALbytesTotalBytes the scan expected to deliver. Comparing the two is the cheapest possible "did it all land?" check, which is why both ship.
CUTVEY_FILES_COPIEDfilesCopiedFiles delivered.
CUTVEY_FILES_TOTALfilesTotalFiles the scan expected.
CUTVEY_DURATION_SECdurationSecWall-clock seconds, to milliseconds.
CUTVEY_PRESET_NAMEpresetNameThe preset the operator ran, when the app supplied one.
CUTVEY_VERIFICATION_MODEverificationModeThe 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_ALGOchecksumAlgoThe checksum algorithm used.
CUTVEY_TRANSFER_LOG_JSON_PATHtransferLogJSONPathThe per-transfer JSON log.
CUTVEY_TRANSFER_LOG_TEXT_PATHtransferLogTextPathThe human-readable log.
CUTVEY_TRANSFER_LOG_DEST_JSON_PATHtransferLogDestJSONPathThe copy of the JSON log written to the destination, when that option is on.
CUTVEY_REPORT_HTML_PATHreportHTMLPathThe camera report's HTML file.
CUTVEY_REPORT_PDF_PATHreportPDFPathThe camera report's PDF file.
CUTVEY_FILE_RELfileRelOn verification-issue: the failing file, destination-relative.
CUTVEY_FILE_PATHfilePathOn verification-issue: its full path.
CUTVEY_ISSUE_INDEXissueIndexOn verification-issue: this issue's index within the capped fan-out.
CUTVEY_ISSUE_COUNTissueCountOn verification-issue: the true total, uncapped.
CUTVEY_ERROR_MESSAGEerrorMessageWhy 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.