macOS launchd: one AI video a day with a per-date Idempotency-Key

A launchd agent that wakes late can run twice. A per-date Idempotency-Key and an existing-file check keep a daily 3 s Gemini Omni clip ($0.1125) to one charge.

4 min readSume
All posts

A daily clip for a dashboard, a newsletter header or a kiosk screen is a scheduling job before it is a video job. On a Mac the native scheduler is launchd, and it behaves differently from cron in a way that matters when each run costs money. The launchd.plist manual page on macOS says cron skips invocations while the computer is asleep, while launchd starts the job the next time the computer wakes, and coalesces several missed intervals into one event.

So a laptop that was shut at 9:00 still runs the job later, and a restart or a reload can start it again. Sume's Idempotency-Key header is built for that: a replay returns the original job. The plan below uses one key per calendar day.

The script

It reads the API key from a file outside the plist, so the key does not sit in a world-readable property list. It exits at once if today's file already exists. Otherwise it submits a Gemini Omni Flash job of 3 seconds at 360p, which is $0.0375 a second on Sume, so $0.1125 a day, polls the polling_url, and saves the MP4 through the content redirect. I ran it against a local stand-in server with a short poll delay, then ran it again and it exited without a second request.

#!/usr/bin/env bash
set -euo pipefail
BASE="${SUME_API_BASE:-https://api.sume.com}"
KEY="$(<"${SUME_KEY_FILE:-$HOME/.config/sume/key}")"
AUTH="Authorization: Bearer $KEY"
DAY="$(date +%F)"
OUT="$HOME/Movies/daily-$DAY.mp4"
[ -e "$OUT" ] && exit 0
POLL="$(curl -sS -X POST "$BASE/v1/videos" -H "$AUTH" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: daily-$DAY" \
  -d '{"model":"gemini-omni-flash-1.1","prompt":"Time-lapse of clouds over a harbor","duration":3,"resolution":"360p","aspect_ratio":"16:9"}' \
  | jq -er .polling_url)"
while true; do
  sleep "${POLL_SECONDS:-30}"
  JOB="$(curl -sS "$POLL" -H "$AUTH")"
  case "$(jq -r .status <<<"$JOB")" in
    failed|cancelled) echo "$JOB" >&2; exit 1 ;;
    completed) curl -sSL "$(jq -r '.unsigned_urls[0]' <<<"$JOB")" -H "$AUTH" -o "$OUT"
               echo "$DAY cost $(jq .usage.cost <<<"$JOB")"; exit 0 ;;
  esac
done

The launchd agent

Save this as ~/Library/LaunchAgents/local.daily-video.plist and change the program path. The PATH entry matters, because launchd does not use your shell profile and jq from Homebrew would otherwise not be found. plutil -lint reports the file as valid.

<?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>local.daily-video</string>
  <key>ProgramArguments</key>
  <array>
    <string>/Users/me/bin/daily-video.sh</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key>
    <string>/opt/homebrew/bin:/usr/bin:/bin</string>
  </dict>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Hour</key>
    <integer>9</integer>
    <key>Minute</key>
    <integer>0</integer>
  </dict>
  <key>StandardOutPath</key>
  <string>/tmp/daily-video.log</string>
  <key>StandardErrorPath</key>
  <string>/tmp/daily-video.err</string>
</dict>
</plist>

Why two guards

The file check handles the common case cheaply and offline. The key covers the case where a run is killed after the job was created but before the file was written: the next run submits with the same daily- key and gets the original job back, instead of a second one. Sume's guide describes the replay behavior.

Loading it

Run launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.daily-video.plist to load it, and launchctl kickstart gui/$(id -u)/local.daily-video to test without waiting until 9:00. The log files named in the plist show the cost line the script prints, which is usage.cost from the finished job. At the 3 second Omni price, a month of daily clips is $3.38 for 30 days.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume