We ❤️ Open Source

A community education resource

14 min read

Create your customized running plan: A step-by-step guide using Python, Elasticsearch, and Agno

Use this tutorial to build a goal-driven, AI running coach using Agno and your own health data.

When working out, deciding what workout to do next to achieve a specific goal can be challenging. This blog post will walk you through creating a four-week workout plan as a Notion page with to-do list items based on your workout history to help you run a faster 5K. You can find the complete code for this example on GitHub

At a high level, the solution you will be building will follow these steps:

  1. Get your health data as an XML file
  2. Parse your workout data from the last three months and send it to Elasticsearch.
  3. Use Agno to create a personalized workout plan as a markdown file based on your workout data in Elasticsearch
  4. Send your markdown file to Notion to track your path to a faster 5k 🏃‍♀️

Prerequisites

  • The version of Python that is used is Python 3.12.1 but you can use any version of Python higher than 3.9.
  • This demo uses Elasticsearch version 8.18, but you can use any version of Elasticsearch that is higher than 8.0.
  • Install the required packages:

    pip install elasticsearch agno notion-client
  • You will want to configure an environment variable for your OpenAI API Key, which you can find on the API keys page in OpenAI’s developer portal.


On Mac:

export OPENAI_API_KEY="your_api_key"

On Windows:

setx OPENAI_API_KEY "your_api_key"

Step 1: Getting your fitness data

If you use Apple Health for fitness tracking, you can obtain an XML file containing your health data using the directions under this article’sShare your health and fitness data in XML format” section. 

Similar data is available for Android and Samsung devices, but additional processing may be needed. There is also a sample dataset, which can help run the code outlined in this post.

Read more: 3 ways AI is changing how we build software in 2025

Step 2: Getting your data into Elasticsearch

After you’ve obtained your data, you will need to parse the XML file to find workout data from the past three months and send that data to Elasticsearch. You will want to use a datastore such as Elasticsearch because adding new data from various sources is easy if needed. So if you start using a new app that doesn’t sync to Apple Health, you can add the data to the same index. You can check out the Jupyter Notebook that contains the code for the section to follow along. 

First, you want to import the required libraries. You will use xml.etree.ElementTree to parse the data from your XML file. You will also need to import datetime from the datetime package to work directly with dates robustly and relativedelta from dateutil.relativedelta to get data from a specific period. You will also use the Elasticsearch function from the Elasticsearch Python client to authenticate and connect your Elasticsearch instance and helpers bulk upload the data in your XML file to Elasticsearch. You will also use getpass from the getpass library to securely pass your keys and tokens. 

import xml.etree.ElementTree as ET
from datetime import datetime
from dateutil.relativedelta import relativedelta
from elasticsearch import Elasticsearch, helpers
from getpass import getpass

You will want to create a variable es that connects to your Elasticsearch instance. You can learn more about connecting to Elasticsearch with the Python client by checking out the documentation on the subject.

es = Elasticsearch(
    getpass("Host: "),
    api_key=getpass("API Key: "),
)

Now, you can set up to start parsing the data from your XML file. You will want to set the tree to parse your file. If your data comes from Apple Health, this is usually called export.xml; since this is using the sample dataset, it’s called sample_data.xml. You will also need to define a root for your tree, making parsing the dataset a bit easier. You can also define a variable for three_months_ago, containing workout data from the past three months.

tree = ET.parse('sample_data.xml')
root = tree.getroot()
three_months_ago = datetime.now() - relativedelta(months=3)

You will parse the document to find your workouts from the past three months and store them in a dictionary called docs. You can also ensure that each workout is in the proper format to send to Elasticsearch with attributes for your workout type, start time, end time, distance in miles and kilometers (converted from miles), calories burned, and what device you used. 

docs = []

for workout in root.findall('Workout'):
    start_date_str = workout.attrib['startDate']
    start_date = datetime.strptime(start_date_str[:19], "%Y-%m-%d %H:%M:%S")

    if start_date >= three_months_ago:
        distance_miles = 0.0
        calories = 0.0

        for stat in workout.findall('WorkoutStatistics'):
            if stat.attrib['type'] == "HKQuantityTypeIdentifierDistanceWalkingRunning":
                distance_miles = float(stat.attrib.get('sum', 0))
            elif stat.attrib['type'] == "HKQuantityTypeIdentifierActiveEnergyBurned":
                calories = float(stat.attrib.get('sum', 0))

        doc = {
            'workout_type': workout.attrib.get('workoutActivityType', 'Unknown'),
            'start_time': workout.attrib['startDate'],
            'end_time': workout.attrib['endDate'],
            'distance_miles': distance_miles,
            'distance_km': distance_miles * 1.60934,
            'calories_burned': calories,
            'device': workout.attrib.get('sourceName', 'Unknown')
        }
        
        docs.append(doc)

If there are workouts from the past three months, it will bulk upload them into an index in Elasticsearch called apple-health-workouts. If the bulk upload succeeded, it will let you know how many workouts were indexed successfully. If your dataset doesn’t contain any workouts from the past three months, it will let you know that there are no workouts from the past three months. 

if docs:
    actions = [
        {
            "_index": "apple-health-workouts",
            "_source": doc
        }
        for doc in docs
    ]
    helpers.bulk(es, actions)
    print(f"Successfully indexed {len(docs)} workouts into Elasticsearch!")
else:
    print("No workouts found in the last 3 months.")

If your data is loaded into Elasticsearch properly, you will see something similar to the following: 

Successfully indexed 25 workouts into Elasticsearch!

Read more: How attackers manipulate AI models: Practical lessons in AI security

Step 3: Creating a markdown fitness plan

Once your data is in Elasticsearch, you can create your fitness plan. Agentic AI is well-suited for this task because AI agents can operate independently and perform actions based on their defined parameters. Agno is a lightweight, agentic AI framework enabling you to create Python agents. You will take the last 500 workouts from your Elasticsearch index and pass them to Agno to develop a personalized workout plan as a markdown file. You can also check out an example markdown file and the script you will create.

To start, you will need to import the necessary packages. You will use the Elasticsearch Python client to get your data from the past three months and create a personalized workout plan based on your uploaded data. You will use Agent from agno.agent to generate your fitness plan and OpenAIChat from agno.models.openai to use an OpenAI model. You will also use getpass as you did in the previous step to secure your secrets. 

from elasticsearch import Elasticsearch
from agno.agent import Agent
from agno.models.openai import OpenAIChat
from getpass import getpass

You will want to create a class that stores your Elasticsearch client and index for future use, and create an Elasticsearch query that queries your last 500 workouts, which makes your plan personalized. 

class SimpleWorkoutKnowledge:
    def __init__(self, es_client, index_name):
        self.es = es_client
        self.index_name = index_name

    def query(self, query_text):
        query_body = {
            "_source": ["workout_type", "start_time", "distance_km"],
            "query": {
                "match_all": {}
            },
            "size": 500
        }
        results = self.es.search(index=self.index_name, body=query_body)
        return [hit["_source"] for hit in results["hits"]["hits"]]

Now, you can authenticate and connect to Elasticsearch and define your agent. You will pass in the model you want to use, its description, any information that will allow your agent to better understand the tasks you want it to perform, your workout data and that you want your plan to be saved in Markdown format.

es = Elasticsearch(
    getpass("Host: "),
    api_key=getpass("API Key: "),
)

agent = Agent(
    model=OpenAIChat(id="gpt-4o"),
    description="Personal Running Coach",
    instructions=[
        "Review the user's past running workouts.",
        "Create a running plan based on past distances and frequency.",
        "If there are gaps or missed days, add easier re-entry runs.",
    ],
    knowledge=SimpleWorkoutKnowledge(es, index_name="apple-health-workouts"),
    markdown=True
)

To better understand your personal information, refine your query and submit your data more efficiently. This will execute your agent with these enhancements and save your new plan as a Markdown file.

recent_workouts = agent.knowledge.query("recent workouts")

workouts_text = "\n".join([
    f"Workout on {w['start_time']}: {w['distance_km']} km ({w['workout_type']})"
    for w in recent_workouts
])

final_prompt = (
    f"Here are my recent workouts:\n\n{workouts_text}\n\n"
    "Based on this, create a personalized 4-week running plan for me to run faster."
)

run_response = agent.run(final_prompt, stream=True)
full_text = "".join([chunk.content for chunk in run_response])

You should now have a workout plan stored in the variable full_text. You can create a Markdown file that contains your running plan.

with open("running_plan.md", "w") as f:
    f.write(full_text)

To run this file in your terminal use the following command: 

python plan.py

If everything has gone as planned, you should have a running plan saved as running_plan.md that should look similar to the following:

From your recent workouts, it seems you're gradually building your running distances, 
with notable runs on the 3rd, 10th, 13th, and 15th of April. There are also various 
non-running activities indicating rest and cross-training, which is excellent for 
overall fitness and injury prevention. Let's focus on consistency and gradually 
increasing your speed and endurance over the next four weeks.

### Personalized 4-Week Running Plan

#### Week 1
- **Day 1**: Easy run - 2 km at a comfortable pace. Focus on form.
- **Day 2**: Rest or cross-training (Yoga or light Cycling).
- **Day 3**: Tempo run - 3x800m with 400m rest jog. Aim for a pace slightly faster than usual.
- **Day 4**: Rest or active recovery (Walking).

Step 4: Sending your plan to Notion

The final step could be having your workout plan as a Markdown file. However, you could track your progress robustly by sending your plan to a tool like Notion. In this step, you will convert your 5K training plan from Markdown to Notion’s block format and send it to Notion. Be sure to check out the complete code.

You will first need to import into Client from notion_client, which allows you to authenticate your Notion account to send your fitness plan there. You also need to import re, which will help find Markdown patterns, and getpass will be used as it has to keep your secrets secure. 

from notion_client import Client
import re
from getpass import getpass

You will also need to authenticate to Notion and pass in your parent page ID, which allows you to write a Notion page. You will also need to read the Markdown file and parse it to find the title.

notion = Client(auth=getpass("Notion auth token: "))
PARENT_PAGE_ID = getpass("Parent page id: ") 

FILE_PATH = "running_plan.md"

with open(FILE_PATH, 'r', encoding='utf-8') as file:
    markdown_content = file.read()

title_match = re.search(r'^# (.+)$', markdown_content, re.MULTILINE)
title = title_match.group(1) if title_match else "Running Plan"

At this point, you will want to convert your Markdown-formatted text using RegEx into a Notion-style rich text JSON format, which supports detecting and annotating bold text.

def parse_markdown_text(text):

    bold_pattern = r'\*\*([^*]+)\*\*'
    parts = re.split(r'(\*\*[^*]+\*\*)', text)
    rich_text = []

    for part in parts:
        if re.match(bold_pattern, part):
            rich_text.append({
                "type": "text",
                "text": {"content": part[2:-2]},  # strip the "**"
                "annotations": {"bold": True}
            })
        elif part:
            rich_text.append({
                "type": "text",
                "text": {"content": part}
            })

    return rich_text

To proceed, you must convert your Markdown file into Notion blocks. These blocks transform the training plan text into structured formats suitable for the Notion API, ensuring that your text is efficiently organized and ready for use with the Notion API.

