Every few weeks, Runna would hand me a plan that looked perfectly reasonable and felt written for nobody in particular. Three easy runs and a tempo. Much the same shape whether I had just raced a quick 5K or not laced up since August. A perfectly good plan for some runner somewhere, only not obviously me.
It is a good app. I stopped paying for it anyway, for four reasons:
- It never felt built from my own history. If it was reading my past runs, it hid the evidence well.
- Runna is about £100 a year, with Strava premium stacked on top. A prompt gets me a plan of similar quality for a few pence each time.
- I still love Strava for the kudos and the nosiness, so that stays. The dull plumbing, exporting runs and keeping the plan current, I wanted gone.
- And, honestly, it looked like a good way to spend a wet weekend.
So I built it: one Apple Shortcut, a small database, and a model that writes the plan. None of it is clever on its own. The interesting part is what each piece is allowed to do.
The piece doing the quiet work is Apple Shortcuts, and specifically Automations. Not the shortcuts you tap, but ones that fire on a trigger by themselves: a time of day, arriving somewhere, an app opening, a workout ending. That last one is what lets the whole sync run without me touching it.
The data
Every run is already sitting in Apple Health: distance, duration, pace, heart rate, elevation. I finish a run, save it to Strava as always, and that is the last thing I do by hand.
The trigger is ending the run itself. A personal Automation watches for the end of a running workout and fires a Shortcut I called Export Runs.
// the trigger: finish a run, the shortcut fires on its own.
That Shortcut does the unglamorous part. It pulls the workout and its GPS route as JSON, then POSTs the lot to an endpoint on this site, with a secret in the header so only I can write to it. The run lands in a Postgres table I own, one row each, and most weeks I forget it is happening at all.
// export workout and route, POST to my table. that is the whole sync.
Strava still gets the run, kudos and all. The difference is that my plan reads from this table, not from whatever a subscription feels like looking at.
The plan is only ever as good as the runs in this table.
The inputs
The form is deliberately short: a goal (build a base, 5K, 10K, half, a faster 5K, or clawing back an old personal best), the days I can actually train, a start date, an optional race date, and one free line for something specific like a finish time.
// the only inputs. everything else is read from my own runs.
Notice what it never asks: how fit I am. It does not need to, because that is already sitting in my runs.
A snapshot of me, not a template
Before the model gets a word in, the server builds a fitness snapshot from my run table. This is the part that turns a generic plan into a personal one, and most of it is quiet arithmetic:
- Easy pace from the slower half of my recent runs, measured rather than hoped for.
- Race predictions from my recent best efforts (Riegel), anchored to a real effort over 1500m so a cheeky 400m sprint cannot flatter me.
- Heart-rate zones from my own observed max, read straight from the runs, instead of the 220-minus-age guess that is wrong for almost everybody.
- Volume trend, my last four weeks against the four before, so week one meets me where I am rather than where I used to be.
My all-time bests go in as well, but only as a far-off ceiling. Come back after two months off and the snapshot can tell, so the plan treats those old paces as a horizon, not a starting line.
What comes back
The snapshot and the goal go in, and a single structured JSON object comes back: a short read on where I am, the pace zones, then every week and its sessions. Stripped right down, the shape is this:
{
"assessment": "A year off, so rebuild easy and add speed late.",
"paces": { "easy": [330, 360] },
"weeks": [
{
"number": 1,
"sessions": [
{
"day": "Mon",
"title": "Easy run",
"distanceM": 3000,
"paceSecPerKm": [330, 360]
}
]
}
]
}
Distances are in metres and paces in seconds per kilometre, so 330 to 360 there means 5:30 to 6:00 /km. The plan page walks that object and renders it: a row per session, a bar per segment, the verdict up top.
The one it wrote for me last time did not flatter: “Sub-25:00 is a big stretch from where you are now, but reachable in 18 weeks with a steady safe build.” Fair, and not what a subscription keen to keep me would have led with.
// real paces throughout, no filler.
The prompt, in layers
For anyone curious what actually gets sent, the prompt is assembled top to bottom in a fixed order:
- Who the model is. A safety-first coach that hands back structured data, not prose.
- The snapshot, as JSON. Pasted in whole and labelled the source of truth, so when a rule of thumb later in the prompt disagrees with my real data, the data wins.
- The output contract. Distances in metres, durations in seconds, and the JSON returned on its own with nothing wrapped around it.
In between sit the goal, the coaching rules and the voice spec. It narrows like a funnel: the lines near the top are context the model can weigh, the lines at the bottom are what the reply gets checked against, and a reply that misses them goes straight back for another pass. The whole thing, with a fictional athlete swapped in for my own data, is there to download and read end to end.
The result
I pick a goal, and half a minute later there is a plan at /running/plan, in paces and heart rates that are genuinely mine. The generator sits behind a secret; the plan it produces is public, if you fancy a nose.
None of which is especially hard, and that is sort of the point. The whole thing is a Postgres table, one Automation, a schema, and roughly a page of prompt. Generating a fresh plan costs a couple of pence; after that, reading it costs nothing. It will not make me faster on its own, and I would not hand it to anyone else as their coach, but it fits one runner rather well, and that runner happens to be me. Strava, for its part, still gets the kudos.
Build your own
If any of this sounds useful, the genuinely good news is that you could stand up a rough version in an afternoon. Most of the effort is deciding what to feed the model and then refusing to let it do the maths. So rather than leave you with a blank page, here is roughly the prompt I would hand Claude Code to get the skeleton up and walking:
Build me a personal running-plan generator.
Stack: a small Postgres table of my past runs (one row each: date,
distance, duration, avg pace, avg + max HR, elevation), an API route
that generates a plan, and a static page that renders the latest one.
Flow:
1. I will feed runs in from Apple Health via an iOS Shortcut, so give me
a POST endpoint that takes a workout as JSON and inserts one row.
2. On "generate", build a fitness snapshot from the table in code, not
in the model: easy pace (the slower half of recent runs), a Riegel
race prediction from my best recent effort, HR zones from my observed
max HR, and a four-week volume trend.
3. Send that snapshot plus the goal to an LLM and ask for the plan as
strict JSON (assessment, pace zones, weeks, sessions). Validate it
against a schema, retry once on failure, and recompute the weekly
totals in code so the numbers always add up.
4. Set the plan's voice in the prompt: numbers over adjectives, no pep
talk, no exclamation marks.
Keep it boring and strongly typed. Ask me anything you need before you start.
From there it is mostly tuning: what goes into the snapshot, how strict the schema is, and how much personality you let the plan keep. If you make something like it, I would love to see it.