
Olympus is a workout log I built for myself, and Claude is my personal trainer inside it. Claude reads my past sessions, my sleep and my injuries through an MCP server, plans the next session, and sends it to the app once I say yes. I train from the Today screen, log each set with one tap, and press Send to PT (my claude personal trainer project) when I'm done. Then Claude looks at what I actually did and plans the next one.
It started as a Next.js 15 app that installs on my phone as a PWA, and it's now also a native iPhone app. It uses Postgres on Neon with Drizzle, an MCP server with its own OAuth login, Garmin, Whoop and Apple Health for sleep and recovery, a food and supplement log, and an offline queue so I can log sets without signal. I designed the app too, so I'll cover both the design and the code behind it.
Updated November 2026. This case study first covered the web app as it was in early October. Since then I've added the iPhone app, Garmin sync, food logging and supplements. The design side has grown the most, so there are new sections on the decisions I got wrong first, designing for an AI that writes into the app, the food log, supplements and the iPhone app.
Why I Built This
I already had a Claude project set up as my trainer. It knows my DEXA scan results, my blood tests, my past workouts, my diet preferences and more, so the planning side worked well. Getting data in and out of it was the hard part. After every session I had to tell Claude what weights I lifted, how much protein and water I'd had and how I slept, then copy its plan into Obsidian and type every set in by hand at the gym. Doing all of that through a chat window was too much work.
The first version of Olympus, back in April, was a simple set logger with a basic MCP server. It worked, but Claude still had to guess a lot. It didn't know that the number on the iso-lateral press is per side, or that on the assisted pull-up a lower number is harder. It once planned a superset I'd been told to stop doing. The data was in the app, but nothing stopped a bad plan from getting in.
So I rebuilt it. The app holds all the data, and Claude reads and writes to it through a fixed set of tools. The server checks every plan Claude sends before saving it.
Once training worked, the gaps were everything around it. Protein reached the app through a Shortcut, HRV depended on which device I was wearing, and my supplements lived in my head. So Olympus grew from a workout log into a companion for the whole routine: a native app, Garmin and Whoop sync, a food log and a supplement tracker, all in the same database Claude reads.
How It Works
Here's a normal gym day. I tell Claude I'm heading to the gym. It reads my profile, my last two sessions of the type that's due, and my sleep for the week. It shows me the plan as a table and waits. I say yes, and it calls push_plan. The plan shows up on the Today screen with a "From your PT" badge, and I get a notification. I train, log my sets, and press Send to PT at the end. Later I ask Claude to review the session, and it updates my working weights or leaves a note for next time.


The Architecture
It's one Next.js app on Vercel with three kinds of clients: the web app, the native iPhone app, and Claude. The web app's server actions, the iPhone app's /api/v1 routes and the MCP server all call the same service layer in src/server, which calls the same functions in src/domain. So a set I log in the app and a session Claude logs through log_session go through the exact same code.
The src/domain folder has one rule: nothing in it can import the database, Next.js or React. It's plain TypeScript. That makes it easy to test, and it also let me import my old workout history using the same code that handles a live set.

The MCP Server
The server has twelve tools. Five read data, seven write data, and none of them delete anything. Claude can create plans, log sessions, update weights and leave notes, but if something needs deleting, I do it in the app. That one decision means I never have to worry about Claude removing a session by mistake.
Every tool returns JSON with a short summary line, so Claude has something simple to read back to me without repeating the whole response. Writes are safe to retry. push_plan takes a clientRef like pt-2026-10-04-A, and if Claude sends the same plan twice, or I ask it to change something, it updates the existing plan instead of creating a second one.
Every call is saved to an mcp_audit_log table with the tool name, whether it read or wrote, whether it worked, and what it sent. The Settings screen shows this log, so I can always see what Claude changed and when.
The server uses the Streamable HTTP transport in stateless mode. Each request creates a fresh server, with no open connections or session IDs to keep track of, so it runs on Vercel like any other API route. If a request comes in without a token, the server returns a 401 with a header that tells Claude where to log in.

The server also has one prompt, programme_next_session, which walks Claude through planning a session in seven steps. And it has two resources that export my whole log in the same format as my old Obsidian notes.
Plan Validation
Every plan Claude sends through push_plan is checked against ten rules before it's saved. There are two levels. Errors stop the plan from being saved and go back to Claude so it can fix them. Warnings don't stop anything, but they're saved with the plan and show up on the Today screen as "2 notes from validation", so I see them even if Claude doesn't mention them.
Most of these rules came from real mistakes. On 12/08, Claude planned a reverse barbell curl even though my wrist can't handle it. On 13/08, it put incline press and triceps pushdowns in the same superset. On 17/08, a machine squat jumped from 50 to 90 kg in one go. Each of these became a rule, and each rule has a test named after the date it happened.

