- Published on
I wanted a marathon coach. I got an LLC.
Last year, I ran a virtual marathon in 3:57:00 to earn my entry into the NYC Marathon. Once I got in, I decided I wanted an AI coach to help me train.
A reasonable person might have downloaded an app. I formed an LLC, applied to Garmin's developer program, built a webapp, and deployed a small collection of Rust services to AWS.
The project was called Endurolog. I finished NYC in 3:20:55: 36 minutes and 5 seconds faster, or a 15.2% reduction in finish time. My legs did the running. The software helped with the recurring question of what to do with them tomorrow.

The only part of the system that couldn't be deployed remotely. Photo: @mooshtravels.
I'm winding the project down now. Before turning off the infrastructure, I backed up the database and took a look at what I'd actually built and used. The interesting part turned out to be less about generating a training plan, and more about giving a model enough reliable context to keep changing one.
First, acquire a company
I wanted a fairly specific loop: the coach could see my completed activities, discuss what to do next, and send a structured workout back to my Garmin. Instructions on my wrist, with the intervals and targets already programmed. No copy-pasting last week's mileage into a chat window before every run.
When I started, getting official access meant going through Garmin's business-oriented developer program. There wasn't the self-serve personal API key path I wanted. So I set up an LLC, applied, got access, and built the integration.
Naturally, Garmin revamped the program just as I was wrapping up. Its current program overview includes personal access to activity and health data. Training API access, which lets an app send workouts back, is listed under the business tiers. There's also DC Rainmaker's October write-up on the change. If you're building something similar today, check the current access model before incorporating your interval session.
That distinction between reading data and writing workouts mattered. A chatbot with my running history would have been useful. Closing the loop all the way back to the watch made it something I could use while training.
Four Rust services, one machine
The webapp used React and TypeScript, served from S3 through CloudFront. API traffic went through a Rust/Axum gateway to three backend services:
| Component | Responsibility |
|---|---|
| API gateway | JWT validation, rate limiting, routing |
| Identity | Accounts, authentication, athlete profiles |
| Garmin | OAuth, activity ingestion, workout uploads and scheduling |
| Chat / agent | Conversations, context assembly, model calls, planning tools |
The boundaries separated work that changed for different reasons: authentication, vendor-specific integration, and agent behavior. A shared Rust crate held common types, database helpers, authentication utilities, and workout validation. Terraform described the infrastructure; GitHub Actions built the service images and deployed them.
But this was still a personal project. Docker Compose ran the backend, PostgreSQL, and Redis on one EC2 instance. The services shared a database, with separate schemas for identity, Garmin data, conversations, athlete state, and plans. The agent read the training tables directly to build its context.
That bought straightforward queries and fewer moving parts. It also meant shared schema coupling and a single machine as a failure domain. Four services on one host do not magically produce high availability. For this workload, that was a reasonable compromise. If I rebuilt it, a modular monolith would be a serious contender: I liked the code boundaries more than I needed the network boundaries.
The Garmin side had the less glamorous work: OAuth2 with PKCE, token refresh, callbacks, and reconciling external data with local state. Activities were upserted using Garmin's summary identifier so repeated delivery updated the same record. The original JSON was retained alongside normalized fields for queries.
Even the definition of “one workout” needed care. A multisport activity can have a parent record plus individual sport records. Summing all of them would count the same effort twice. The metrics layer explicitly excluded parents and transitions. My training needed progressive overload; my SQL did not need to invent it.
The coach needed a memory with a schema
The most useful example came a couple of months before the race, when I picked up a knee injury. Remembering it across conversations, and tailoring more recovery rides as I got closer to race day, helped a lot.
That sounds small until you imagine opening a fresh chat, asking what to do today, and getting a hard running session because the injury was mentioned three conversations ago.
Endurolog assembled three kinds of persistent context at the start of each turn:
- Athlete state: goals, preferences, physical stats, active injuries, and coach notes.
- Observed training: deterministic summaries computed from completed activities.
- The plan: training phases, weekly targets, and notes, with scheduled workout intents available through tools.
Conversation history supplied the immediate discussion. The other layers survived opening a new conversation. When I shared a goal or injury, the coach could save it through a profile-update tool instead of relying on that sentence remaining in the chat window.
The agent used Claude Sonnet 4.6 through Amazon Bedrock. It streamed responses to the webapp over server-sent events and could call tools to inspect history, update the profile, edit the weekly schedule, or generate a workout. Tool calls and results were saved alongside conversation messages, making it possible to inspect what the agent had actually done. The loop was bounded to ten iterations per turn.
This was a small, explicit tool loop. There was no need for a committee of coaches to debate whether I should do an easy run.
Let SQL count the kilometers
One design decision I particularly like is the separation between measured facts and the model's interpretation of them.
The code computed training snapshots over 7-, 14-, and 28-day windows: sessions, distance, duration, active days, longest sessions, and time split across sports. It also computed the current Monday-to-today totals and recency information. These were formatted into compact text and included automatically in the prompt.
For example, this is the shape of the context, using illustrative values:
7d:
Run: 4 sessions, 38.2km, 3h12m, 4 days active, longest 14.0km
Mon–today (this week so far):
Run: 2 sessions, 18.0km, 1h30m, 2 days active, longest 10.0km
Last activity: yesterday; days since last — Run:1 Bike:4
Those two windows answer different questions. “What have I done recently?” is a rolling window. “How much of this week's plan have I completed?” needs the same calendar boundaries as the plan. The implementation used the athlete's configured timezone for the latter. Comparing a seven-day total against a Monday-to-Sunday target is an easy way to produce confidently wrong advice on a Tuesday.
The model could still query individual activities when it needed more detail. But the baseline context was already there, before it decided which tools to call. That reduced the chance of it prescribing a session without checking recent load, and avoided paying for an extra model/tool round trip just to discover the obvious facts.
This was deliberately lossy compression. Totals cannot capture how a run felt, the weather, or every interval. They are good at answering a defined set of questions consistently. The model's job was to reason from those facts and the conversation, rather than recreate the arithmetic from a pile of activity JSON.
The distinction also kept factual memory refreshable. When a new activity arrived, the next snapshot changed. I didn't need an LLM to rewrite a paragraph about my training and hope it preserved all the numbers.
A plan is not yet a workout
I separated the longer-term plan from a short-term calendar of intents: an easy run, a long run, a recovery ride, perhaps a duration or distance. The precise steps and targets could be generated closer to execution, with fresher context.
That was useful when the knee changed the plan. A calendar entry could express what I was trying to accomplish without committing weeks in advance to a particular set of intervals.
Turning an intent into a workout was a separate operation:
scheduled intent
→ model proposes structured workout
→ Rust deserializes and validates it
→ upload workout to Garmin
→ schedule it on the calendar
→ save the external identifiers locally
The schema details mattered. A heart-rate zone and a custom beats-per-minute range use different fields. Pace targets use meters per second. A syntactically valid JSON object can still describe an invalid workout, so the implementation checked domain rules and fed validation errors back to the model for correction.
There was also a background path: after ingesting an activity, the Garmin service signaled the agent service, which could materialize the next unscheduled intent using the same agent loop. The coach didn't always need me to open the app to move the plan forward.
This is where the distributed-systems bill arrived. Uploading a workout, scheduling it in Garmin, and updating PostgreSQL cannot be one database transaction. If scheduling failed, the code attempted to delete the uploaded workout. A conditional database update let one concurrent worker claim the intent; the losing worker attempted to clean up its external artifacts.
Those were best-effort compensating actions, not an exactly-once guarantee. Similarly, the activity signal was an in-process asynchronous HTTP request, without a durable queue. That kept deployment simple, but a crash could lose a trigger, and failed cleanup could leave an orphan.
For a service other people depended on, I'd consider Temporal to make the upload, scheduling, and compensation sequence a durable workflow. Its execution history lets the workflow resume after a worker failure, with explicit retry and recovery behavior. I'd still need to make the external activities safe to retry and reconcile ambiguous Garmin responses: durable orchestration does not make a third-party API call transactional. A durable outbox would cover the separate boundary between committing an ingested activity and starting that workflow.
What actually happened
The November 3 backup gives a more useful picture than a demo screenshot.1
| Recorded usage | Count |
|---|---|
| Conversations | 58 |
| Messages from me | 192 |
| Assistant text messages | 289 |
| Tool calls within conversations | 235 |
| Saved workouts with an upload timestamp | 65 |
| Running activities, April 22–November 3 | 85 |
| Recorded running distance in that period | 936.5 km |
Of those uploaded workouts, 53 were running, 11 cycling, and one cardio. The conversation log contains 46 successful generate_workout results and 28 successful materialize_workout results. Those measure different steps in the workflow; they aren't 74 additional uploaded workouts. Background turns also ran without saving conversation messages.
There were 36 explicit training-history queries, 26 of which succeeded. I wouldn't label that “26 workouts analyzed”: a query could return multiple activities, and the coach also received training summaries automatically on each turn. Counting a convenient event is easy. Naming what it actually measures takes more care.
Garmin-recorded activities, including road and trail running. Weekly buckets use UTC, matching the stored profile setting. Pace is total recorded duration divided by total distance; heart rate is weighted by recorded duration. These are mixed-session summaries, not matched-effort fitness tests.
The plot is reassuringly untidy. July had much less recorded running. The biggest week before race week was 54.4 km in early August. October's aggregate pace was about 5:13/km, compared with 5:19/km in May, but different routes, intensities, and session lengths make that a poor stand-alone fitness comparison. The July pace was faster still, on far less volume. Optimizing that chart would be a terrible training objective.
The result I care about is 3:57:00 → 3:20:55. On the standard marathon distance, that's roughly 5:37/km → 4:46/km. The backup's NYC activity records a duration of 12,055 seconds, matching 3:20:55, although the watch measured 42.58 km.
I felt that the coaching and recovery adjustments helped. Two different marathons, a year of training, and a sample size of me do not establish how many minutes the AI contributed. I'm happy with the result without assigning a causal coefficient to the chatbot.
Context freshness needs an enforcement mechanism
The archive contains a less flattering example of persistent context. In the final week, the coach initially suggested a tempo session. I reminded it that the marathon was only days away, and it switched to a much lighter race-week recommendation.
That failure exposes a gap in the context-maintenance protocol. Storing a plan and asking an LLM to keep it current leaves too much implicit. The agent needs tools to inspect, correct, supersede, and retire stored facts, along with explicit rules for when those operations must happen. The surrounding system needs to enforce the checks that matter.
For this coach, that would mean recording when a fact was observed, where it came from, and when it needs review; invalidating affected plan assumptions when an injury or race date changes; and requiring reconciliation before generating the next workout. A corrected race date should replace the old value and trigger a check of the training phase. If those facts conflict or cannot be verified, workout generation should pause for clarification. That precondition belongs in the application, where it can be enforced and tested.
The LLM can interpret a correction and propose the appropriate updates. Giving it sufficient tools and a clear maintenance protocol makes that work possible; a prompt saying “remember this” does not. The deterministic activity snapshots already had an explicit refresh mechanism. Goals, injuries, and plans needed an equally deliberate lifecycle.
The tool log had another, more conventional lesson. Ten history queries failed because a PostgreSQL aggregate returned NUMERIC while the Rust result type expected f64. The deterministic snapshot queries and the on-demand history queries were separate code paths; getting one right didn't validate the other. Fluent answers can conceal a broken tool surprisingly well when the model has enough other context to continue.
Across the 235 recorded tool calls, 14 returned errors, including those ten query failures and four rejected schedule operations. That is a count of captured tool results, not a production-wide reliability metric. The backup doesn't contain the request-latency or token-usage telemetry needed to make credible speed or cost claims.
If I continued, I'd prioritize a small replay-based evaluation set: injury remembered across a new conversation, stale race date reconciled, unavailable history made explicit, and duplicate scheduling handled correctly. I'd also add real database contract checks for the aggregates. Better context and observable failure modes would earn their place before another clever prompt.
Run easier, for longer
The running lesson I'm keeping: run easier than you think, for longer than you plan.
Roughly 80% easy, 20% hard is the rule of thumb I'm taking forward. Most training should feel sustainable enough to keep coming back, with the harder sessions chosen deliberately. I want to accumulate consistent work over months, without turning every run into a test of whether I'm getting faster.
It's tempting to make the impressive workout the point. I'd rather make the easy work repeatable, give it time, and arrive at the start line ready to run the race I trained for.
Crossing the finish line
Endurolog made a vague question—“what should I do today?”—actionable enough to end up on my watch. The work underneath involved data access, contracts, state, time boundaries, validation, and partial failures. Familiar engineering problems, with an LLM in the loop and occasionally a sore knee on the other end.
Endurolog put a principle I apply in agent design into a very personal setting: an agent needs a well-maintained account of the world—what happened, what is still true, what was planned, and what has changed. The knee example showed the value of persistent state. The race-week mistake exposed where the mechanisms for maintaining that state were incomplete.
I got a marathon PB, six months of concrete evidence about these design choices in daily use, and an LLC with an unusually specific origin story.
Now I get to turn off the server. The running can stay.
Footnotes
Computed from the November 3, 2026 PostgreSQL backup, scoped to the user owning the sole athlete profile. All 58 conversations contain a user message. The 951 stored message rows comprise 192 user text rows, 289 assistant text rows, 235 tool calls, and 235 tool results. Tool successes mean
is_error = false, not independently verified coaching quality. The activity table contains 179 rows; removing two multisport parents leaves 177, including two older non-running activities from 2024. All 85 running activities fall within April 22–November 3, 2026; the distance includes the marathon and the subsequent recovery run. Weeks with no running rows show zero recorded distance, which cannot distinguish no running from missing data. The monthly panel uses only the six complete calendar months, May–October. Uploaded workout counts describe rows retained in the backup, not necessarily every workout ever created or completed. ↩