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.'
            )
    });
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description carries full burden and excels at disclosing behavioral traits. It covers authentication requirements ('Requires activity:read scope'), data limitations ('Not all streams are available for all activities'), performance characteristics ('Large activities are automatically chunked to ~50KB per message'), and intelligent features like downsampling and pagination optimized for LLM context limits.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is well-structured with clear sections (Key Features, Format Options, Common Use Cases, Output Format, Notes) and front-loaded with the core purpose. While comprehensive, some sections could be more concise - the 'Key Features' list contains 6 items where 3-4 might suffice, and the description is longer than typical for a retrieval tool.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a complex tool with 9 parameters, no annotations, and no output schema, the description provides excellent completeness. It covers authentication, data availability, performance considerations, pagination behavior, format options, common use cases, and output structure. The 'Notes' section addresses important edge cases and limitations that an agent needs to know.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 100%, so the schema already documents all 9 parameters thoroughly. The description adds some context about parameter interactions (e.g., 'Use max_points parameter to downsample very large activities intelligently') and format implications, but doesn't provide significant additional semantic meaning beyond what's in the schema. Baseline 3 is appropriate when schema does heavy lifting.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool's purpose with specific verbs ('retrieves detailed time-series data streams') and resources ('from a Strava activity'), distinguishing it from siblings like get-activity-details or get-activity-laps. It explicitly focuses on data streams rather than summary information or other activity aspects.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description provides clear context for when to use this tool through 'Common Use Cases' (e.g., analyzing workout intensity, calculating power metrics) and 'Notes' section (e.g., requires activity:read scope, not all streams available for all activities). However, it doesn't explicitly state when NOT to use it or name specific alternative tools for different needs.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

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