Building a Personal Fitness Coach: Wiring My Fitbit Air Into a Local Coaching App
TL;DR: I got tired of my Fitbit Air data just sitting in an app, so I wired it through Google Health into a local Flask app that reads my steps, sleep, resting HR, and HRV, runs them through a rules-based coaching engine tuned for fat loss, and pulls live YouTube workouts matched to whatever metric I'm slacking on. Here's the architecture, the evidence behind the coaching logic, and the three things that broke along the way.
I've worn a Fitbit for a while now, and for most of that time it did exactly what these devices are designed to do: collect a lot of data and hand it back to me as a wall of charts. Steps here, sleep there, a resting heart rate number that trends up or down for reasons the app never bothered to explain. It was measurement without coaching. The device knew I'd slept 6.2 hours and walked 6,400 steps, and its response was to show me a ring that wasn't closed.
What I actually wanted was something that would look at yesterday and tell me the one thing to fix today. Not fifteen metrics — one prioritized call. My goal is specific and unglamorous: drop weight, lose the belly fat, stay fit. So I spent a few weekends building a coach that reads my own data and gives me a ranked to-do list tuned to that goal. This is that project. It's a personal thing I built on my own time to solve my own problem, and it taught me a surprising amount about both health science and the quiet failure modes of OAuth.
The architecture
The whole thing is local-first and boringly simple by design. There are three Python modules doing the real work, a Flask app on top, and two cron jobs keeping it fed.
Data source. My Fitbit Air surfaces through the Google Health API — in Google's console the device literally shows up as "Google Fitbit Air." I authenticate with OAuth against scopes for activity and fitness, health metrics, and sleep, then use the refresh-token flow to pull daily summaries: steps, active/intense minutes, sleep hours, resting heart rate, and HRV. (More on why that refresh token became a recurring headache in the "what broke" section.)
Data layer — health_data.py. This is the piece I'm happiest with, and it's the least clever. A sync step writes the raw pull to local JSON on disk. health_data.py reads that JSON, normalizes it into clean daily metrics, and hands them to everything else. Crucially, it never touches the network. The dashboard reads from disk, so it renders instantly and — this matters — it keeps working when my auth token is dead. Decoupling "fetch" from "display" meant a stale token degrades one background job instead of taking down the whole app. Local-first wasn't an ideology here; it was the thing that kept the dashboard usable the week I couldn't get a fresh token.

Coaching engine — coach.py. This is the brain, and it's deliberately a rules engine, not a model. It compares my rolling metrics against a set of evidence-informed targets — around 9,000 steps, 30 minutes of moderate-to-vigorous activity, 7 hours of sleep — and emits prioritized advice plus the search queries it'll later feed to YouTube. The important word is prioritized. On a recent read it looked at ~6,669 steps/day, ~34 intense minutes/day, and 6.2 hours of sleep and produced this order:
- Dial in a protein-forward, modest deficit — do first.
- Nudge steps up toward 9,000 — next.
- Protect sleep, inch toward 7h+ — next.
Notice what it did not say: "do another workout." That ordering is the single most important design decision in the app, and it's not arbitrary — the evidence section below is basically the argument for why the engine ranks sleep and diet above piling on more training.
Live recommendations — recommend.py. Once the engine knows the day's weak metric, it needs actual workouts, and I did not want to babysit a Google Cloud project or a quota for a single-user app. So YouTube search runs keyless through yt-dlp. A call like yt-dlp "ytsearch15:10000 steps walking workout indoor" --dump-json --flat-playlist scrapes public search results and returns structured metadata — title, channel, duration, view count — with no API key, no OAuth, and no daily ceiling (yt-dlp keyless search reference). The official YouTube Data API v3, by contrast, charges 100 quota units for a single search.list against a default 10,000-unit daily budget — roughly a hundred searches a day before you're cut off. For a personal dashboard, scraping is the right trade. I fetch 15–20 results and bucket them client-side into Quick (≤10 min) and Full sessions, so the app can hand me a ten-minute walk on a busy day or a full session when I've got the time.
Web app — Flask + vanilla JS + Chart.js. The frontend is intentionally unfashionable: server-rendered Flask, a little hand-written JS, Chart.js for the graphs. No build step, no framework churn. It shows the last-7-days cards, the coach's read with its ranked advice, live workouts, food guidance, and a weight/waist logging section so I can watch belly-fat progress as a trend rather than a single scary morning weigh-in.

