Earlier this week, I got an unexpected email that I was approved for the ChatGPT Plugins alpha. This is super exciting but the service I intended to develop for isn't yet in production. I've been writing some small projects for Bluesky to get to know the AT Protocol and wondered if I could use this opportunity to play around with both ChatGPT and the Bluesky API.
In this series of write-ups, I'm going to take you the first few steps of integrating the two and hopefully inspire some ideas, especially with federation around the corner
Plugin Structure
Let's first take a look the structure of an ChatGPT plugin. A plugin is intended to give ChatGPT access to an API by supplying the information that it needs to become, in OpenAI's words, 'an intelligent API caller'.
The plugin itself is made of metadata files that describe what endpoints are exposed. In its most minimal form, a plugin has three required files:
.well-known/ai-plugin.json
index.js
openai.yaml
index.js is mostly self-explanatory being the entry point of your plugin.
openai.yaml is an OpenAPI 3.0 specification file that defines the API and its endpoints as well as the structure of the data that is returned.
.well-known/ai-plugin.json is a manifest file, which tells ChatGPT information about the plugin. The manifest describes metadata about the plugin, the authorization method the plugin will use to access the api, and where the OpenAPI specification file can be found.
Writing a Plugin
Plugins can be developed either locally or remotely. For the purposes of this project, we're going to develop locally. The plugin development program is not really meant for people to develop for APIs that they do not control. When accessing a non-local resource, ChatGPT looks for the ai-plugin.json
file at example.com/.well-known/ai-plugin.json
.
We can get around this by developing locally and writing an HTTP proxy in Express that is going to act as middleware between ChatGPT and Bluesky, intercepting requests and making a call to Bluesky, doing some logic, and then returning the results.
To get started, use either the ChatGPT Plugins Quickstart or use the ChatGPT Typescript Starter that I've put together here.
If you're starting fresh, the first thing you're going to want to do is to install some dependencies:
npm install express http-proxy cors axios morgan dotenv
If you're using my starter, you can go ahead and install with:
npm install
Express
provides the server and the routing for our local API.http-proxy
provides middleware that allows us to rewrite the path and pass the endpoint that we're calling locally through to the actual Bluesky endpoint.cors
is more middlware that will handle settingCross-Origin Resource Sharing
headers for our HTTP requests.axios
allows us to make asynchronous HTTP requests.dotenv
will allow us keep our secrets in a local .env file (which should always be .gitignore'd) and load them at run-time.
Create your .env
file by using the .env-example
:
ATPROTO_USER=bluesky_handle_without_@ # Ex: torin.bsky.social
ATPROTO_PASS=preferably_an_app_password
and import the dependencies into index.js
:
import express, { Request, Response, NextFunction } from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import cors from 'cors';
import axios from 'axios';
import morgan from 'morgan';
import dotenv from 'dotenv';
import process from 'node:process';
We'll read the credentials from our .env file with dotenv
and set up our environment:
dotenv.config()
const targetAPI = 'https://bsky.social'; // API's base url
const apiIdentifier = process.env.ATPROTO_USER;
const apiPassword = process.env.ATPROTO_PASS;
and outline a basic Express app:
const app = express();
const port = 3005;
// Tells express to use morgan for logging
// and the cors middleware
app.use(morgan('dev'));
app.use(cors())
// Configure the proxy middleware
const apiProxy = createProxyMiddleware('/api', {
target: targetAPI,
changeOrigin: true,
pathRewrite: {
'^/api': '/xrpc', // Change '/api' to /xrpc at the beginning of the path
},
onError: (err, req: Request, res) => {
console.error('Proxy error:', err);
res.status(500).json({error: 'Proxy Error'})
},
});
// Tells express to use the apiProxy middlware
app.use(apiProxy);
// Starts the server listening on port 3005
app.listen(port, () => {
console.log(`Proxy server listening on port ${port}`);
});
What we're doing here is creating an express
server that will listen on localhost:3005
. We're telling express
to use the cors
middleware and morgan
for logging. We then tell express
to use the http-proxy
middleware.
createProxyMiddleware
abstracts away a bunch of work by taking each endpoint that we call locally and transforming it into a valid call to the Bluesky API. For example, a call to localhost:3005/api/getProfile
becomes a call to https://bsky.app/xrpc/getProfile
(not a real endpoint).
AT Protocol and XRPC
Bluesky's API is built on top of the Authenticated Transfer (AT) Protocol, a protocol built for large scale distributed social applications. I highly encourage you to read the protocol docs at atproto.com with the caveat that they are fairly out of date as of this writing and the protocol itself is still evolving. Things are expected to change.
The protocol uses its own idiomatic URI structure, identifiers, and server-to-server messaging protocol called XRPC. I'm not going to do a deep drive here but for the purposes of this write-up, you mostly need to know that XRPC is a thin wrapper around HTTP that uses GET and POST to exchange data and supports both structured JSON data and binary blobs.
AT Protocol's XRPC methods and record types are documented in schemas called Lexicons, which are defined in reverse DNS order like app.bsky.feed.getPosts
or com.atproto.server.createSession
. The two lexicon families we'll be working with are com.atproto
, which defines methods and record types native to the protocol and app.bsky
, which defines Bluesky-specific methods and record types.
Returning to our program, what our HTTP proxy allows us to do is to make a request like localhost:3005/api/app.bsky.feed.getPosts
and have that transformed into bsky.app/xrpc/app.bsky.feed.getPosts
, receiving the response to the proxy which is then relayed back to ChatGPT.
Authenticating
One thing that's missing from our proxy is authentication. We run into an early adoption problem here since ChatGPT does not yet support user-level authentication for plugins and Bluesky's authentication does not yet support service-level authentication (i.e. API keys) or OAuth.
For the moment, we're going to authenticate with a user account in our proxy app itself and, because we're developing locally, instruct ChatGPT that no authentication is required. This is not something that you would ever want to do in a production environment.
We're going to add two authentication functions:
// Function to authenticate with Bluesky's API
async function authenticate(): Promise<string> {
const params = {
"identifier": apiIdentifier,
"password": apiPassword
}
// Make a POST request to createSession and wait for the response
const response = await axios.post(`${targetAPI}/xrpc/com.atproto.server.createSession`, params, {});
// Return the JWT from the response
return response.data.accessJwt;
}
// Function to insert Auth headers that will be used for future requests
async function addAuthToken(req: Request, res: Response, next: NextFunction) {
try {
const token = await authenticate();
req.headers['Authorization'] = `Bearer ${token}`;
next();
} catch (err) {
console.error('Authentication error: ', err);
res.status(500).json({ error: 'Authentication error'});
}
}
app.use(addAuthToken);
authenticate()
is going to make an HTTP POST request to the com.atproto.server.createSession
endpoint with a JSON payload that includes our username and password. A successful response contains a JSON Web Token which is returned to the calling addAuthToken()
, which creates an Authorization header with the JWT.
app.use(addAuthToken)
tells Express to use the middleware function and the call to next()
in addAuthToken
proceeds to the next method in the chain.
At this point, we should be able to make a call to any of the Bluesky endpoints with the correct parameters and get a valid response.
Let's check that by making a call to the app.bsky.unspecced.getPopular
endpoint, which returns the most popular posts at the moment:
curl 'localhost:3005/api/app.bsky.unspecced.getpopular' | jq '.’
and we get
Great! We now have a working proxy to the Bluesky API.
In the next article, we're going to build the ai-plugin.json
and openapi.yaml
files that will tell ChatGPT how to use our API, register our plugin to run locally, and pose our first question in the chat.
Top comments (0)