Blocked exercises are matched word by word, ignoring case, punctuation and plurals, so "straight-bar curl" still matches "Barbell Curl (straight bar)". Claude can still plan a blocked exercise if it gives a reason, and that reason gets saved and shown in the session review. Cardio only goes through the first rule, because a treadmill walk doesn't need rest period checks.
Handling Machine Weights
This took the longest to get right, and the validation rules depend on it. My gym has iso-lateral machines where the part you load plates onto has its own weight. So 25 kg of plates on the horizontal press is really 33.2 kg per side. The assisted pull-up works the other way: the number is how much the machine helps you, so 40 is harder than 47. And cardio is measured in minutes, not kilos.
Each exercise has a load mode: TOTAL, PER_SIDE, COUNTERWEIGHT or TIME. Before anything is saved, the weight is converted to the real load. Checks like "is this heavier than last time" or "is this a PR" flip direction for counterweight machines. Claude only ever sees real weights, and the server instructions tell it that.

Connecting Claude
Custom connectors in Claude log in using OAuth 2.1, as described in the MCP spec. I didn't want to pay for an auth service for an app only I use, so Olympus runs its own OAuth server. When Claude connects, I sign in with the same Google account I use for the app, and approve access on a consent screen.

Wearables and Health Data
A plan is only as good as the recovery data behind it. Each plan has a sleep check: if I slept less than six hours, Claude doesn't increase any weights that day, and if there's no sleep data, it has to ask me first. Whoop tracks my sleep and MyFitnessPal tracks my protein. They don't talk to each other, and only Whoop has an API.

Whoop uses a normal OAuth flow with their v2 API, and I only use the sleep endpoint. There are no webhooks, so the app pulls the data. It gets the last 14 days when I first connect, then syncs at most every 30 minutes while I'm using the app, and again whenever Claude asks for my recovery. To stop two open tabs from syncing at the same time, each sync first claims the slot with a single UPDATE … WHERE last_sync_at < now() - 30 min RETURNING query. Only one request can win it.
Working out which day a night of sleep belongs to was trickier than fetching it. A night counts for the day I woke up, in the timezone I was in, not the UTC date it started. Sleep time is time in bed minus time awake. Only scored sleeps count, naps are ignored, and if there are two main sleeps on one day, the longer one wins.

Apple Health has no web API, and neither does MyFitnessPal. So MyFitnessPal saves to Apple Health, and an iOS Shortcut reads the day's totals and sends them to /api/ingest/health with a personal token. An automation runs the Shortcut every time I close MyFitnessPal, so the numbers are there before I get to the gym. The token is shown once in Settings and stored as a hash.
With three sources writing to the same row, I needed rules for which one wins. Every field remembers where its value came from. For sleep, whatever I entered myself, or told Claude, always wins, and a sync never overwrites it. Protein and water go up during the day, so a sync can raise a number I entered but can't lower it.

I also wear a Garmin watch now, so the app syncs from Garmin's Personal API too: HRV, sleep and its stages, resting heart rate and activities. Garmin hands over workouts as FIT files, which the app parses into heart rate by the second, laps and sets. The first sync covers 90 days, and each one after starts two days before the last, because the watch uploads late.
Three sources meant a new rule for which one to trust. HRV and resting heart rate now come from Garmin first, then Whoop, then Apple Health, and every HRV value keeps whether it was measured as RMSSD or SDNN, so Claude never compares the two. Apple Health's HRV is SDNN and Garmin never writes HRV to Apple Health, so the old order left a Garmin wearer with the wrong number or none at all. A Garmin workout also appears in Apple Health, so the app hides that copy. Whoop is a full fallback for everything it also measures, so switching watches needs no setting.
The Shortcut above is how protein first got in. The food log below replaces it as the source for protein, and a value I type still wins.
Offline Logging
I wanted logging a set to feel instant, with or without signal. So every change I make during a session goes through a queue stored in IndexedDB. That includes logging a set, logging water, resolving a note, finishing, and sending to my PT. If I'm online, it's sent straight away. If I'm not, it waits in the queue in order. Editing the same set three times only keeps the latest version, so it sends once instead of three times. Each change stores the full value rather than the difference, so sending the same one twice does no harm. The queue sends everything when the signal comes back or when I reopen the app. If the server rejects something, the screen undoes it and shows a message.

