twenty-four: workout generator

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:

  1. Fetch workouts from Intervals.icu that don’t have plans
  2. For each workout, send context to Claude API
  3. Claude generates a structured plan
  4. 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.

The Format Conversion

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.


See also: Part 0: The Platform | Part 1: Building with Claude | Part 2: Calendar Service | Part 3: Gym Service | Part 4: Strava Service | Part 6: AI Recommendations | Part 7: Service Consolidation | Part 8: What’s Next

🤖 Generated with Claude Code

Co-Authored-By: Claude noreply@anthropic.com