All articles

Insight Run, generating a training plan without paying for the LLM twice

Insight Run generates training plans with an LLM. Generation takes up to two minutes, the app can be suspended at any time, and an interrupted call is still billed. It runs in Cloudflare Workflows, in four-week blocks saved one by one.

Insight Run is an iOS running analysis app, available on the App Store. It generates training plans leading up to a race, from 5K to ultra. This post describes how that generation is built.

Training plan
A generated plan, in demo mode.

The constraints

A 16-week plan with detailed sessions is a lot of text for a model to produce. In production, generation took 40 seconds at the median and 99 seconds at the 90th percentile.

Three constraints come on top of these durations. iOS suspends the app shortly after it moves to the background, and a request that lasts more than a minute does not complete. When the client disconnects, Cloudflare gives the Worker only 30 seconds (waitUntil) to finish its work. Finally, the model call is not streamed: if it is interrupted, it is still billed, because the provider has already generated the tokens.

So generation has to continue without the app, and nothing that was already generated should be requested from the model again.

A skeleton computed by the code

The code first computes a deterministic skeleton: the phases (base, build, peak, taper) and the volume of each week. During the build-up, volume grows by at most 10% from one loading week to the next, and one week in four is lighter. The taper lasts one to three weeks depending on the distance.

Weekly skeleton volume for a 16-week half marathon 0 km 25 km 50 km 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Base Build Peak Taper recovery recovery race 30 50 41
Actual skeleton computed by buildPlanSkeleton for a 16-week half marathon, intermediate level, 4 days per week. Weeks 4 and 8, the lighter weeks, come back to 80% of the previous week's volume. Week 16 includes the 21.1 km of the race.

The model then fills in the sessions, in blocks of at most four weeks. The prompt gives it the phase and the target volume of each week. When the answer comes back, the code puts the skeleton phases back, and uses the skeleton volume when the model does not return a valid one. Phases and volumes stay consistent from one block to the next, even though the blocks are generated separately.

Since the blocks do not depend on each other, they can be generated in parallel.

The pipeline

The app creates a job on the Worker, which starts a workflow. The workflow generates the blocks, records progress in KV, then assembles the plan. The app does not keep a connection open: it polls the Worker until the plan is ready.

Generating a plan: the app creates a job, a workflow generates the blocks in parallel and delivers the plan iPhone job saved before the call GET every 3 s POST, GET status, plan Worker Idempotency-Key Workflow weeks 1 to 4 weeks 5 to 8 weeks 9 to 12 weeks 13 to 16 deliver the plan LLM OpenRouter KV progress block cache
The blocks are generated in parallel and each one is a saved step. If the workflow restarts, it resumes at the first unfinished step.

Four calls in parallel

A Cloudflare Worker can open six simultaneous outgoing connections. Generation therefore runs at most four tasks in parallel, and leaves the other two connections for KV reads and writes. Each task is a loop: it takes the next block that has not started yet, calls the model, waits for the answer, then moves on to the next block. There are never more than four model calls at the same time.

Six blocks spread over four tasks task 1 W1-4 W17-20 task 2 W5-8 W21-24 task 3 W9-12 task 4 W13-16 1st wave: four blocks in parallel a free task takes the next block
24-week plan: six blocks, at most four model calls at the same time. Blocks 5 and 6 are taken by the first tasks that become free.

After a first failure, queued blocks no longer start. Those already in flight run to completion: they are already paid for, and their result is kept for the retry.

One Workflows step per block

waitUntil only allows 30 seconds after a disconnection, and does not resume anything after an error. Generation therefore runs in Cloudflare Workflows. A workflow is a sequence of steps: the result of each step is saved, and a failed step can be retried on its own. Each block becomes a step, named after its weeks (weeks 5-8), with a five-minute timeout and a single retry, ten seconds after the failure. At the end of the step, the block is recorded as finished in KV.

A last step assembles the plan and delivers it. A finished block is never generated again, even if the workflow restarts, and the blocks recorded in KV give the app real progress.

The plan is computed from the job’s creation date, not from the date it runs: a resumed run finds the same number of weeks and the same skeleton.

Each generated block is also cached in KV for one hour. The key is a hash of the user and the content of the request: goal, dates, level, skeleton. If a job fails and the same generation is started again, the blocks already produced are reused instead of being paid for a second time. On delivery, these entries are deleted, so that a new request for the same race produces a new plan.

When a block fails

Each answer is validated before it is accepted: number and numbering of the weeks, session types, race placed in the last week. If it is rejected, or if the call fails, the block is requested again from a second model. When the answer was invalid, the reason for the rejection is added to the prompt, so that the model does not return the same output. If both attempts fail, Workflows retries the step once: a block can therefore cost up to four calls.

Attempts for one block: primary model, fallback model, then one step retry Primary model 1st call failure Fallback model + rejection reason failure Step retried once, after 10 s 2nd pass valid valid fails on 2nd pass Block accepted skeleton phases applied, block cached in KV Plan failed error message in the app
The attempts for one block, from generatePlanBlock and the step configuration. Each model call has a 75 s timeout. Blocks already finished are not generated again.

Idempotency, from the iPhone to the server

The app can be suspended right after it starts generation, or lose the response to its request. So it creates a job identifier (a UUID) and saves it on the phone before the call. This identifier goes in the Idempotency-Key header; without a valid UUID, the server returns a 400.

The Worker derives the workflow instance identifier from it: plan-<hash of the user>-<UUID>, where the user is the identifier the app sends in the X-User-ID header. A resent request lands on the existing instance instead of creating a second one, and a job identifier alone does not give access to another user’s plan.

Exchanges between the iPhone, the Worker and the workflow while a plan is generated iPhone Worker Workflow UUID saved POST, Idempotency-Key create, id tied to the user 202 running blocks keep running while the app is suspended GET every 3 s running, 2 of 4 blocks app suspended GET on resume complete, with the plan DELETE, plan saved
The workflow runs on the server while the app is suspended; the app fetches the plan when it comes back.

The app then polls the server every three seconds. It ignores transient network errors and gives up after 20 minutes. If it is suspended, the workflow continues without it. On the next launch or when it comes back to the foreground, it picks up the saved jobs and fetches the plan. If generation is started again for the same goal and the same dates, the app joins the running job instead of creating another one.

Once the plan is saved on the phone, the app deletes the job on the server. A job that is never collected expires after 30 days, according to the Workflows retention period.

On iOS 26 and later, the app asks iOS for execution time with BGContinuedProcessingTask, and the system shows a progress bar. It follows the finished blocks and, between two blocks, the elapsed time, capped at 95%: iOS stops a task whose progress stalls.