The screen never waits for the network. When I tick a set, the ✓ shows up, the rest timer starts, and the RPE buttons appear right away. The service worker caches pages as I visit them, and the app never reloads itself when the signal comes back, because reloading in the middle of a set would be the worst time.
A few smaller things help a lot at the gym. The screen stays on during a session using the Wake Lock API. Ticking a set gives a small vibration. On Android that's navigator.vibrate. On iOS 18, it toggles a hidden native switch, which was the only way I found to get a haptic from a web app. If I lock my phone during rest, the server sends a notification when rest is over. If the app is already open, the service worker skips it.
The Database
PostgreSQL on Neon, using Drizzle, with sixteen migrations. The folder is still called supabase/ from the first version. The original schema in April had three tables. The rebuild added plans, recovery data, coach notes, and the tables the OAuth server needs. Since then it has added per-user data, wearable data, activity streams, a food database of more than 14,000 foods and supplement logs, which brings it to 38 tables. The diagram below shows the first fifteen.

Design
The second version used an amber accent and Barlow Condensed, and it looked like every other fitness app. The third, Chalk & Ember, was dark with one bright orange accent. It looked sharp, but it still felt generic: monospace labels, all-caps headings, dots between every bit of text, and a lot of grey boxes that all looked the same.
For this version I looked at apps that feel at home on an iPhone and still have a personality, like Whoop, Gentler Streak, AllTrails, Waterllama and Nike Training Club. I followed Apple's Human Interface Guidelines, switched to the system fonts, and built the design around big blocks of colour. I called it Coral.
Each session type has its own colour, and while I train it fills the top of the screen. Session A is coral, B is berry, C is apricot, and cardio is ink. The Today card, the live session, the Finish screen and the calendar in the Log all use the same colours, so I can tell which day it is from across the room. Everything else sits on warm off-white, with no grey.

For text I use SF Pro, the iPhone's own font, so the app feels like part of the phone, and SF Pro Rounded for numbers and big titles. There's no monospace and no all-caps text anywhere. Labels are in sentence case, and lists are written with commas instead of dots. The weight I'm about to lift is 88px, because I need to read 247 from arm's length with my phone on the floor.

I designed the layout to be used with one hand between sets. Every button is at least 44px, and inputs are 16px so iOS doesn't zoom in. For entering weight I built a custom keypad with ±2.5 buttons, recent weights and the PT's target, because the phone keyboard covers half the screen. Tapping the reps adds one, and goes back to 1 after 15. Weight and reps are filled in from the plan, then last session, then the previous set, so most sets only need one tap.
For barbell lifts and plate-loaded machines, the screen also shows what to put on. 110 kg on the deadlift shows 25 + 20 on each side, and the iso-lateral machines take the weight of the arm into account. If standard plates can't make the weight, it says how far off it is.

I wanted it to feel playful without slowing anything down. The ✓ pops when I log a set, and the next set has a ring that gently pulses so I know where to tap. The tab bar has a white pill that springs to the tab I pick. Plates slide onto the bar, the recovery bubbles fill up with a wave like a glass of water, and finishing a session drops confetti with "Crushed it." or "New PR!". Animations that loop only play a few times and then stop, so they don't drain the battery. If reduced motion is turned on, all animations are instant, but the vibrations stay on.

It also had to feel fast. Every tab has a skeleton that shows up the moment I tap, links load in the background before I need them, and the session screen updates before the server replies. Every button shrinks to 96% when pressed, so a tap always feels like it landed.
Design Decisions I Got Wrong First
Most of the good design in Olympus came from using it at the gym and finding out what was wrong. These are the ones I changed my mind on.
The reps cell was the first. A tap adds one rep and wraps from 15 back to 1. That's the fastest thing for the usual case, but taking a rep off meant cycling through the whole dial. On the web I added a stepper sheet: hold the cell and you get minus and plus buttons and chips from 1 to 15. On the iPhone I first tried something smaller, holding the cell to remove one rep, with a one-time hint under the table header. It was too narrow, so I replaced it with the same sheet and changed the hint to "Hold to edit". Tapping stays the fast path, and holding is the way out when it's wrong.
The Today card was the second. It only previewed four exercises and "+N more", so the only way to read the whole plan was to press Let's go, which starts the session. Claude had just written that plan and I wanted to read it before committing to it. Now a View workout sheet sits next to Let's go and shows every set, load, rest, superset and note from the plan.
The third was what happens when the plan is wrong for the day. A machine is taken, or I feel good and want to add something. The live session now has Add exercise. If I pick one the rules would have blocked, because of an injury, the app warns me first and then lets me do it, and flags it for the PT so it comes up in the review. If an exercise isn't in the library yet, I can create it from the picker. The server is strict with Claude, but the app trusts me and keeps a record.

