Part 1: Writing the First Bluesky Plugin for ChatGPT

trozzelle

Torin Rozzelle

Posted on May 7, 2023

Part 1: Writing the First Bluesky Plugin for ChatGPT

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
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

If you're using my starter, you can go ahead and install with:

npm install
Enter fullscreen mode Exit fullscreen mode
  • 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 setting Cross-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 
Enter fullscreen mode Exit fullscreen mode

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';    
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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}`);  
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 '.’
Enter fullscreen mode Exit fullscreen mode

and we get

Image description

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.

💖 💪 🙅 🚩
trozzelle
Torin Rozzelle

Posted on May 7, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related