A headless loop, a launchd agent that survives reboots, and the four things that will break it.
I run an AI trading desk on a Mac Mini: eight agents and scheduled jobs around the clock, all on a Claude subscription with no API key. It took me a few months of 3am breakages to get Claude Code working while I'm away from the keyboard. Below is the setup that held, plus the failures, so you can skip them.
Claude Code has a non-interactive mode (claude -p "prompt") that prints an answer and exits. You wrap it in a loop, let macOS keep the loop alive, and you get a worker on the subscription you already pay for.
Headless runs reuse the login from your normal session. Open Terminal and run:
claude auth login
claude -p "say OK"
If the second command prints OK, background runs will work too. If it asks you to log in, fix that first. A background run can't answer a login prompt and will fail with nothing on screen.
This version watches a folder. Drop a .txt file with a prompt into ~/worker/inbox and the answer shows up in ~/worker/done. You can swap the folder for an API call or a database later and keep the same loop.
#!/bin/bash
# ~/worker/worker.sh
IN="$HOME/worker/inbox"; OUT="$HOME/worker/done"; LOG="$HOME/worker/worker.log"
mkdir -p "$IN" "$OUT"
while true; do
for job in "$IN"/*.txt; do
[ -e "$job" ] || continue
name=$(basename "$job" .txt)
echo "$(date) start $name" >> "$LOG"
if out=$(claude -p "$(cat "$job")" < /dev/null 2>&1); then
printf '%s\n' "$out" > "$OUT/$name.md"
rm "$job"
else
echo "$(date) FAIL $name: $out" >> "$LOG"
case "$out" in
*"limit"*) sleep 1800 ;; # usage cap: wait it out, retry later
esac
fi
done
sleep 30
done
chmod +x ~/worker/worker.sh
The script deletes a job file only after a successful run. A failed job stays in the inbox and the loop retries it on the next pass.
Close a terminal tab and your loop dies with it. launchd, the macOS service manager, can start your script at login and restart it if it crashes. Save this as ~/Library/LaunchAgents/com.you.claude-worker.plist and change YOU to your username:
<?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.you.claude-worker</string>
<key>ProgramArguments</key>
<array><string>/bin/bash</string><string>/Users/YOU/worker/worker.sh</string></array>
<key>EnvironmentVariables</key>
<dict><key>PATH</key>
<string>/Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string></dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardErrorPath</key><string>/Users/YOU/worker/stderr.log</string>
</dict></plist>
launchctl load ~/Library/LaunchAgents/com.you.claude-worker.plist
echo "Write a haiku about cron jobs" > ~/worker/inbox/test.txt
# wait ~30s
cat ~/worker/done/test.md
PATH. launchd starts your script with a bare PATH, so the first error you'll likely hit is claude: command not found. Run which claude in Terminal and make sure that folder is in the plist's PATH line.
Auth expiry. Your login expires after a while, and a power outage can leave it stale. After that each run fails with a 401 until you log in again in a real terminal. The worker can't do that step for you. Check once an hour with claude -p "say OK" and send yourself an alert if it doesn't come back OK.
Usage caps. Your subscription has a session limit and a weekly limit, and the worker shares both with you. Read the error before you alert on it. My setup fired "auth failing" alarms for four hours one afternoon when I had only hit the session cap. When you see a limit message, have the worker wait and retry.
Sleep. Your Mac runs nothing while it sleeps. On a desktop, set it to never sleep in System Settings or run the worker under caffeinate -s. A laptop with the lid closed stops too, unless you plug it into power and an external display.
Watch your burn. If your agents can create their own follow-up work, they can eat a weekly limit in days. Mine used 11% of my week in a single day because each job queued "monitor" and "finalize" jobs of its own. I fixed it two ways: each job has to finish as done or blocked, and no job may queue new work for itself.
Swap the inbox folder for a real queue. A small web service hands out jobs, and the worker on your Mac claims them with a token, runs them on your subscription, and posts the results back. You never hand the service an API key. If your Mac is off, the jobs wait, so show an "offline" badge to whoever is waiting on them.
This is how The Floor works. The Floor is the trading ops desk I built on this pattern. The hosted side queues the work and a small runner on your machine does it with Claude Code, Codex, Gemini CLI or Ollama, no API key. Paper trading only.