Designing for an AI That Writes Into the App
Most apps only show what the person typed. Olympus also shows what Claude wrote, and I didn't want to trust it blindly or re-read the chat to find out what changed. So every plan from Claude carries a "From your PT" badge, and validation warnings sit on the Today screen as "2 notes from validation", so I see them even if Claude doesn't mention them. When Claude plans a blocked exercise anyway, the reason it gave is shown in the session review. Everything Claude changed is in the audit log in Settings. The rule behind all of it is that Claude can add to the app, but I can always see exactly what it added.
Designing the Food Log
The food log was the hardest design problem in the app. It's the screen I use most, and the one where the app is most likely to be wrong. Before building it I prototyped three versions: one box that does everything, a sheet with Search, Describe and Barcode tabs, and a describe-first screen. The tabs sheet was the easiest to use, so that's the one I built.
The key decision was splitting finding from checking. The sheet only finds foods. Pick a few, and a sticky "Review 3" bar takes you to a second screen with the amounts, the meal and the time, and one Log button. Describing a meal in a sentence skips straight to Review. Mistakes show up on the screen where they're cheap to fix, and search stays quick because nothing on it needs a decision.

Amounts work like MyFitnessPal, because that's what I already knew. Tap a search row and set a number and a unit, which can be grams, millilitres or a portion like a roti, and the calories and protein update as you type. Changing the unit keeps the weight. Getting units right also fixed a quiet bug: "200 ml milk" was being logged as 200 g, because millilitres were treated as grams. Now they're converted using the food's own density.
Describe is the other way in. I type what I ate, like "2 rotis, dal and a bowl of curd", and the app finds the foods and takes me straight to Review. The screen says what the AI does and doesn't do: it only reads what I ate and estimates the grams, the nutrients come from the food database, and I check the amounts before adding. Every line on Review shows what I said, the food it matched, where the amount came from (a standard portion, or my usual amount), and a Not this? link to swap the match.

Barcode covers packaged food. It opens the camera and reads the code as soon as it is in frame, putting a highlight on the one it found, then looks it up on Open Food Facts, which covers Indian and UAE packaged foods. The product lands on the same Review screen as everything else, so there is one place to check an amount, whichever way the food came in. The camera view in the clip is a capture from my phone, reading a can of Coke Zero, and the rest is the app recorded in the simulator.

The home screen only shows the day. Supplements are one line, and a single Add food button opens the sheet on the tab I used last, so if I always describe my meals, I land on Describe. Each meal's plus button opens the sheet for that meal. Food also became a fifth tab, and the bottom bar's fixed four-column grid overflowed, so the grid now follows the number of tabs.
Because the app looks up foods it doesn't know, I wanted it to be honest about what it isn't sure of. A food found on the web is saved with its source page and marked Unverified, and the app refuses a result that doesn't add up, like calories that don't match the protein, carbs and fat. A wrong food is worse than none, so a failed lookup says "no match" and I can add the food by hand.
Under the screen, a language model turns a sentence into items and grams, and the numbers always come from the database, never from the model. A food the database doesn't have is looked up in this order, cheapest first: my own saved lookups, Open Food Facts, then a Firecrawl search that reads up to three pages and extracts the label. On ten test products it found nine, at about 3.4 Firecrawl credits and under a tenth of a cent of model cost each, and the one it refused was the right call.
Supplements Without Guilt
Supplements were the opposite problem. It's a few seconds a day, done on autopilot, so the design had to be nearly invisible. On the iPhone, checking one off is optimistic: it changes on tap, the request runs behind it, and if the server rejects it the change rolls back. It works offline too, and undoing a check-off while offline replaces the queued one. Each item has Taken, Half and Skip at equal widths, after my first version wrapped its labels, and the time it was taken can be edited.

