OpenAI released ChatGPT Plugins in March 2023, allowing ChatGPT to interact with external APIs. I built three plugins and learned a lot about what works and what doesn’t.

Table of contents

What Are ChatGPT Plugins?

ChatGPT Plugins extend ChatGPT’s capabilities by:

  • Accessing real-time data
  • Performing actions (send emails, book appointments)
  • Integrating with third-party services
  • Retrieving specialized information

Key Components:

  1. Plugin manifest (ai-plugin.json)
  2. OpenAPI specification
  3. API endpoints
  4. Authentication (optional)

Plugin Architecture

Your Plugin
├── ai-plugin.json          # Plugin manifest
├── openapi.yaml            # API specification
├── /.well-known/
│   └── ai-plugin.json      # Served at this path
└── /api/
    ├── search              # Your API endpoints
    ├── create
    └── update

Building Your First Plugin

Step 1: Create Plugin Manifest

{
  "schema_version": "v1",
  "name_for_human": "Weather Plugin",
  "name_for_model": "weather",
  "description_for_human": "Get current weather and forecasts for any location.",
  "description_for_model": "Plugin for getting weather information. Use it whenever a user asks about weather, temperature, or forecasts.",
  "auth": {
    "type": "none"
  },
  "api": {
    "type": "openapi",
    "url": "https://your-domain.com/openapi.yaml"
  },
  "logo_url": "https://your-domain.com/logo.png",
  "contact_email": "support@your-domain.com",
  "legal_info_url": "https://your-domain.com/legal"
}

Step 2: Define OpenAPI Specification

openapi: 3.0.1
info:
  title: Weather Plugin
  description: A plugin to get weather information
  version: 'v1'
servers:
  - url: https://your-domain.com
paths:
  /api/weather:
    get:
      operationId: getWeather
      summary: Get current weather for a location
      parameters:
        - name: location
          in: query
          description: City name or coordinates
          required: true
          schema:
            type: string
        - name: units
          in: query
          description: Temperature units (celsius or fahrenheit)
          required: false
          schema:
            type: string
            enum: [celsius, fahrenheit]
            default: celsius
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WeatherResponse'
components:
  schemas:
    WeatherResponse:
      type: object
      properties:
        location:
          type: string
          description: Location name
        temperature:
          type: number
          description: Current temperature
        conditions:
          type: string
          description: Weather conditions
        humidity:
          type: number
          description: Humidity percentage
        wind_speed:
          type: number
          description: Wind speed

Step 3: Implement API

from flask import Flask, jsonify, request
from flask_cors import CORS
import requests

app = Flask(__name__)
CORS(app)

# Serve plugin manifest
@app.route('/.well-known/ai-plugin.json')
def serve_manifest():
    return jsonify({
        "schema_version": "v1",
        "name_for_human": "Weather Plugin",
        "name_for_model": "weather",
        "description_for_human": "Get current weather and forecasts.",
        "description_for_model": "Plugin for weather information.",
        "auth": {"type": "none"},
        "api": {
            "type": "openapi",
            "url": "https://your-domain.com/openapi.yaml"
        },
        "logo_url": "https://your-domain.com/logo.png",
        "contact_email": "support@your-domain.com",
        "legal_info_url": "https://your-domain.com/legal"
    })

# Serve OpenAPI spec
@app.route('/openapi.yaml')
def serve_openapi():
    with open('openapi.yaml', 'r') as f:
        return f.read(), 200, {'Content-Type': 'text/yaml'}

# Weather API endpoint
@app.route('/api/weather')
def get_weather():
    location = request.args.get('location')
    units = request.args.get('units', 'celsius')
    
    if not location:
        return jsonify({"error": "Location is required"}), 400
    
    # Call external weather API
    weather_data = fetch_weather(location, units)
    
    return jsonify(weather_data)

def fetch_weather(location, units):
    """Fetch weather from external API"""
    # Example using OpenWeatherMap API
    api_key = "your_api_key"
    url = f"https://api.openweathermap.org/data/2.5/weather"
    
    params = {
        'q': location,
        'appid': api_key,
        'units': 'metric' if units == 'celsius' else 'imperial'
    }
    
    response = requests.get(url, params=params)
    data = response.json()
    
    return {
        'location': data['name'],
        'temperature': data['main']['temp'],
        'conditions': data['weather'][0]['description'],
        'humidity': data['main']['humidity'],
        'wind_speed': data['wind']['speed']
    }

