Simple LangGraph Bike Training Agent A developer built a LangGraph-based bike training agent that logs workouts, retrieves recent training history, and provides summary statistics. The agent uses a set of tools to manage a JSON-based workout log and is powered by a language model with a cycling coach system prompt. | import json | | | import os | | | from datetime import date, datetime | | | from pathlib import Path | | | from typing import Annotated | | | from langchain litellm import ChatLiteLLM | | | from langchain core.messages import HumanMessage | | | from langchain core.tools import tool | | | from langgraph.prebuilt import create react agent | | | WORKOUTS FILE = Path "workouts.json" | | | def load workouts - list dict : | | | if not WORKOUTS FILE.exists : | | | return | | | return json.loads WORKOUTS FILE.read text | | | def save workouts workouts: list dict - None: | | | WORKOUTS FILE.write text json.dumps workouts, indent=2 | | | @tool | | | def log workout | | | workout type: Annotated str, "Type of workout: ride, interval, recovery, race, etc." , | | | duration min: Annotated int, "Duration in minutes" , | | | distance km: Annotated float | None, "Distance in kilometers, if applicable" = None, | | | avg power w: Annotated int | None, "Average power in watts, if applicable" = None, | | | avg hr bpm: Annotated int | None, "Average heart rate in BPM, if applicable" = None, | | | notes: Annotated str | None, "Any notes about the workout" = None, | | | workout date: Annotated str | None, "Date in YYYY-MM-DD format, defaults to today" = None, | | | - str: | | | """Log a completed bike workout.""" | | | entry = { | | | "date": workout date or date.today .isoformat , | | | "type": workout type, | | | "duration min": duration min, | | | "distance km": distance km, | | | "avg power w": avg power w, | | | "avg hr bpm": avg hr bpm, | | | "notes": notes, | | | } | | | workouts = load workouts | | | workouts.append entry | | | save workouts workouts | | | return f"Logged {workout type} workout on {entry 'date' } {duration min} min ." | | | @tool | | | def get recent workouts | | | days: Annotated int, "Number of days to look back" = 14, | | | - str: | | | """Get recent workouts from the training log.""" | | | workouts = load workouts | | | if not workouts: | | | return "No workouts logged yet." | | | cutoff = date.today .toordinal - days | | | recent = w for w in workouts if date.fromisoformat w "date" .toordinal = cutoff | | | if not recent: | | | return f"No workouts in the last {days} days." | | | lines = | | | for w in sorted recent, key=lambda x: x "date" , reverse=True : | | | parts = f"{w 'date' } | {w 'type' } | {w 'duration min' } min" | | | if w.get "distance km" : | | | parts.append f"{w 'distance km' } km" | | | if w.get "avg power w" : | | | parts.append f"{w 'avg power w' }W avg" | | | if w.get "avg hr bpm" : | | | parts.append f"{w 'avg hr bpm' } bpm" | | | if w.get "notes" : | | | parts.append f"— {w 'notes' }" | | | lines.append " | ".join parts | | | return "\n".join lines | | | @tool | | | def get training stats - str: | | | """Get summary training statistics across all logged workouts.""" | | | workouts = load workouts | | | if not workouts: | | | return "No workouts logged yet." | | | total rides = len workouts | | | total min = sum w "duration min" for w in workouts | | | total km = sum w.get "distance km" or 0 for w in workouts | | | power entries = w "avg power w" for w in workouts if w.get "avg power w" | | | avg power = sum power entries / len power entries if power entries else None | | | by type: dict str, int = {} | | | for w in workouts: | | | by type w "type" = by type.get w "type" , 0 + 1 | | | lines = | | | f"Total workouts: {total rides}", | | | f"Total time: {total min // 60}h {total min % 60}m", | | | f"Total distance: {total km:.1f} km", | | | | | | if avg power: | | | lines.append f"Average power: {avg power:.0f}W" | | | lines.append "By type: " + ", ".join f"{k} {v} " for k, v in by type.items | | | return "\n".join lines | | | SYSTEM PROMPT = """You are a knowledgeable and encouraging cycling coach assistant. | | | Help the user log workouts, review their training history, and provide training advice. | | | When giving recommendations, consider periodization, recovery, and progressive overload. | | | Keep responses concise and practical. Today's date is {today}.""" | | | def main - None: | | | api base = os.environ.get "DATAROBOT ENDPOINT", "https://app.datarobot.com/api/v2" | | | api key = os.environ.get "DATAROBOT API TOKEN", "" | | | llm = ChatLiteLLM | | | model="datarobot/bedrock/anthropic.claude-opus-4-8", | | | api base=api base, | | | api key=api key, | | | | | | tools = log workout, get recent workouts, get training stats | | | system = SYSTEM PROMPT.format today=date.today .isoformat | | | agent = create react agent llm, tools, prompt=system | | | print "Bike Training Assistant type 'quit' to exit \n" | | | messages = | | | while True: | | | try: | | | user input = input "You: " .strip | | | except EOFError, KeyboardInterrupt : | | | print "\nRide on " | | | break | | | if not user input: | | | continue | | | if user input.lower in "quit", "exit", "q" : | | | print "Ride on " | | | break | | | messages.append HumanMessage content=user input | | | result = agent.invoke {"messages": messages} | | | messages = result "messages" | | | reply = messages -1 .content | | | print f"\nCoach: {reply}\n" | | | if name == " main ": | | | main |