Automation. A cron job re-syncs from Google Health every six hours so the local JSON stays fresh, and a second job fires a coaching digest to my Telegram at 6:30 AM. That second one is the piece that actually changed my behavior. A dashboard is something you have to remember to open; a message that says "you slept 6.2 hours, protect sleep tonight" while I'm still deciding what to eat for breakfast is coaching that shows up whether I asked for it or not.
The evidence behind the targets
I didn't want the coaching to be vibes. Every threshold in coach.py traces back to something I could point at, and a few of the choices — especially ranking sleep above extra workouts — surprised me enough that I went and read the studies.
Steps: ~7k is the sweet spot, 9k is a defensible stretch. The 10,000-steps number everyone quotes is a marketing artifact, not a research finding. A 2025 Lancet Public Health meta-analysis concludes that 7,000 steps/day is "a more realistic and achievable target" tied to clinically meaningful benefit, with only small incremental gains past that — 10,000 vs 7,000 bought about 10% lower all-cause mortality and, for most other outcomes, "no statistical difference." The 15-cohort meta-analysis of 47,471 adults (Paluch et al., 2022) puts the lowest-risk plateau at roughly 7,000–9,000 steps/day, and a 2024 umbrella review found protection starting as low as ~3,000/day with a hazard ratio of 0.91 per additional 1,000 steps. So when the app tells me ~6,669 steps is "decent" and nudges me toward 9k, it's being honest: I'm already past the protective threshold, and the extra push captures most of what's left rather than chasing a round number.
Activity: 150–300 minutes a week. The WHO 2020 guidelines call for 150–300 minutes/week of moderate activity (or 75–150 vigorous), plus muscle-strengthening on at least two days. That's about 21 minutes a day at the floor. My ~30–36 intense minutes/day clears the floor comfortably and reaches into the "additional benefit" band, which is why the engine hands me a green "good intensity" badge instead of nagging. Worth remembering that roughly 1.8 billion adults — 31% — don't hit even the floor.
Sleep: 7+ hours, and it's higher-leverage than you'd think. The AASM/Sleep Research Society consensus puts the floor at 7 or more hours, with less than 7 associated with weight gain, obesity, and worse cardiometabolic outcomes. That's the threshold the badge logic keys off — 6.2h triggers "protect sleep," 7.1h earns "sleep on point." But the reason sleep sits so high in the priority order comes from a study I keep coming back to. In a randomized crossover from Annals of Internal Medicine (Nedeltcheva et al., 2010), dieters on identical calorie restriction who slept 5.5 vs 8.5 hours lost 55% less fat and 60% more lean mass on the short sleep. Same deficit — sleep was the only variable, and it changed what the body gave up. A 2022 inpatient crossover found sleep restriction drove ~308 extra kcal/day of intake and an 11% rise in visceral fat. On the mechanism, the Wisconsin Sleep Cohort linked short sleep to ~15.5% lower leptin and ~14.9% higher ghrelin — though in honesty the hormone story is part of the picture, not all of it; some controlled studies see the extra eating without the clean hormone shift. Either way, the behavioral outcome is robust: skimp on sleep and you eat more and lose muscle instead of fat. That's why my coach flags a bad night above "just train harder." Under a fixed deficit, protecting sleep protects the quality of the loss.
Belly fat: deficit and protein beat spot training. This is where "you can't out-train a bad diet" stops being a gym cliché and becomes the app's number-one do-first. Spot reduction is a myth — a 2011 trial had people do 5,000+ ab reps over six weeks with no significant change in abdominal fat, waist, or body-fat percentage. Fat comes off systemically, driven by energy balance, not by which muscle you're contracting. A calorie deficit alone cut visceral fat ~16% in 12 weeks with no training intervention, and the good news for my waist-logging UI is that visceral fat gets mobilized early — a shrinking waist can show up before the scale moves much. Protein does double duty: a 43-RCT meta-analysis found higher-protein diets produced 1.6 kg more weight loss, and a controlled-feeding review points to 1.2–1.6 g/kg/day, ~25–30 g per meal, for satiety and lean-mass preservation. Resistance training independently trims visceral fat even without weight loss. So the engine's ordering — deficit and protein first, movement second — is the actual evidence hierarchy, not my opinion.
HRV and resting HR: trends, not scores. My resting HR sits around 66–68 bpm and reads "steady," which is healthy and unremarkable — each +10 bpm associates with ~16% higher all-cause mortality, so the value is in watching the direction. HRV is even more of a trend metric: it has no universal normal range, and population cutoffs are meaningless at the individual level. My ~41–47 ms only means something against my own baseline, and only as a 7-day rolling average — a single dip is noise; a sustained drop is signal. So the app plots HRV as a personal line, never a leaderboard number.
What broke
The parts that make a build story real are the parts that didn't work the first time. Three things ate weekends.
1. macOS Python ships without CA certificates. The very first network call died with SSL: CERTIFICATE_VERIFY_FAILED. Not my code — the python.org macOS build doesn't wire up a trusted CA bundle out of the box, so urllib/requests had nothing to validate Google's TLS against. The fix is to point Python at the certifi bundle (running the bundled Install Certificates.command, or setting SSL_CERT_FILE to certifi.where()). Ten minutes once you know; a genuinely confusing hour if you assume the bug is yours.
2. OAuth "Testing" status silently kills your refresh token every 7 days. This one was maddening because it worked, then didn't, then did again after I re-authed, on a roughly weekly cadence with no code change. The cause is documented: a Google Cloud OAuth consent screen in "Testing" publishing status issues refresh tokens that expire in 7 days, full stop, regardless of scopes beyond basic identity. On day 8 you get a 400 invalid_grant and everything downstream falls over. The subtle part: the clock is really about token use, so an app that refreshes but doesn't consistently call an API can still time out silently. My stopgap was a little reauth.py helper to walk through consent again fast, and this is exactly why I decoupled the data layer from the network — a dead token degrades the sync job, not the dashboard. The real fix is publishing the app to Production, which removes the 7-day TTL. Consider that on my list.
3. YouTube Shorts wouldn't surface reliably. I originally wanted to serve short-form Shorts for the quick-workout slot, but yt-dlp text search doesn't return Shorts dependably — they don't come back as consistent, filterable results. Rather than fight YouTube's markup, I pivoted to duration buckets: fetch normal search results and split them into Quick (≤10 min) and Full using the duration field the scraper already returns. Better outcome, less fragility. Which points at the honest caveat with keyless scraping generally — it depends on YouTube's page structure and needs occasional yt-dlp updates when that structure shifts. The right trade for a personal, no-quota project; I would not build a production multi-user service on it.
What's next, and one honest caveat
The app does the job I built it for. The 6:30 AM Telegram nudge is the part that actually moved my behavior — coaching that comes to me beats a dashboard I have to remember to open. From here I want to publish the OAuth app to Production so the weekly re-auth dance ends, add resistance-training tracking (the evidence for it is too strong to leave out), and maybe layer a small model on top of the rules engine to phrase the advice more like a coach and less like a linter. But the rules are staying underneath — I want to be able to explain why it told me to sleep more, and a transparent rules engine gives me that in a way a black box wouldn't.
One thing I've kept front of mind: this is a builder's story, not medical advice. The engine encodes general-population, evidence-informed targets — steps, activity, sleep, protein, a modest deficit — not personalized clinical guidance. Your needs vary with age, sex, health status, and medication, and anyone with a medical condition or making a big change should talk to a qualified professional. I built a coach for one user, me, out of my own data and a stack of studies. If it nudges you to go build your own, that's the best outcome I could ask for.