How I Automated Daily Garmin Recaps to My Inbox
Building a Mac automation that fetches Garmin health data at 7am, generates AI recaps with Claude CLI, and emails them — built across two sessions with Claude.ai and Claude Code
Ingredients
- Claude.ai — browser-based AI for prototyping scripts and configs ($200/yr)
- Claude Code — terminal CLI for automated recap generation ($200/yr, same account)
- garminconnect — Python library for Garmin Connect API (free)
- Resend — email API for delivery notifications (free tier: 3,000 emails/month)
- macOS launchd — built-in scheduler for automated runs (free)
- A Garmin watch — collecting sleep, body battery, stress, VO2 max data (you already have one)
The Problem: Garmin Data Sits Unused
I wear a Garmin watch 24/7. It tracks sleep stages, body battery, resting heart rate, stress, VO2 max, training status, steps, and yesterday’s activity. All this data syncs to the Garmin Connect app, where it sits in a dashboard I rarely open. The data is there — I just don’t look at it consistently.
What I wanted: a daily health recap delivered to my inbox every morning with the metrics that matter, formatted with context and recommendations. No opening apps. No checking dashboards. Just: wake up, check email, see if I should prioritize recovery or push harder today.
The tools existed (Garmin API, Claude for formatting), but no off-the-shelf solution combined them into an automated morning email. So I built it.
Session 1: The Initial Build with Claude.ai
Pace: Deliberate. Lots of debugging around launchd, Claude CLI tool permissions, and getting the Garmin API working. Claude.ai handled the code generation; I handled the Mac system integration.
Phase 1: Fetch Garmin Data with Python
The first piece was a small program (a Python script) that logs into Garmin Connect and downloads the day’s health data as a structured file. Claude wrote the script from a plain-English description. Running it for the first time took 3 seconds and successfully pulled all nine metrics.
🔧 Developer section: Python fetch script
garmin_fetch.py— uses thegarminconnectlibrary to authenticate and pull metrics- Auto-installs the library if missing (no manual pip install needed)
- Fetches 9 metrics: sleep, body battery, resting heart rate, stress, VO2 max, training status, steps, calories, intensity minutes
- Saves everything to
garmin_raw_data.jsonfor Claude to parse later
First run: worked immediately. Garmin credentials loaded from a local config file, API authenticated, all metrics fetched. Took 3 seconds.
Your Garmin credentials live in a local config file in plaintext. Keep it that way — local only. Two rules before touching git:
- Store the credentials file outside any project folder that could become a git repo. A dedicated directory in your home folder (not inside a project) means there’s no risk of accidental commits, even without a
.gitignore. - If the credentials file is anywhere near a git project, add it to
.gitignoreimmediately and rungit statusbefore every commit to confirm it’s not showing up as a tracked file.
Credentials in a public repo — even briefly — should be treated as compromised. Change the password immediately if that ever happens.
Phase 2: Generate the Recap with Claude CLI
The raw health data needed to become something readable — a formatted summary with a daily focus recommendation and context for each metric. Claude generated a shell script (a sequence of automated commands) that feeds the data directly to the Claude CLI and saves the result as a formatted text file.
🔧 Developer section: Recap generation requirements
- Read the JSON data
- Determine a daily focus mode (Recovery / Improving Fitness / Maintenance) based on body battery and sleep
- Write a markdown recap with sections for each metric
- Skip sections with missing data (don’t show "Sleep: N/A")
- Lead with the highest-value recommendation
Claude.ai generated a shell script (run_recap.sh) — a file that runs a sequence of steps automatically, like a recipe the computer follows:
🔧 Developer section: Shell script steps
- Runs
garmin_fetch.pyto get fresh data - Embeds the entire JSON into a Claude CLI prompt (no file reading, no browser tools)
- Calls
claude -p "..." --allowedTools "Write"to generate and save the recap - Shows a macOS popup with a button to open the recap in Terminal
- The
--allowedTools "Write"flag pre-approves file writes so the script runs unattended at 7am without prompting for permission
Embedding data directly in the prompt (RAW_DATA=$(cat file.json) then passing it in the prompt text) prevents Claude from trying to use browser tools or file readers. All context is in the prompt — Claude just formats and writes.
Phase 3: Schedule with launchd
With the scripts working manually, the last step was making them run automatically every morning. macOS has a built-in scheduler called launchd — similar to a calendar alarm for programs — that can trigger a script at any time, every day, without any manual action required.
🔧 Developer section: launchd scheduling configuration
- Schedules
run_recap.shto run at 7:00am daily - Sets environment variables (PATH, HOME) so Python and Claude CLI are found
- Logs stdout and stderr to
logs/launchd-out.logandlogs/launchd-err.log
Installed the job with:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.jose.garmin-recap.plist
(Not launchctl load — that’s deprecated on modern macOS. Learned that the hard way after the first attempt failed silently.)
Bugs Fixed During Session 1
- zsh heredoc substitution error —
$(cat <<'EOF' ... EOF)doesn’t work in zsh. Fixed by writing the prompt to a temp file withmktempfirst. - Claude CLI browser automation — Claude tried to open Garmin Connect in Chrome instead of reading the embedded JSON. Fixed by adding explicit "DO NOT use browser tools" in the prompt.
- Activity showing 7am zeros —
user_summarywas fetching TODAY’s data at 7am (nearly empty). Fixed by switching to YESTERDAY for completed activity. - Write permission blocking automation — Claude CLI prompted for write approval. Fixed with
--allowedTools "Write".
By the end of Session 1, the system worked: 7am trigger → fetch data → generate recap → show popup. Tested it manually, verified the next morning it ran automatically. Success.
Session 2: Debugging & Email Integration with Claude Code
Pace: Fast debugging, then adding email automation using existing Resend setup from the contact form.
The 401 Unauthorized Error
Woke up on February 28, expected a recap email — didn’t get one. Checked the logs:
requests.exceptions.HTTPError: 401 Client Error: Unauthorized for url: https://connectapi.garmin.com/oauth-service/oauth/preauthorized
The Garmin session had expired overnight. The garminconnect library uses token-based auth that occasionally needs re-authentication. Running the fetch script manually (in Claude Code) fixed it:
python3 garmin_fetch.py → re-authenticated, tokens refreshed, data fetched successfully.
Tomorrow at 7am, it should work again. But I still wanted the recap delivered to my inbox, not just saved to a file.
Adding Email Notifications via Resend
Saving the recap to a file was useful, but delivering it to my inbox meant I’d actually see it. I already had an email-sending account set up from the contact form build, so this was a matter of reusing the same credentials rather than setting up anything new. Claude added the email step to the existing automation script.
🔧 Developer section: Email delivery script
- Reads the generated recap markdown file
- Converts markdown to basic HTML (headings, bold, lists)
- Sends email via Resend API with subject "🏃 Your Garmin Health Recap — [DATE]"
- Pulls
RESEND_API_KEYfrom the existing.env.localfile in the website project - Auto-installs the
resendnpm package if missing
Updated run_recap.sh to call node send-email.js after saving the recap. Tested it:
✅ Email arrived in my inbox in under 2 seconds with today’s health recap.
Resend was already integrated for the contact form. No new account, no new API key, no new environment variables. Just reuse the existing setup. If you’ve built one email-sending feature, the second one is 5 minutes of work.
Auto-Wake with pmset
One problem remained: if the Mac was asleep at 7am, the job wouldn’t run until I woke it (could be hours later). Solution: schedule the Mac to wake at 6:59am every morning.
After debugging the pmset syntax (the man page examples helped), the working command:
sudo pmset repeat wakeorpoweron MTWRFSU 06:59:00
Verified with pmset -g sched:
Repeating power events: wakepoweron at 6:59AM every day
Now the Mac wakes at 6:59am, the recap runs at 7:00am, and the email arrives in my inbox — even if I left it asleep overnight.
Final Output
A fully automated daily health recap system that:
- 6:59am — Mac wakes (if asleep)
- 7:00am —
garmin_fetch.pylogs into Garmin API and fetches 9 health metrics - 7:01am —
claudeCLI reads the JSON, determines focus mode (Recovery/Fitness/Maintenance), and writes a markdown recap - 7:01am —
send-email.jsemails the recap tomy email inboxvia Resend - 7:01am — macOS popup appears with "Open in Terminal" button
Built in ~2 hours initial setup (Claude.ai for Python + shell scripts + launchd) + 45 minutes debugging & email (Claude Code terminal for fixes and Resend integration).
What went fast
- Garmin API integration (garminconnect library handled auth, Claude.ai wrote the fetch script in one shot)
- Claude CLI automation (embedding JSON in prompt worked first try after fixing browser tool issue)
- Email integration (Resend already set up from contact form — just reused the API key)
- Terminal workflow with Claude Code (debugging the 401 error, adding email script, testing — all in one session)
What needed patience
- launchd configuration (deprecated commands, environment variable issues, silent failures — took 3 attempts to get it loaded correctly)
- zsh heredoc syntax (bash and zsh handle heredocs differently — had to switch to temp files)
- Claude CLI tool permissions (browser tools triggered by default, Write tool needed pre-approval for automation)
- pmset repeat syntax (tried 3 different formats before finding
wakeorpoweroninstead ofwake) - Garmin session expiration (401 errors after tokens expire — needs occasional manual re-auth)
Claude.ai vs Claude Code: The Right Tool for the Job
This project used both Claude interfaces, matching Anthropic’s recommended use cases:
- Claude.ai (browser) for Session 1 — exploratory prototyping when you want to see the full code, review it carefully, and decide what to run. Generated complete Python scripts, shell scripts, and launchd configs that I could read through before executing. Perfect for "here’s the problem, show me solutions."
- Claude Code (terminal) for Session 2 — direct file editing and rapid debugging when you already know the structure. Fixed the 401 error, added email integration, tested commands — all without leaving the terminal. Perfect for "fix this specific issue in these files."
From Anthropic’s docs: "Use Claude.ai when you want to explore and understand. Use Claude Code when you want to build and iterate." This project proved it — the browser for initial architecture, the terminal for production debugging.
Both are the same Claude model (Opus 4.6), same account, same $200/yr subscription. The interface changes the workflow, not the intelligence. Choose based on whether you need exploration or execution.
Every morning at 7am: fetch → generate → email. All automated, zero manual work.
The biggest lesson? Garmin data is only useful if you see it. An automated daily email beats a dashboard you never open. And when the automation breaks (401 errors, expired tokens), having Claude Code in the terminal means you can debug and fix it in minutes instead of hours.
Now every morning starts with a health recap in my inbox. Body battery at 60? Prioritize recovery. Sleep score 85+? Push the workout. VO2 max trending down? Add interval training. The data was always there. Now it’s impossible to ignore.