Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Keep an Audit Trail for Jobs on a Hosted Mac

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.

Pick the Figment plan that matches your agent

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.

Define the audit boundary before you write code

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.

FieldWhat to record
run_idISO timestamp + random hex suffix, unique per invocation
started_at / finished_atUTC ISO 8601
inputsName and SHA256 of each input file or hashed parameter value
outputsName, absolute path, byte count, SHA256
exit_codeInteger, written even when the job step fails
log_pathAbsolute 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.

The JSON run receipt schema

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.

Wire launchd to capture logs before your script starts

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 job wrapper script

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.

Failure checks and replay verification

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.

Cost worksheet and when to upgrade

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.

TierMonthlyFit for this workload
Virtual Intel macOS$29One agent, scripted automation, no arm64-only tooling
Physical M-seriesfrom $95Concurrent agents, arm64 binaries, Xcode 27, visionOS
Managed Build Mac$249Includes 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.

Move an existing agent onto the hosted Mac

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


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms