The workout generator creates structured workout plans for activities that don’t have them in Intervals.icu.
Uses the Claude API to generate plans dynamically. No templates, no static rules - just context and inference.
Using the Claude API
The service is simple:
- Fetch workouts from Intervals.icu that don’t have plans
- For each workout, send context to Claude API
- Claude generates a structured plan
- Apply it to the workout
The API call:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
func GeneratePlan(workout Workout) (string, error) {
prompt := fmt.Sprintf(`Generate a structured workout plan for:
Type: %s
Duration: %d minutes
Name: %s
Description: %s
Recent training context:
%s
Format as plain text with sections (Warmup, Main Set, Cooldown).
Keep it concise - this will display on a watch.
Use appropriate pacing zones and intervals.`,
workout.Type,
workout.Duration / 60,
workout.Name,
workout.Description,
getRecentTrainingContext(workout))
response, err := claudeClient.Messages.Create(context.Background(), &anthropic.MessageCreateParams{
Model: "claude-sonnet-4-20250514",
MaxTokens: 1024,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
},
})
if err != nil {
return "", err
}
return response.Content[0].Text, nil
}
|
Training context includes recent workouts, current fitness level, upcoming races - anything that might inform the plan.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
func getRecentTrainingContext(workout Workout) string {
// Fetch last 7 days of workouts
recent, _ := fetchRecentWorkouts(7)
var context strings.Builder
context.WriteString("Last week's training:\n")
for _, w := range recent {
context.WriteString(fmt.Sprintf("- %s: %d min\n", w.Type, w.Duration/60))
}
// Add upcoming races if any
races, _ := fetchUpcomingRaces()
if len(races) > 0 {
context.WriteString("\nUpcoming races:\n")
for _, r := range races {
context.WriteString(fmt.Sprintf("- %s in %d days\n", r.Name, r.DaysUntil))
}
}
return context.String()
}
|
Claude sees the full picture. Not just “generate a 45-minute swim workout” but “generate a 45-minute swim workout for someone who swam 30 minutes yesterday and has a triathlon in 3 weeks”.
What Claude Generates
Example 1: Recovery swim (day after hard workout)
1
2
3
4
5
6
7
8
9
|
Warmup
- 200yd easy, focus on form
Recovery Set
- 1200yd continuous @ 65-70% effort
- Every 4th length backstroke for active recovery
Cooldown
- 200yd easy
|
Example 2: Tempo run (race prep)
1
2
3
4
5
6
7
8
9
|
Warmup
- 10 min easy build
Tempo Block
- 20 min @ threshold (comfortably hard)
- Stay relaxed, controlled breathing
Cooldown
- 10 min easy
|
Example 3: Bike intervals (fresh legs, no recent hard efforts)
1
2
3
4
5
6
7
8
9
|
Warmup
- 15 min progressive 50-70%
Main Set
- 5x (4 min @ 90-95%, 3 min @ 60%)
- Hold steady power, controlled cadence
Cooldown
- 10 min @ 50%
|
Notice: No rigid templates. The recovery swim has continuous effort, not intervals. The tempo run uses “comfortably hard” instead of a percentage. The bike workout suggests 5 intervals instead of 4 because the legs are fresh.
This is inference, not templating.
Handling Edge Cases
Claude adapts to weird inputs.
Short workout (20 min swim):
1
2
3
4
5
6
7
8
|
Warmup
- 100yd easy
Main
- 800yd steady
Cool
- 100yd easy
|
No elaborate warmup. Just get in, swim, get out.
Long workout (90 min bike):
1
2
3
4
5
6
7
8
9
10
11
12
13
|
Warmup
- 20 min progressive 50-75%
Endurance Block
- 50 min @ 70-80%
- Settle into rhythm, stay aerobic
Tempo Section
- 15 min @ 85%
- Push a bit but sustainable
Cooldown
- 5 min easy spin
|
Splits it into blocks. Adds variety. Keeps it interesting.
Ambiguous name (“Workout”):
1
2
3
4
5
|
General Workout
- 40 min mixed effort
- Include dynamic warmup
- Alternate moderate and easy efforts
- Cool down with stretching
|
When the type is unclear, Claude defaults to something reasonable.
Claude generates plain text. Intervals.icu needs JSON.
The service parses the plain text and converts it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
func convertToICUFormat(plan string) (string, error) {
// Send the plan back to Claude with conversion instructions
prompt := fmt.Sprintf(`Convert this workout plan to Intervals.icu JSON format:
%s
Output as JSON with this structure:
{
"steps": [
{"duration": <seconds>, "target": <intensity 0-100>, "text": "<description>"},
...
]
}
Use these intensity mappings:
- easy/recovery: 60
- steady/moderate: 70
- tempo/threshold: 85
- hard/VO2: 95
For distance-based steps (yards), calculate duration assuming 1:30/100yd pace.`,
plan)
response, _ := claudeClient.Messages.Create(...)
return response.Content[0].Text, nil
}
|
This is slightly lazy - calling Claude twice (once for plan, once for conversion) - but it works. Claude understands both formats and does the conversion perfectly.
Alternative approach: Give Claude the JSON schema upfront and have it generate JSON directly. But plain text is easier to debug and read.
Cost & Rate Limits
Claude API pricing (as of Oct 2025):
- Sonnet 4: $3 per million input tokens, $15 per million output tokens
Each workout plan:
- ~500 input tokens (prompt + context)
- ~200 output tokens (generated plan)
Cost per plan: ~$0.004
With ~10-15 new workouts per week needing plans:
- Weekly cost: ~$0.05
- Monthly cost: ~$0.20
Negligible.
Rate limits are generous (50 requests/min). The service processes workouts sequentially, so ~10-15 API calls per hour. No issues.
🤖 Generated with Claude Code
Co-Authored-By: Claude noreply@anthropic.com