if __name__ == '__main__':
    app.run(port=5000)

Advanced Plugin: Task Manager

Plugin Manifest

{
  "schema_version": "v1",
  "name_for_human": "Task Manager",
  "name_for_model": "tasks",
  "description_for_human": "Manage your tasks and to-do lists.",
  "description_for_model": "Plugin for creating, updating, and managing tasks. Use it when users want to create tasks, mark them complete, or view their task list.",
  "auth": {
    "type": "oauth",
    "client_url": "https://your-domain.com/oauth/authorize",
    "authorization_url": "https://your-domain.com/oauth/token",
    "authorization_content_type": "application/json",
    "scope": "tasks:read tasks:write"
  },
  "api": {
    "type": "openapi",
    "url": "https://your-domain.com/openapi.yaml"
  },
  "logo_url": "https://your-domain.com/logo.png",
  "contact_email": "support@your-domain.com",
  "legal_info_url": "https://your-domain.com/legal"
}

API Implementation with Authentication

from flask import Flask, jsonify, request
from functools import wraps
import jwt
import datetime

app = Flask(__name__)
SECRET_KEY = "your-secret-key"

# Authentication decorator
def require_auth(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        token = request.headers.get('Authorization', '').replace('Bearer ', '')
        
        if not token:
            return jsonify({"error": "No token provided"}), 401
        
        try:
            payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
            request.user_id = payload['user_id']
        except jwt.ExpiredSignatureError:
            return jsonify({"error": "Token expired"}), 401
        except jwt.InvalidTokenError:
            return jsonify({"error": "Invalid token"}), 401
        
        return f(*args, **kwargs)
    
    return decorated_function

# OAuth endpoints
@app.route('/oauth/authorize')
def oauth_authorize():
    # Implement OAuth authorization flow
    pass

@app.route('/oauth/token', methods=['POST'])
def oauth_token():
    # Implement OAuth token exchange
    code = request.json.get('code')
    
    # Verify code and generate token
    token = jwt.encode({
        'user_id': 'user123',
        'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=24)
    }, SECRET_KEY, algorithm='HS256')
    
    return jsonify({
        'access_token': token,
        'token_type': 'Bearer',
        'expires_in': 86400
    })

# Task endpoints
@app.route('/api/tasks', methods=['GET'])
@require_auth
def get_tasks():
    """Get all tasks for the authenticated user"""
    user_id = request.user_id
    tasks = fetch_user_tasks(user_id)
    return jsonify(tasks)

@app.route('/api/tasks', methods=['POST'])
@require_auth
def create_task():
    """Create a new task"""
    user_id = request.user_id
    data = request.json
    
    task = {
        'id': generate_task_id(),
        'user_id': user_id,
        'title': data.get('title'),
        'description': data.get('description', ''),
        'due_date': data.get('due_date'),
        'completed': False,
        'created_at': datetime.datetime.utcnow().isoformat()
    }
    
    save_task(task)
    return jsonify(task), 201

@app.route('/api/tasks/<task_id>', methods=['PATCH'])
@require_auth
def update_task(task_id):
    """Update a task"""
    user_id = request.user_id
    data = request.json
    
    task = get_task(task_id)
    
    if not task or task['user_id'] != user_id:
        return jsonify({"error": "Task not found"}), 404
    
    # Update fields
    if 'title' in data:
        task['title'] = data['title']
    if 'completed' in data:
        task['completed'] = data['completed']
    if 'due_date' in data:
        task['due_date'] = data['due_date']
    
    save_task(task)
    return jsonify(task)

@app.route('/api/tasks/<task_id>', methods=['DELETE'])
@require_auth
def delete_task(task_id):
    """Delete a task"""
    user_id = request.user_id
    task = get_task(task_id)
    
    if not task or task['user_id'] != user_id:
        return jsonify({"error": "Task not found"}), 404
    
    remove_task(task_id)
    return '', 204

Best Practices

1. Clear Descriptions

{
  "description_for_model": "Use this plugin when the user wants to search for products, check prices, or get product recommendations. The plugin can search by product name, category, or price range. Always include relevant filters to narrow down results."
}

Good: Specific, actionable, tells ChatGPT when to use it

Bad: “A shopping plugin” (too vague)

2. Descriptive Operation IDs

paths:
  /api/products/search:
    get:
      operationId: searchProducts  # Clear and descriptive
      summary: Search for products by name, category, or filters

3. Detailed Parameter Descriptions

parameters:
  - name: query
    in: query
    description: "Search query. Can be product name, brand, or keywords. Examples: 'laptop', 'nike shoes', 'wireless headphones'"
    required: true
    schema:
      type: string

4. Error Handling

@app.route('/api/search')
def search():
    try:
        query = request.args.get('query')
        
        if not query:
            return jsonify({
                "error": "Query parameter is required",
                "example": "/api/search?query=laptop"
            }), 400
        
        results = perform_search(query)
        return jsonify(results)
        
    except Exception as e:
        return jsonify({
            "error": "Internal server error",
            "message": str(e)
        }), 500

5. Rate Limiting

from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(
    app,
    key_func=get_remote_address,
    default_limits=["100 per hour"]
)

@app.route('/api/search')
@limiter.limit("10 per minute")
def search():
    # Your code here
    pass

Testing Your Plugin

Local Testing

# 1. Start your server
python app.py

# 2. Use ngrok for HTTPS tunnel
ngrok http 5000

# 3. Update manifest with ngrok URL
# https://abc123.ngrok.io

# 4. Test in ChatGPT Plugin Store (developer mode)

Testing with curl

# Test manifest
curl https://your-domain.com/.well-known/ai-plugin.json

# Test OpenAPI spec
curl https://your-domain.com/openapi.yaml

# Test API endpoint
curl "https://your-domain.com/api/weather?location=London"

Deployment

Using Vercel

# api/index.py
from flask import Flask, jsonify
app = Flask(__name__)

@app.route('/.well-known/ai-plugin.json')
def manifest():
    # Your manifest
    pass

# vercel.json
{
  "builds": [
    {
      "src": "api/index.py",
      "use": "@vercel/python"
    }
  ],
  "routes": [
    {
      "src": "/(.*)",
      "dest": "api/index.py"
    }
  ]
}

Using AWS Lambda

# lambda_function.py
import json

def lambda_handler(event, context):
    path = event['path']
    
    if path == '/.well-known/ai-plugin.json':
        return {
            'statusCode': 200,
            'body': json.dumps({
                # Your manifest
            })
        }
    
    # Handle other routes

Real-World Examples

Example 1: Database Query Plugin

Allows ChatGPT to query your database:

@app.route('/api/query', methods=['POST'])
@require_auth
def query_database():
    """Execute a read-only database query"""
    query = request.json.get('query')
    
    # Validate query is SELECT only
    if not query.strip().upper().startswith('SELECT'):
        return jsonify({"error": "Only SELECT queries allowed"}), 400
    
    # Execute query safely
    results = execute_safe_query(query)
    return jsonify(results)

Example 2: Email Plugin

Send emails through ChatGPT:

@app.route('/api/send-email', methods=['POST'])
@require_auth
def send_email():
    """Send an email"""
    data = request.json
    
    email = {
        'to': data.get('to'),
        'subject': data.get('subject'),
        'body': data.get('body')
    }
    
    # Send email
    send_email_via_smtp(email)
    
    return jsonify({"status": "sent"})

Common Pitfalls

1. Vague Descriptions

ChatGPT won’t know when to use your plugin.

2. Missing CORS Headers

from flask_cors import CORS
CORS(app, origins=["https://chat.openai.com"])

3. No Rate Limiting

Your API will get hammered.

4. Poor Error Messages

Return helpful error messages:

return jsonify({
    "error": "Invalid location",
    "message": "Please provide a valid city name or coordinates",
    "example": "London or 51.5074,-0.1278"
}), 400

Conclusion

ChatGPT Plugins open up endless possibilities for extending ChatGPT’s capabilities.

Key Takeaways:

  • Clear descriptions are crucial
  • Authentication adds security
  • Test thoroughly before publishing
  • Monitor usage and rate limit
  • Provide helpful error messages

Plugin Development Time: 2-8 hours depending on complexity

Worth it? Absolutely, if you have a useful API or service to integrate.

The plugin ecosystem is still young, but it’s growing fast. Get in early!