Building ChatGPT Plugins - From Idea to Production
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:
- Plugin manifest (
ai-plugin.json) - OpenAPI specification
- API endpoints
- 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!