Skip to content

tomelliot/strada-infinita

Repository files navigation

Strada Infinita

A ChatGPT Apps SDK application built with Next.js that demonstrates how to create custom UI components that render within ChatGPT conversations.

Architecture Overview

This project uses a Next.js server that serves dual purposes:

  1. MCP Server (app/mcp/route.ts) - Exposes tools and resources to ChatGPT via the Model Context Protocol
  2. React Component Host - Serves the React component HTML that renders inside ChatGPT's iframe

Data Flow

ChatGPT → MCP Tool Call → Next.js MCP Server → Tool Response (structuredContent)
                                                      ↓
ChatGPT → Resource Request → Next.js Serves HTML → React Component Hydrates
                                                      ↓
React Component → useWidgetProps() → window.openai.toolOutput → Renders UI

Setup

Developer Account Limitations: By default, Strava developer accounts are limited to 1 athlete (your own account). That means to use Strada Infinita with your own Strava account, you'll need to:

  1. Generate Strava API Keys: Create API credentials for your Strava account by following the Strava Getting Started guide. Your API keys (Client ID and Client Secret) are available in your Strava API Settings.

  2. Host the Application: This application needs to be hosted on your own server. The MCP server must be accessible to ChatGPT, and the React components need to be served from a public URL.

Strava Integration

Strada Infinita connects to users' Strava accounts to:

  • View Activities: Browse running activities, including distance, pace, elevation, and route maps
  • Analyze Performance: Get insights on training patterns, progress over time, and performance metrics
  • Interactive Coaching: Your AI assistant can help you understand your training data, suggest improvements, and answer questions about your runs
  • Activity Details: Explore detailed information about individual activities, including splits, heart rate zones, and power data (when available)

The app uses Strava's OAuth API to securely authenticate users and access their activity data. All Strava data is fetched on-demand through MCP tools, ensuring your data stays secure and up-to-date.

Scaffolding Tools (MCP Server)

Tools are registered in app/mcp/route.ts using the createMcpHandler pattern.

1. Define Tool Schema

Use Zod to define the input schema:

import { z } from "zod";

server.registerTool(
  "get_activity",
  {
    title: "Get Activity",
    description: "Fetches a Strava activity by ID",
    inputSchema: {
      activityId: z.string().describe("The Strava activity ID"),
      includeDetails: z.boolean().optional().describe("Include detailed metrics"),
    },
    _meta: widgetMeta(activityWidget), // Links tool to widget resource
  },
  async ({ activityId, includeDetails }) => {
    // Fetch activity from Strava API
    const activity = await stravaClient.getActivity(activityId, includeDetails);
    return {
      content: [{ type: "text", text: `Retrieved activity: ${activity.name}` }],
      structuredContent: activity,
      _meta: widgetMeta(activityWidget),
    };
  }
);

2. Return Structured Content

The tool handler must return structuredContent in the response. This data is passed to your React component:

async ({ activityId, includeDetails }) => {
  const activity = await stravaClient.getActivity(activityId);
  
  return {
    content: [{ type: "text", text: `Retrieved activity: ${activity.name}` }],
    structuredContent: {
      id: activity.id,
      name: activity.name,
      distance: activity.distance,
      movingTime: activity.moving_time,
      averagePace: activity.average_speed,
      elevation: activity.total_elevation_gain,
      startDate: activity.start_date,
      // Add any data your component needs
    },
    _meta: widgetMeta(activityWidget),
  };
}

3. Register Widget Resource

Each tool that renders a UI needs a corresponding resource that serves the React component HTML:

const myWidget: ContentWidget = {
  id: "my_tool_id",
  title: "My Tool",
  templateUri: "ui://widget/my-widget.html",
  invoking: "Loading...",
  invoked: "Loaded",
  html: await getAppsSdkCompatibleHtml(baseURL, "/my-component"),
  description: "Displays my component",
  widgetDomain: "https://example.com",
};

server.registerResource(
  "my-widget",
  myWidget.templateUri,
  {
    title: myWidget.title,
    description: myWidget.description,
    mimeType: "text/html+skybridge",
    _meta: {
      "openai/widgetDescription": myWidget.description,
      "openai/widgetPrefersBorder": true,
    },
  },
  async (uri) => ({
    contents: [
      {
        uri: uri.href,
        mimeType: "text/html+skybridge",
        text: `<html>${myWidget.html}</html>`,
        _meta: {
          "openai/widgetDescription": myWidget.description,
          "openai/widgetPrefersBorder": true,
          "openai/widgetDomain": myWidget.widgetDomain,
        },
      },
    ],
  })
);

Key Points:

  • templateUri must match the openai/outputTemplate in tool metadata
  • html is fetched from a Next.js route (e.g., /my-component)
  • The resource handler returns the HTML wrapped in <html> tags

Scaffolding Components (ChatGPT SDK)

React components are standard Next.js pages that read data from ChatGPT via the window.openai API.

