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.

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
doneThe 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
- MAI streaming sessions end at one hour: a chunk plan for long audio
A MAI-Transcribe-2-Streaming session lasts at most one hour; Sume STT jobs cap at 10 minutes. Plan overlap-free chunks and shift each chunk's word times.
- MAI Flash 429 and spend limits vs Sume queued jobs and idempotency
OpenRouter's MAI-Voice-2.1-Flash returns 429 on rate limits and has spend limits. Sume accepts jobs into queued status. See an idempotent double submit.
- MAI Flash defaults to PCM: wrap it as WAV, vs Sume output formats
OpenRouter lists MAI-Voice-2.1-Flash output as mp3 or pcm, default pcm, which will not play as saved. Wrap it as WAV in Python; Sume defaults to mp3.
- MAI-Voice 24 kHz 160 kbps mp3 header vs Sume output_format settings
Microsoft's REST example asks for audio-24khz-160kbitrate-mono-mp3. Sume has no 160 kbps option: mp3 bit rates are 32, 64, 96, 128 or 192 kbps at up to 48 kHz.
Written by Sume