Whey and collagen count as food. Marking a dose taken posts its protein to the food log, so protein has one source. In the clip, logging whey takes the day from 135 g to 160 g and the protein card flips to Target reached.
I decided against streaks. A dose taken is a fact to record, not a streak to protect, so the record shows the last 14 days and nothing counts up. When a timing rule is broken, like a coffee too close to a medication, the app shows one calm line, with the detail and the source under Timing rules, so a late coffee doesn't feel like an alarm.
The iPhone App
The PWA's haptics needed a hack and its Apple Health link needed a Shortcut, so I built a native SwiftUI app. It talks to the same server through /api/v1, signs in with the same OAuth server using PKCE, and keeps the same offline idea: writes queue on the phone, and every screen shows what it last loaded. It reads Apple Health directly with HealthKit and syncs in the background. The Coral palette lives in a shared file, so the web and the iPhone app use exactly the same colours.
The part I'm happiest with is the workout Live Activity. The Lock Screen and the Dynamic Island show the current exercise and set, the time elapsed, and the rest countdown as a draining ring. +30 and Skip are buttons on the activity itself, so I can change a rest without unlocking my phone. Timers draw from dates, so nothing updates during a rest, and if one runs out while the app is asleep it reads "Rest's up".
I also gave the app a face. The old icon was an amber bolt on black, left over from the first design. The new one is a coral weight plate that is also Olympus at sunrise. Oly, a small snowcapped mountain, appears in lifting, cheering and resting poses on the sign-in screen, the empty history and the finish screen after a clean session.

Form Cues
I wanted a quick form reminder for the lifts I keep getting wrong, one tap away from the set I'm logging. Each one is a 3D figure built in code and animated with keyframes. It uses inverse kinematics so the hands stay on the bar and the feet stay on the floor from any angle. three.js only loads when you open a form cue, so it doesn't slow down the rest of the app, and there's a 2D fallback for phones without WebGL. The animation highlights the muscles being worked and shows joint angles, and it pauses when it's off screen.
In the Coral version the figure stands on the same warm floor as the rest of the app. Coral marks the muscles doing the work, and berry is used for the angles and guides.
It's the biggest file in the project at 1,615 lines, and the one I needed least. I still really like it.

Testing
There are more than 270 tests, and the ones that matter most are named after real sessions. When something went wrong at the gym, I wrote down the date, turned it into a rule, and put that date in the test name. If a test fails, I know exactly which mistake it's there to catch.

My older workouts were in an Obsidian note: eight sessions between 05/08 and 21/09. I copied them into a JSON file, and a script runs them through the same domain code as a live set, then writes out SQL that's safe to run more than once. So the old sessions get the same PRs and flags as anything logged in the app.
Things I'd Change
The Apple Health Shortcut used to be the weakest link, and the native app has mostly replaced it with HealthKit. What's fragile now is Garmin. Its key is opaque, so I enter the expiry date by hand and the app reminds me 14, 7, 3 and 1 days before it lapses.
Whoop syncing is pull-based. Every 30 minutes is fine for sleep, since it only changes once a day, but Whoop supports webhooks, and using them would mean the data is never out of date.
The form cue figure is built in code because the 3D model I wanted can't be redistributed. The engine already supports loading a model from /models/athlete.glb, I just haven't found a free one I like yet.
It started as an app for one person. It's now built to be shared: everything is per user, and a public Build Your Own PT guide explains how to set up the trainer project. Sign-in is still an allowlist, so it isn't open to everyone yet, and I still need to think harder about what Claude should be allowed to see.
Outcome
Olympus is installed on my phone as a native app, connected to Claude on my phone and laptop, and it's what I train with and track my food in now. It has twelve MCP tools, ten validation rules, its own OAuth server, sleep, HRV and workouts from Garmin, Whoop and Apple Health, a food log, a supplement tracker, and offline logging that hasn't lost a set yet.
What made the biggest difference was moving the rules out of the chat and into the server. Before, every session meant typing my weights, sleep, protein and water into a chat and copying plans back into my notes. Now the app records all of that as I train, Claude reads it directly, and the server rejects plans that break the rules.
Tech Stack: Next.js 15 · React 18 · TypeScript · Tailwind CSS · Swift · SwiftUI · HealthKit · ActivityKit · WidgetKit · Postgres (Neon) · Drizzle ORM · NextAuth v5 · Model Context Protocol SDK · OAuth 2.1 · Zod · three.js · IndexedDB · Web Push · next-pwa · Vitest · Garmin Personal API · WHOOP API v2 · Apple Health · Open Food Facts · Firecrawl · OpenRouter · Vercel