Skip to main content
Glama

get-activity-streams

Retrieve detailed time-series data streams from Strava activities for analyzing workout metrics, visualizing routes, and performing activity analysis with flexible resolution and format options.

Instructions

Retrieves detailed time-series data streams from a Strava activity. Perfect for analyzing workout metrics, visualizing routes, or performing detailed activity analysis.

Key Features:

  1. Multiple Data Types: Access various metrics like heart rate, power, speed, GPS coordinates, etc.

  2. Flexible Resolution: Choose data density from low (~100 points) to high (~10000 points)

  3. Smart Pagination: Get data in manageable chunks optimized for LLM context limits

  4. Rich Statistics: Includes min/max/avg for numeric streams

  5. Dual Format Support: Compact (LLM-optimized) or verbose (human-readable)

  6. Intelligent Downsampling: Automatically reduce large datasets while preserving key features

Format Options:

  • compact (default): Raw arrays, minified JSON, ~70-80% smaller payloads, ideal for LLM processing

  • verbose: Human-readable objects with formatted values, backward compatible with legacy format

Common Use Cases:

  • Analyzing workout intensity through heart rate zones

  • Calculating power metrics for cycling activities

  • Visualizing route data using GPS coordinates

  • Analyzing pace and elevation changes

  • Detailed segment analysis

Output Format:

  1. Metadata: Activity overview, available streams, data points, units, format info

  2. Statistics: Summary stats for each stream type (max/min/avg where applicable)

  3. Data: Time-series data in compact arrays or verbose objects (based on format parameter)

Notes:

  • Requires activity:read scope

  • Not all streams are available for all activities

  • Older activities might have limited data

  • Large activities are automatically chunked to ~50KB per message

  • Use max_points parameter to downsample very large activities intelligently

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
idYesThe Strava activity identifier to fetch streams for. This can be obtained from activity URLs or the get-activities tool.
typesNoArray of stream types to fetch. Available types: - time: Time in seconds from start - distance: Distance in meters from start - latlng: Array of [latitude, longitude] pairs - altitude: Elevation in meters - velocity_smooth: Smoothed speed in meters/second - heartrate: Heart rate in beats per minute - cadence: Cadence in revolutions per minute - watts: Power output in watts - temp: Temperature in Celsius - moving: Boolean indicating if moving - grade_smooth: Road grade as percentage
resolutionNoData resolution. Affects number of data points returned: - low: ~100 points (recommended for LLM analysis) - medium: ~1000 points - high: ~10000 points (warning: very large payload, may cause slowness) Defaults to "low" when omitted. Pass explicitly if you need more data.
series_typeNoOptional base series type for the streams: - time: Data points are indexed by time (seconds from start) - distance: Data points are indexed by distance (meters from start) Useful for comparing different activities or analyzing specific segments.distance
pageNoOptional page number for paginated results. Use with points_per_page to retrieve specific data ranges. Example: page=2 with points_per_page=100 gets points 101-200.
points_per_pageNoOptional number of data points per page. Special values: - Positive number: Returns that many points per page - -1: Returns ALL data points split into multiple messages (~1000 points each) Use -1 when you need the complete activity data for analysis.
formatNoOutput format: - compact: Raw arrays, minified JSON (~70-80% smaller, LLM-friendly) - verbose: Human-readable objects with formatted values (backward compatible)compact
max_pointsNoMaximum number of data points to return. If activity exceeds this, data will be intelligently downsampled while preserving peaks and valleys. Useful for very large activities.
summary_onlyNoIf true, returns only metadata and statistics (min/max/avg) without raw stream data. Much faster and smaller response. Ideal for quick activity overviews or when raw data is not needed.

Implementation Reference

  • The "get-activity-streams" tool definition and execution logic.
    export const getActivityStreamsTool = {
        name: 'get-activity-streams',
        description: 
            'Retrieves detailed time-series data streams from a Strava activity. Perfect for analyzing workout metrics, ' +
            'visualizing routes, or performing detailed activity analysis.\n\n' +
            
            'Key Features:\n' +
            '1. Multiple Data Types: Access various metrics like heart rate, power, speed, GPS coordinates, etc.\n' +
            '2. Flexible Resolution: Choose data density from low (~100 points) to high (~10000 points)\n' +
            '3. Smart Pagination: Get data in manageable chunks optimized for LLM context limits\n' +
            '4. Rich Statistics: Includes min/max/avg for numeric streams\n' +
            '5. Dual Format Support: Compact (LLM-optimized) or verbose (human-readable)\n' +
            '6. Intelligent Downsampling: Automatically reduce large datasets while preserving key features\n\n' +
            
            'Format Options:\n' +
            '- compact (default): Raw arrays, minified JSON, ~70-80% smaller payloads, ideal for LLM processing\n' +
            '- verbose: Human-readable objects with formatted values, backward compatible with legacy format\n\n' +
            
            'Common Use Cases:\n' +
            '- Analyzing workout intensity through heart rate zones\n' +
            '- Calculating power metrics for cycling activities\n' +
            '- Visualizing route data using GPS coordinates\n' +
            '- Analyzing pace and elevation changes\n' +
            '- Detailed segment analysis\n\n' +
            
            'Output Format:\n' +
            '1. Metadata: Activity overview, available streams, data points, units, format info\n' +
            '2. Statistics: Summary stats for each stream type (max/min/avg where applicable)\n' +
            '3. Data: Time-series data in compact arrays or verbose objects (based on format parameter)\n\n' +
            
            'Notes:\n' +
            '- Requires activity:read scope\n' +
            '- Not all streams are available for all activities\n' +
            '- Older activities might have limited data\n' +
            '- Large activities are automatically chunked to ~50KB per message\n' +
            '- Use max_points parameter to downsample very large activities intelligently',
        inputSchema,
        execute: async ({ id, types = ['time', 'distance', 'heartrate', 'cadence', 'watts'], resolution: rawResolution, series_type, page = 1, points_per_page = 100, format = 'compact', max_points, summary_only = false }: GetActivityStreamsParams) => {
            // Default resolution to 'low' for LLM-friendly payloads (issue #14).
            // This intentionally changes prior behavior where omitting resolution returned
            // the full native resolution (often 'high', ~10000 points), causing slow responses.
            // Callers who need more data should explicitly pass resolution: 'medium' or 'high'.
            const resolution = rawResolution ?? 'low';
            const token = process.env.STRAVA_ACCESS_TOKEN;
            if (!token) {
                return {
                    content: [{ type: 'text' as const, text: '❌ Missing STRAVA_ACCESS_TOKEN in .env' }],
                    isError: true
                };
            }
    
            try {
                // Set the auth token for this request
                stravaApi.defaults.headers.common['Authorization'] = `Bearer ${token}`;
                
                // Build query parameters
                // Always send resolution to control payload size (defaults to 'low' for issue #14)
                const params: Record<string, any> = {};
                params.resolution = resolution;
                if (series_type) params.series_type = series_type;
    
                // Convert query params to string
                const queryString = new URLSearchParams(params).toString();
                
                // Build the endpoint URL with types in the path
                const endpoint = `/activities/${id}/streams/${types.join(',')}${queryString ? '?' + queryString : ''}`;
                
                const response = await stravaApi.get<StreamSet>(endpoint);
                let streams = response.data;
    
                if (!streams || streams.length === 0) {
                    return {
                        content: [{ 
                            type: 'text' as const, 
                            text: '⚠️ No streams were returned. This could mean:\n' +
                                  '1. The activity was recorded without this data\n' +
                                  '2. The activity is not a GPS-based activity\n' +
                                  '3. The activity is too old (Strava may not keep all stream data indefinitely)'
                        }],
                        isError: true
                    };
                }
    
                // At this point we know streams[0] exists because we checked length > 0
                let referenceStream = streams[0]!;
                let totalPoints = referenceStream.data.length;
                let wasDownsampled = false;
                let originalPoints = totalPoints;
    
                // Generate stream statistics on the ORIGINAL data (before downsampling)
                // so that summary_only mode always reflects the full activity
                const streamStats: Record<string, any> = {};
                streams.forEach(stream => {
                    const data = stream.data;
                    let stats: any = {
                        total_points: data.length,
                        resolution: stream.resolution,
                        series_type: stream.series_type
                    };
    
                    // Add type-specific statistics (guard against empty arrays)
                    switch (stream.type) {
                        case 'heartrate':
                            const hrData = data as number[];
                            if (hrData.length > 0) {
                                stats = {
                                    ...stats,
                                    max: Math.max(...hrData),
                                    min: Math.min(...hrData),
                                    avg: Math.round(hrData.reduce((a, b) => a + b, 0) / hrData.length)
                                };
                            }
                            break;
                        case 'watts':
                            const powerData = data as number[];
                            if (powerData.length > 0) {
                                stats = {
                                    ...stats,
                                    max: Math.max(...powerData),
                                    avg: Math.round(powerData.reduce((a, b) => a + b, 0) / powerData.length),
                                    normalized_power: calculateNormalizedPower(powerData)
                                };
                            }
                            break;
                        case 'velocity_smooth':
                            const velocityData = data as number[];
                            if (velocityData.length > 0) {
                                stats = {
                                    ...stats,
                                    max_kph: Math.round(Math.max(...velocityData) * 3.6 * 10) / 10,
                                    avg_kph: Math.round(velocityData.reduce((a, b) => a + b, 0) / velocityData.length * 3.6 * 10) / 10
                                };
                            }
                            break;
                    }
    
                    streamStats[stream.type] = stats;
                });
    
                // Summary-only mode: return metadata and statistics without raw data
                // This runs BEFORE downsampling so stats reflect the full activity
                if (summary_only) {
                    const jsonArgs: [any, any, any] = format === 'compact'
                        ? [null, undefined, undefined]
                        : [null, null, 2];
                    return {
                        content: [{
                            type: 'text' as const,
                            text: JSON.stringify({
                                metadata: {
                                    available_types: streams.map(s => s.type),
                                    total_points: totalPoints,
                                    resolution: referenceStream.resolution,
                                    series_type: referenceStream.series_type
                                },
                                statistics: streamStats
                            }, jsonArgs[1], jsonArgs[2])
                        }]
                    };
                }
    
                // Apply downsampling if max_points is specified
                if (max_points && totalPoints > max_points) {
                    originalPoints = totalPoints;
                    streams = streams.map(stream => ({
                        ...stream,
                        data: downsampleStream(stream, max_points)
                    }));
                    referenceStream = streams[0]!;
                    totalPoints = referenceStream.data.length;
                    wasDownsampled = true;
                }
    
                // Special case: return all data in multiple messages if points_per_page is -1
                if (points_per_page === -1) {
                    // Calculate optimal chunk size based on format
                    const CHUNK_SIZE = calculateOptimalChunkSize(totalPoints, streams.length, format);
                    const numChunks = Math.ceil(totalPoints / CHUNK_SIZE);
    
                    // Return array of messages
                    return {
                        content: [
                            // First message with metadata
                            {
                                type: 'text' as const,
                                text: `📊 Activity Stream Data (${totalPoints} points)\n` +
                                      `Will be sent in ${numChunks + 1} messages:\n` +
                                      `1. Metadata and Statistics\n` +
                                      `2-${numChunks + 1}. Stream Data (${CHUNK_SIZE} points per message)\n\n` +
                                      `Message 1/${numChunks + 1}:\n` +
                                      JSON.stringify({
                                          metadata: {
                                              available_types: streams.map(s => s.type),
                                              total_points: totalPoints,
                                              total_chunks: numChunks,
                                              chunk_size: CHUNK_SIZE,
                                              resolution: referenceStream.resolution,
                                              series_type: referenceStream.series_type,
                                              format,
                                              units: {
                                                  time: 'seconds',
                                                  distance: 'meters',
                                                  altitude: 'meters',
                                                  velocity_smooth: 'meters_per_second',
                                                  heartrate: 'beats_per_minute',
                                                  cadence: 'revolutions_per_minute',
                                                  watts: 'watts',
                                                  temp: 'celsius',
                                                  grade_smooth: 'percent',
                                                  latlng: '[latitude, longitude]',
                                                  moving: 'boolean'
                                              },
                                              stream_descriptions: {
                                                  time: 'Time elapsed from activity start',
                                                  distance: 'Cumulative distance from start',
                                                  altitude: 'Elevation above sea level',
                                                  velocity_smooth: 'Smoothed speed',
                                                  heartrate: 'Heart rate',
                                                  cadence: 'Pedal/step cadence',
                                                  watts: 'Power output',
                                                  temp: 'Temperature',
                                                  grade_smooth: 'Road grade percentage',
                                                  latlng: 'GPS coordinates [latitude, longitude]',
                                                  moving: 'Whether athlete was moving'
                                              },
                                              ...(wasDownsampled && { downsampled: true, original_points: originalPoints })
                                          },
                                          statistics: streamStats
                                      }, format === 'compact' ? undefined : null, format === 'compact' ? undefined : 2)
                            },
                            // Data messages
                            ...Array.from({ length: numChunks }, (_, i) => {
                                const chunkStart = i * CHUNK_SIZE;
                                const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, totalPoints);
                                const streamData: Record<string, any> = { data: {} };
    
                                // Process each stream for this chunk
                                streams.forEach(stream => {
                                    const chunkData = stream.data.slice(chunkStart, chunkEnd);
                                    const processedData = format === 'compact' 
                                        ? formatStreamDataCompact(stream, chunkData)
                                        : formatStreamDataVerbose(stream, chunkData);
                                    streamData.data[stream.type] = processedData;
                                });
    
                                return {
                                    type: 'text' as const,
                                    text: `Message ${i + 2}/${numChunks + 1} (points ${chunkStart + 1}-${chunkEnd}):\n` +
                                          JSON.stringify(streamData, format === 'compact' ? undefined : null, format === 'compact' ? undefined : 2)
                                };
                            })
                        ]
                    };
                }
    
                // Regular paginated response
                const totalPages = Math.ceil(totalPoints / points_per_page);
    
                // Validate page number
                if (page < 1 || page > totalPages) {
                    return {
                        content: [{ 
                            type: 'text' as const, 
                            text: `❌ Invalid page number. Please specify a page between 1 and ${totalPages}`
                        }],
                        isError: true
                    };
                }
    
                // Calculate slice indices for pagination
                const startIdx = (page - 1) * points_per_page;
                const endIdx = Math.min(startIdx + points_per_page, totalPoints);
    
                // Process paginated stream data
                const streamData: Record<string, any> = {
                    metadata: {
                        available_types: streams.map(s => s.type),
                        total_points: totalPoints,
                        current_page: page,
                        total_pages: totalPages,
                        points_per_page,
                        points_in_page: endIdx - startIdx,
                        format,
                        units: {
                            time: 'seconds',
                            distance: 'meters',
                            altitude: 'meters',
                            velocity_smooth: 'meters_per_second',
                            heartrate: 'beats_per_minute',
                            cadence: 'revolutions_per_minute',
                            watts: 'watts',
                            temp: 'celsius',
                            grade_smooth: 'percent',
                            latlng: '[latitude, longitude]',
                            moving: 'boolean'
                        },
                        stream_descriptions: {
                            time: 'Time elapsed from activity start',
                            distance: 'Cumulative distance from start',
                            altitude: 'Elevation above sea level',
                            velocity_smooth: 'Smoothed speed',
                            heartrate: 'Heart rate',
                            cadence: 'Pedal/step cadence',
                            watts: 'Power output',
                            temp: 'Temperature',
                            grade_smooth: 'Road grade percentage',
                            latlng: 'GPS coordinates [latitude, longitude]',
                            moving: 'Whether athlete was moving'
                        },
                        ...(wasDownsampled && { downsampled: true, original_points: originalPoints })
                    },
                    statistics: streamStats,
                    data: {}
                };
    
                // Process each stream with pagination
                streams.forEach(stream => {
                    const paginatedData = stream.data.slice(startIdx, endIdx);
                    const processedData = format === 'compact' 
                        ? formatStreamDataCompact(stream, paginatedData)
                        : formatStreamDataVerbose(stream, paginatedData);
                    streamData.data[stream.type] = processedData;
                });
    
                return {
                    content: [{ 
                        type: 'text' as const, 
                        text: JSON.stringify(streamData, format === 'compact' ? undefined : null, format === 'compact' ? undefined : 2)
                    }]
                };
            } catch (error: any) {
                const statusCode = error.response?.status;
                const errorMessage = error.response?.data?.message || error.message;
                
                let userFriendlyError = `❌ Failed to fetch activity streams (${statusCode}): ${errorMessage}\n\n`;
                userFriendlyError += 'This could be because:\n';
                userFriendlyError += '1. The activity ID is invalid\n';
                userFriendlyError += '2. You don\'t have permission to view this activity\n';
                userFriendlyError += '3. The requested stream types are not available\n';
                userFriendlyError += '4. The activity is too old and the streams have been archived';
                
                return {
                    content: [{
                        type: 'text' as const,
                        text: userFriendlyError
                    }],
                    isError: true
                };
            }
        }
    };
  • Input schema definition for the get-activity-streams tool.
    export const inputSchema = z.object({
        id: z.number().or(z.string()).describe(
            'The Strava activity identifier to fetch streams for. This can be obtained from activity URLs or the get-activities tool.'
        ),
        types: z.array(z.enum(STREAM_TYPES))
            .default(['time', 'distance', 'heartrate', 'cadence', 'watts'])
            .describe(
                'Array of stream types to fetch. Available types:\n' +
                '- time: Time in seconds from start\n' +
                '- distance: Distance in meters from start\n' +
                '- latlng: Array of [latitude, longitude] pairs\n' +
                '- altitude: Elevation in meters\n' +
                '- velocity_smooth: Smoothed speed in meters/second\n' +
                '- heartrate: Heart rate in beats per minute\n' +
                '- cadence: Cadence in revolutions per minute\n' +
                '- watts: Power output in watts\n' +
                '- temp: Temperature in Celsius\n' +
                '- moving: Boolean indicating if moving\n' +
                '- grade_smooth: Road grade as percentage'
            ),
        resolution: z.enum(RESOLUTION_TYPES).optional()
            .describe(
                'Data resolution. Affects number of data points returned:\n' +
                '- low: ~100 points (recommended for LLM analysis)\n' +
                '- medium: ~1000 points\n' +
                '- high: ~10000 points (warning: very large payload, may cause slowness)\n' +
                'Defaults to "low" when omitted. Pass explicitly if you need more data.'
            ),
        series_type: z.enum(['time', 'distance']).optional()
            .default('distance')
            .describe(
                'Optional base series type for the streams:\n' +
                '- time: Data points are indexed by time (seconds from start)\n' +
                '- distance: Data points are indexed by distance (meters from start)\n' +
                'Useful for comparing different activities or analyzing specific segments.'
            ),
        page: z.number().optional().default(1)
            .describe(
                'Optional page number for paginated results. Use with points_per_page to retrieve specific data ranges.\n' +
                'Example: page=2 with points_per_page=100 gets points 101-200.'
            ),
        points_per_page: z.number().optional().default(100)
            .describe(
                'Optional number of data points per page. Special values:\n' +
                '- Positive number: Returns that many points per page\n' +
                '- -1: Returns ALL data points split into multiple messages (~1000 points each)\n' +
                'Use -1 when you need the complete activity data for analysis.'
            ),
        format: z.enum(['compact', 'verbose']).optional().default('compact')
            .describe(
                'Output format:\n' +
                '- compact: Raw arrays, minified JSON (~70-80% smaller, LLM-friendly)\n' +
                '- verbose: Human-readable objects with formatted values (backward compatible)'
            ),
        max_points: z.number().optional()
            .describe(
                'Maximum number of data points to return. If activity exceeds this, data will be intelligently downsampled ' +
                'while preserving peaks and valleys. Useful for very large activities.'
            ),
        summary_only: z.boolean().optional().default(false)
            .describe(
                'If true, returns only metadata and statistics (min/max/avg) without raw stream data. ' +
                'Much faster and smaller response. Ideal for quick activity overviews or when raw data is not needed.'
            )
    });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/LimeON-source/Strava-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server