1. Create Component Page

Create a new page in app/activity/page.tsx:

"use client";

import { useWidgetProps } from "@/app/hooks";

export default function ActivityComponent() {
  const activity = useWidgetProps<{
    id?: string;
    name?: string;
    distance?: number;
    movingTime?: number;
    averagePace?: number;
    elevation?: number;
    startDate?: string;
  }>();

  if (!activity) return <div>Loading...</div>;

  return (
    <div>
      <h1>{activity.name}</h1>
      <p>Distance: {(activity.distance / 1000).toFixed(2)} km</p>
      <p>Time: {formatTime(activity.movingTime)}</p>
      <p>Pace: {formatPace(activity.averagePace)}</p>
      {activity.elevation && <p>Elevation: {activity.elevation}m</p>}
    </div>
  );
}

2. Access Tool Output Data

Use the useWidgetProps() hook to access data from the tool's structuredContent:

const toolOutput = useWidgetProps<MyToolOutputType>();

This hook reads from window.openai.toolOutput, which contains the structuredContent returned by your tool.

3. Access Other ChatGPT Globals

Use hooks from app/hooks to access other ChatGPT context:

import {
  useDisplayMode,      // "inline" | "pip" | "fullscreen"
  useMaxHeight,        // Container max height
  useWidgetState,      // Persistent widget state
  useCallTool,         // Call MCP tools from component
  useSendMessage,      // Send follow-up messages
} from "@/app/hooks";

4. Widget State (Persistent Data)

Use useWidgetState() to persist data across user interactions:

const [state, setState] = useWidgetState<{ 
  favoriteActivities: string[];
  trainingGoals: { distance: number; targetDate: string }[];
}>({
  favoriteActivities: [],
  trainingGoals: [],
});

// Update state (automatically persisted and sent to ChatGPT)
setState({ 
  favoriteActivities: [...state.favoriteActivities, activityId],
  trainingGoals: state.trainingGoals,
});

Important: Widget state is scoped to a single widget instance and is visible to ChatGPT. Keep payloads under 4k tokens.

Design Guidelines for ChatGPT Components

Components in app/mcp-components/ must follow OpenAI's design guidelines to ensure they feel native to ChatGPT. The layout at app/mcp-components/layout.tsx enforces these constraints.

Key Constraints

  • Typography: Use system fonts only (inherit platform-native fonts like SF Pro on iOS, Roboto on Android). No custom fonts, even in fullscreen modes.
  • Colors:
    • Use system colors for text, icons, and spatial elements (dividers, backgrounds)
    • Brand colors are only allowed on primary buttons via CSS variables (--brand-primary)
    • Do not override text colors or backgrounds with brand colors
  • Spacing: Use system grid spacing and maintain consistent padding
  • Accessibility: Maintain WCAG AA contrast ratios, provide alt text for images, support text resizing

Using Brand Colors on Buttons

// Apply brand color to primary buttons
<button data-brand="primary" className="btn-primary">
  Action
</button>

The brand color CSS variables are defined in app/mcp-components/widgets.css and can be customized per your brand.

Full guidelines: See docs/openai/design-guidelines .md for complete visual design, tone, and interaction guidelines.

Passing Data Between Tool and Component

Tool → Component

  1. Tool returns structuredContent:

    return {
      structuredContent: {
        id: "123456789",
        name: "Morning Run",
        distance: 5000,
        movingTime: 1800,
        averagePace: 3.6, // m/s
        elevation: 120,
      },
    };
  2. Component reads via useWidgetProps():

    const activity = useWidgetProps<{ 
      id: string; 
      name: string; 
      distance: number;
      movingTime: number;
    }>();
    // activity.name === "Morning Run"
    // activity.distance === 5000 (meters)

Component → Tool (via callTool)

Components can call tools directly using useCallTool():

const callTool = useCallTool();

async function refreshActivity() {
  await callTool("get_activity", { 
    activityId: activity.id,
    includeDetails: true 
  });
}

Note: Tools must be marked as component-accessible in the MCP server configuration.

Reference

Project Structure

server/
├── app/
│   ├── mcp/
│   │   └── route.ts          # MCP server (tools & resources)
│   ├── mcp-components/       # ChatGPT widget components
│   │   ├── layout.tsx        # Widget layout (system fonts, SDK bootstrap)
│   │   ├── widgets.css       # Widget styles (design guidelines)
│   │   └── [component]/      # Individual widget pages
│   ├── hooks/                # ChatGPT SDK hooks
│   │   ├── use-widget-props.ts
│   │   ├── use-widget-state.ts
│   │   └── ...
│   ├── page.tsx              # Website landing page
│   └── layout.tsx            # Website layout (custom fonts)
└── middleware.ts             # CORS handling

About

Running coaching tool that combines Strava with your AI chatbot analyse recent activities, show weekly performance stats, and enable AI-based coaching

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors