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:
Multiple Data Types: Access various metrics like heart rate, power, speed, GPS coordinates, etc.
Flexible Resolution: Choose data density from low (~100 points) to high (~10000 points)
Smart Pagination: Get data in manageable chunks optimized for LLM context limits
Rich Statistics: Includes min/max/avg for numeric streams
Dual Format Support: Compact (LLM-optimized) or verbose (human-readable)
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:
Metadata: Activity overview, available streams, data points, units, format info
Statistics: Summary stats for each stream type (max/min/avg where applicable)
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
| Name | Required | Description | Default |
|---|---|---|---|
| id | Yes | The Strava activity identifier to fetch streams for. This can be obtained from activity URLs or the get-activities tool. | |
| types | No | Array 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 | |
| resolution | No | Data 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_type | No | Optional 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 |
| page | No | Optional 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_page | No | Optional 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. | |
| format | No | Output format: - compact: Raw arrays, minified JSON (~70-80% smaller, LLM-friendly) - verbose: Human-readable objects with formatted values (backward compatible) | compact |
| max_points | No | 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 | No | 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. |
Implementation Reference
- src/tools/getActivityStreams.ts:337-687 (handler)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.' ) });