Part 1: Writing the First Bluesky Plugin for ChatGPT
Published: 2023-05-07 15:35:22

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](atproto.com/) 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](https://blueskyweb.xyz/blog/5-5-2023-federation-architecture)

### 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](https://github.com/openai/plugins-quickstart/) or use the [ChatGPT Typescript Starter that I've put together here.](https://github.com/trozzelle/chatgpt-plugin-starter)

If you're starting fresh, the first thing you're going to want to do is to install some dependencies:


```bash
npm install express http-proxy cors axios morgan dotenv 
```

If you're using my starter, you can go ahead and install with:
```bash
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 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 
```

and import the dependencies into `index.js`:

```javascript
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:

```javascript
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:

```javascript
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](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:


```javascript
// 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:

```bash
curl 'localhost:3005/api/app.bsky.unspecced.getpopular' | jq '.’
```

and we get 


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ziwke1fjoy6hpdfllu0h.gif)

Great! We now have a working proxy to the Bluesky API.

In the [next article](https://dev.to/trozzelle/part-2-describing-the-api-2b4), 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.