def markdown_to_notion_blocks(content):
    
    blocks = []
    lines = content.split('\n')
    
    i = 0
    while i < len(lines):
        line = lines[i].strip()
        
        if not line:  # Skip empty lines
            i += 1
            continue

        # Markdown Heading (### Week X)
        heading3_match = re.match(r'^### (.+)$', line)
        if heading3_match:
            blocks.append({
                "object": "block",
                "type": "heading_3",
                "heading_3": {
                    "rich_text": [{"type": "text", "text": {"content": heading3_match.group(1)}}]
                }
            })
            i += 1
            continue

        # Training day with bold format: * **Day X**: Activity
        day_match = re.match(r'^\* \*\*(Day \d+)\*\*: (.+)$', line)
        if day_match:
            full_text = f"**{day_match.group(1)}**: {day_match.group(2)}"
            blocks.append({
                "object": "block",
                "type": "to_do",
                "to_do": {
                    "rich_text": parse_markdown_text(full_text),
                    "checked": False
                }
            })
            i += 1
            continue

        # Alt format without bold: * Day X: Activity
        alt_day_match = re.match(r'^\* (Day \d+): (.+)$', line)
        if alt_day_match:
            full_text = f"**{alt_day_match.group(1)}**: {alt_day_match.group(2)}"
            blocks.append({
                "object": "block",
                "type": "to_do",
                "to_do": {
                    "rich_text": parse_markdown_text(full_text),
                    "checked": False
                }
            })
            i += 1
            continue

        # Generic asterisk bullet, maybe with bold
        list_match = re.match(r'^\* (.+)$', line)
        if list_match:
            blocks.append({
                "object": "block",
                "type": "to_do",
                "to_do": {
                    "rich_text": parse_markdown_text(list_match.group(1)),
                    "checked": False
                }
            })
            i += 1
            continue

        # Dash bullet
        dash_match = re.match(r'^- (.+)$', line)
        if dash_match:
            blocks.append({
                "object": "block",
                "type": "to_do",
                "to_do": {
                    "rich_text": parse_markdown_text(dash_match.group(1)),
                    "checked": False
                }
            })
            i += 1
            continue

        # Checkbox format: - [x] Activity
        todo_match = re.match(r'^- \[([ xX])\] (.+)$', line)
        if todo_match:
            checked = todo_match.group(1).lower() == 'x'
            blocks.append({
                "object": "block",
                "type": "to_do",
                "to_do": {
                    "rich_text": parse_markdown_text(todo_match.group(2)),
                    "checked": checked
                }
            })
            i += 1
            continue

        # Fallback: plain paragraph
        blocks.append({
            "object": "block",
            "type": "paragraph",
            "paragraph": {
                "rich_text": parse_markdown_text(line)
            }
        })
        i += 1
    
    return blocks

Finally, you can send your fitness plan to Notion. It will also print out information about your page that can be helpful for troubleshooting.

def create_notion_page():
    try:
        # Create the page
        page = notion.pages.create(
            parent={"page_id": PARENT_PAGE_ID},
            properties={
                "title": {
                    "title": [{"text": {"content": title}}]
                }
            }
        )
        
        notion.blocks.children.append(
            block_id=page["id"],
            children=blocks
        )
        
        print(f"✅ Created Notion page: {title}")
        print(f"🔗 URL: {page.get('url')}")
        
        todo_count = sum(1 for block in blocks if block["type"] == "to_do")
        checked_count = sum(1 for block in blocks if block["type"] == "to_do" and block["to_do"]["checked"])
        
        print(f"📋 To-do items: {todo_count} ({checked_count} completed)")
        
        return page
    
    except Exception as e:
        print(f"❌ Error: {str(e)}")
        return None

result = create_notion_page()

If everything worked well, your output should contain the title of the Notion page you created, a link to your running plan, and the number of to-list items as the output, which could be helpful for troubleshooting if needed.

✅ Created Notion page: Running Plan
🔗 URL: https://www.notion.so/Running-Plan
📋 To-do items: 32 (0 completed)

In Notion your running plan would look like this:

Running plan screenshot from Notion.

Conclusion

Building a four-week 5K training plan is just the start of what you can make using Agentic AI. As a next step, consider expanding this to have the data dynamically loaded regularly or make use of the multi-agent capabilities of Agno. You can also easily extend this application to include other data sources, create a meal plan to go along with your training, make this running plan longer than four weeks, or change the distance to 10K or a half marathon.

More from We Love Open Source

About the Author

Jessica Garson is a Python programmer, educator, and artist. She currently works at Elastic as a Senior Developer Advocate.

Read Jessica Garson's Full Bio

The opinions expressed on this website are those of each author, not of the author's employer or All Things Open/We Love Open Source.

Want to contribute your open source content?

Contribute to We ❤️ Open Source

Help educate our community by contributing a blog post, tutorial, or how-to.

This year we're hosting two world-class events!

Check out the AllThingsOpen.ai summary and join us for All Things Open 2025, October 12-14.

Open Source Meetups

We host some of the most active open source meetups in the U.S. Get more info and RSVP to an upcoming event.