
If a scheduled job on your hosted Mac ran but left no receipt, you cannot prove what it touched, when it finished, or whether the file your downstream system received is the file your script produced. The fix is three cooperating layers: launchd captures stdout and stderr from the moment the process starts, a wrapper script writes a timestamped JSON run receipt, and SHA256 hashes tie every input and output file to that receipt. This guide wires all three together on a Figment hosted Mac.
For a single scheduled agent that calls APIs, transforms JSON, and pushes artifacts to object storage, the $29 virtual Intel macOS instance (2 vCPU, 8 GiB RAM, 80 GiB disk) is the right starting point. It runs macOS, so launchd, launchctl, shasum, jq, and the shortcuts CLI all work as documented. Access is through the browser console, outbound network is open to the public internet, and your agent can reach external APIs and S3-compatible storage normally.
Choose a physical M-series plan from $95 per month when your workload needs Apple silicon execution (arm64-only binaries, Xcode 27, visionOS tooling), when you run several concurrent agents, or when GPU acceleration matters. The managed Build Mac at $249 bundles one M-series Mac with CI setup, backups, monitoring, and 30 minutes of monthly support; the Managed Mac Team at $999 bundles two M-series Macs and two CI runners with team setup, backups, monitoring, and one hour of monthly support. Physical plans are fulfilled against confirmed supply, so request the configuration early if you have a deadline.
Start or request a configuration at Figment Mac hosting.
Decide upfront what the receipt must prove. Pinning the same fields on every job lets you query the archive with jq and hand it to an auditor without custom tooling.
| Field | What to record |
|---|---|
run_id | ISO timestamp + random hex suffix, unique per invocation |
started_at / finished_at | UTC ISO 8601 |
inputs | Name and SHA256 of each input file or hashed parameter value |
outputs | Name, absolute path, byte count, SHA256 |
exit_code | Integer, written even when the job step fails |
log_path | Absolute path to the stdout+stderr log for this run |
Record only the environment variables you actually need to reproduce the run. Never write a full environment dump to disk: it routinely contains API tokens, signing secrets, and session cookies. Transfer the names of the required variables separately and let your secret manager repopulate values at job start.
One file per run, written at the end whether the job succeeded or failed.
{
"schema": "run-receipt/v1",
"run_id": "2026-11-10T14-32-01Z-a3f8b2",
"job_label": "com.example.data-sync",
"host": "figment-mac-01",
"started_at": "2026-11-10T14:32:01Z",
"finished_at": "2026-11-10T14:32:47Z",
"exit_code": 0,
"inputs": [
{ "name": "FEED_URL", "sha256": "e3b0c44298fc1c149afbf4c8996fb924..." }
],
"outputs": [
{
"name": "report.json",
"path": "/Users/agent/jobs/a3f8b2/report.json",
"bytes": 14228,
"sha256": "9f86d081884c7d659a2feaa0c55ad015..."
}
],
"log_path": "/Users/agent/Library/Logs/example/data-sync-2026-11-10T14-32-01Z-a3f8b2.log",
"env_snapshot": { "LANG": "en_US.UTF-8" }
}
If your job calls Apple's Shortcuts CLI, the shortcuts command exits 0 on success and 1 on error, per Apple's command-line reference for Shortcuts. Capture that exit code directly into exit_code rather than inferring success from output.
A launchd LaunchAgent plist redirects stdout and stderr from the moment the process is spawned, which means early crashes still leave a trace. Place the file at ~/Library/LaunchAgents/com.example.data-sync.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.data-sync</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/data-sync-wrapper.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key> <integer>3</integer>
<key>Minute</key> <integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/Users/agent/Library/Logs/example/launchd-out.log</string>
<key>StandardErrorPath</key>
<string>/Users/agent/Library/Logs/example/launchd-err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
Label, ProgramArguments, KeepAlive, StartCalendarInterval, StandardOutPath, and StandardErrorPath are documented in Apple's Creating Launchd Jobs reference, which also points at the launchd.plist(5) man page for the authoritative key list. On modern macOS releases, load the agent with launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.data-sync.plist; the older launchctl load form still works in current releases. Check the exact behaviour of each key on the macOS version actually installed on your host, including what happens to stdout when StandardOutPath is omitted.
The wrapper creates a unique work directory, redirects its own logs, runs the job, hashes the output, and writes the receipt. set -euo pipefail stops early on unexpected errors; an explicit || { EXIT_CODE=$?; ... } around the job step keeps the real exit code so the receipt reflects reality even on failure.
#!/usr/bin/env bash
set -euo pipefail
JOB_LABEL="com.example.data-sync"
STARTED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ") # ISO 8601 for receipt
RUN_ID_TS=$(date -u +"%Y-%m-%dT%H-%M-%SZ") # filesystem-safe
RUN_ID="${RUN_ID_TS}-$(openssl rand -hex 3)"
WORK_DIR="${HOME}/jobs/${RUN_ID}"
LOG_DIR="${HOME}/Library/Logs/example"
LOG_FILE="${LOG_DIR}/${JOB_LABEL}-${RUN_ID}.log"
RECEIPT="${WORK_DIR}/receipt.json"
EXIT_CODE=0
umask 077
mkdir -p "${WORK_DIR}" "${LOG_DIR}"
exec >> "${LOG_FILE}" 2>&1 # per-run log; launchd plist catches early crashes
echo "[${STARTED_AT}] START ${JOB_LABEL} run_id=${RUN_ID}"
# ── your job logic ────────────────────────────────────────────────
OUTPUT="${WORK_DIR}/report.json"
curl --fail --silent --show-error \
--header "Authorization: Bearer ${API_TOKEN}" \
"${FEED_URL}" \
--output "${OUTPUT}" \
|| { EXIT_CODE=$?; echo "[$(date -u +%T)] fetch failed exit=${EXIT_CODE}"; }
# ──────────────────────────────────────────────────────────────────
FINISHED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
OUTPUT_SHA=""
OUTPUT_BYTES=0
if [[ -f "${OUTPUT}" ]]; then
OUTPUT_SHA=$(shasum -a 256 "${OUTPUT}" | awk '{print $1}')
OUTPUT_BYTES=$(stat -f "%z" "${OUTPUT}")
fi
cat > "${RECEIPT}" <<JSON
{
"schema": "run-receipt/v1",
"run_id": "${RUN_ID}",
"job_label": "${JOB_LABEL}",
"started_at": "${STARTED_AT}",
"finished_at": "${FINISHED_AT}",
"exit_code": ${EXIT_CODE},
"outputs": [
{
"name": "report.json",
"path": "${OUTPUT}",
"bytes": ${OUTPUT_BYTES},
"sha256": "${OUTPUT_SHA}"
}
],
"log_path": "${LOG_FILE}"
}
JSON
echo "[$(date -u +%T)] receipt written: ${RECEIPT}"
exit "${EXIT_CODE}"
Pass API_TOKEN and FEED_URL through the launchd EnvironmentVariables dict or your CI secret manager. Do not place secret literals on the command line; they end up in the process listing and shell history.
After each scheduled fire, launchctl print gui/$(id -u)/com.example.data-sync shows the last exit status and total run count. Cross-reference it against the latest receipt. If the receipt is missing, the wrapper crashed before the write step, and the launchd StandardOutPath log is where the trace landed.
Add a short verification job that runs immediately after each sync:
#!/usr/bin/env bash
set -euo pipefail
LATEST=$(ls -t ~/jobs/*/receipt.json 2>/dev/null | head -1)
[[ -n "${LATEST}" ]] || { echo "ERROR: no receipt found"; exit 1; }
EC=$(jq '.exit_code' "${LATEST}")
[[ "${EC}" -eq 0 ]] || { echo "ERROR: last run exited ${EC}"; exit 1; }
echo "OK run_id=$(jq -r '.run_id' "${LATEST}") finished=$(jq -r '.finished_at' "${LATEST}")"
For replay integrity, re-fetch the same input from your source of record, rehash it, and compare against the inputs field in the receipt. A mismatch means the upstream feed changed after your run finished — which is exactly the question an auditor will ask.
Rotate work directories. A small second LaunchAgent that deletes receipts and logs older than 30 days keeps the 80 GiB VM disk from filling silently.
The $29 virtual Intel macOS plan is billed monthly. It is not pro-rated and there is no published daily rate, so a 14-day evaluation still costs the full month. For a single nightly agent that writes a few hundred kilobytes per run, one month of receipts, logs, and outputs is well under 1 GiB.
| Tier | Monthly | Fit for this workload |
|---|---|---|
| Virtual Intel macOS | $29 | One agent, scripted automation, no arm64-only tooling |
| Physical M-series | from $95 | Concurrent agents, arm64 binaries, Xcode 27, visionOS |
| Managed Build Mac | $249 | Includes one M-series Mac, CI setup, backups, monitoring, 30 min support |
If one scheduled job graduates into ten concurrent build jobs, the Managed Build Mac removes the launchd-rotation and backup plumbing you would otherwise keep writing. Compare against your own engineering time, not a generic hourly rate.
Copy the wrapper script and plist to the hosted machine through the browser console session, place the plist under ~/Library/LaunchAgents/, bootstrap it, and trigger a test run with launchctl kickstart gui/$(id -u)/com.example.data-sync. Confirm a receipt appears at ~/jobs/*/receipt.json and that its exit_code is 0 before you rely on the schedule.
Start or request a configuration at Figment →
Related guides:
TCC permissions for Mac agents · App crash recovery on a hosted Mac · GitHub Actions self-hosted runner on Mac