Part 2: Describing the API
Published: 2023-05-07 15:44:26

In the [first article in this series](https://dev.to/trozzelle/part-1-writing-the-first-bluesky-plugin-for-chatgpt-ce5), we started writing our ChatGPT plugin by building an HTTP proxy in `express` that authenticates with Bluesky and proxies requests to Bluesky's endpoints. 

In this article, we're going to build the metadata files that tell ChatGPT about the API and what structure it can expect the data in the requests and responses to take. After that, we'll register the plugin to run locally and make our first successful request in the chat.

First, let's create the plugin manifest by creating the directory `.well-known` and, in it, a file called `ai-plugin.json`. If you haven't encountered the `.well-known` folder before, it's a relatively recent IETF standard that defines a standardized folder that contains metadata about what services are available on that server. 

The `ai-plugin.json` should look like this: 

```json
{  
  "schema_version": "v1",  
  "name_for_human": "Bluesky (no auth)",  
  "name_for_model": "bluesky",  
  "description_for_human": "Plugin for accessing Bluesky, you can search for profiles and posts.",  
  "description_for_model": "Plugin for accessing Bluesky, you can search for profiles and posts.",  
  "auth": {  
    "type": "none"  
  },  
  "api": {  
    "type": "openapi",  
    "url": "http://localhost:3005/openapi.yaml",  
    "is_user_authenticated": false  
  },  
  "logo_url": "http://localhost:3005/logo.png",  
  "contact_email": "legal@example.com",  
  "legal_info_url": "http://example.com/legal"  
}
```



The fields are mostly self-explanatory but I want to call your attention to `description_for_model` and the `api` object.

`description_for_model` lets us write a description of the plugin that is provided to ChatGPT as part of the metadata it ingests to understand what’s going on. For lack of a better term, it’s a prompt. Where `description_for_human` has a max length of 120 chars, `description_for_model` has a max length of **8,000** chars. We don’t need to touch it yet but in the future you’ll see how we can use it to provide important contextual data that guides ChatGPTs inferences.

The `api` object defines the API specification we’re using and tells ChatGPT where to find the OpenAPI spec file that actually tells it about our API. Since we’re handling authentication in our middleware, we can set `auth: { type: none }` and `is_user_authenticated: false`. Once Bluesky has more robust authentication in place and OpenAI permits user-level authentication, our plugin should be refactored to move authentication from the middleware to as close to ChatGPT as possible.

### OpenAPI Specification

As of this writing, I don't believe a proper OpenAPI 3.0 spec of either the AT Protocol or Bluesky's API has been published. If we were implementing an API for another service that already had the API well-documented in the OpenAPI format, we would be able to slot it in, make a few minor tweaks, and be on our way. Because we don’t, we’re going to have to write one. 

The two resources that are the most helpful here are the [atproto lexicon docs](https://atproto.com/guides/lexicon) and the shape of the data in the response that we get when we call each endpoint. We’re going to focus on `app.bsky.actor.getProfile` as our first endpoint. 

Consulting the [app.bsky.actor lexicon](https://atproto.com/lexicons/app-bsky-actor), we see that getProfile takes a single parameter:

```json
{
  "lexicon": 1,
  "id": "app.bsky.actor.getProfile",
  "defs": {
    "main": {
      "type": "query",
      "parameters": {
        "type": "params",
        "required": [
          "actor"
        ],
        "properties": {
          "actor": {
            "type": "string",
            "format": "at-identifier"
          }
        }
      },
      "output": {
        "encoding": "application/json",
        "schema": {
          "type": "ref",
          "ref": "app.bsky.actor.defs#profileViewDetailed"
        }
      }
    }
  }
}
```



The actor parameter is expected to be in the format of an `at-identifier`, or, more commonly, a user handle. On Bluesky, these take the form of `@torin.bsky.social` or `@bsky.app`. 

Let's start to write our `openapi.yaml`:

```yaml
openapi: 3.0.1
info:
  title: Bluesky client
  description: Plugin for accessing Bluesky, you can search for profiles and posts.
  version: 'v1'
servers:
  - url: http://localhost:3005
```

We start with the metadata, which is self-explanatory. 

Let’s add the `app.bsky.actor.getProfile` endpoint:

```yaml
paths:
  /app.bsky.actor.getProfile:
    get:
      operationId: getProfile
      summary: Get user profile by handle
      description: Fetches a user's profile using the user's handle, which is usually in the form of a.b.c or a.b. Ex. torin.bsky.social
      parameters:
        - in: query
          name: actor
          schema:
            type: string
          description: The handle of the user profile to fetch.
          required: true
```

We will add each endpoint we want to define in the `paths` section. In `/app.bsky.actor.getProfile`, we first define the type of request (`get`). The `operationId` is  the unique name for the operation (ChatGPT really needs this). `Summary` is a short, imperative statement of what the endpoint does.

`description` is more interesting. As we’ll see later on, the descriptions that we provide for each operation are ostensibly small mini-prompts  that we can use to help ChatGPT infer what to do and how to do it. Here, I’ve added a little bit of help to describe the structure of Bluesky handles.

The `parameters` defines the input parameters for the request, in this case a single string called `actor`. The parameters for the GET requests are sent as query parameters, so we state `in: query`. The schema is the expected type of the parameters or, in the case of an object, the shape of the object and the types of the values within it. 

So far, so good. Let’s make a call to this endpoint and see what gets returned.

```bash
curl 'localhost:3005/api/app.bsky.actor.getProfile?actor=bsky.app' | jq '.'
```

```json
{
  "did": "did:plc:z72i7hdynmk6r22z27h6tvur",
  "handle": "bsky.app",
  "displayName": "Bluesky",
  "description": "Official bluesky account (check domain 👆)\n\nFollow for updates and announcements",
  "avatar": "https://cdn.bsky.social/imgproxy/LQdoaVquF7hEwCIJ1FW_AL52O95YYgoNyKzr9C-ibik/rs:fill:1000:1000:1:0/plain/bafkreic5kmqlhrhbfnh2bx6fsetvkra4noqja5ngsnnadrvubd6jcoc3ae@jpeg",
  "banner": "https://cdn.bsky.social/imgproxy/cFuBuKQpl9WM_Hq27hjMRm9OzlISj3BZmd4eU6o5JO0/rs:fill:3000:1000:1:0/plain/bafkreif7lzr3l7ysgwfuiv7q6rtlyebup2mizdgmbhmsunowspiq7m4oee@jpeg",
  "followsCount": 11,
  "followersCount": 12230,
  "postsCount": 22,
  "indexedAt": "2023-04-12T17:37:56.123Z",
  "viewer": {
    "muted": false,
    "blockedBy": false
  },
  "labels": []
}
```



We can now describe whatever data we want ChatGPT to be aware of in the `response` section of `ai-plugin.yaml`. Right now, we want to define everything that gets returned so that ChatGPT can make decisions on the data. Once we've fleshed out our spec a bit more, ChatGPT will be able to infer from our definitions to chain API calls to get what the user is asking for.

Something to be aware of is that we will, at some point, need to make some decisions on what data we are passing back and forth. The response for getProfile is light. Our response is ever only going to be 20-30 lines or 300-400 tokens. Once we start asking ChatGPT to search or pull down posts, the response quickly balloons to an unmanageable size.

Still under `get`, we define what responses we can expect to receive.

```yaml
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Profile'
        '400':
          description: Bad Request. Missing handle parameter.
        '500':
          description: Internal server error. API error or proxy error.

```

 The encoding of the response is `application/json`. Under schema, we add a reference to a component schema that we will add to the next section. This helps to keep the YAML file uncluttered by grouping endpoints with endpoints and schemas with schemas.

The `components` section is where we define various reusable definitions. API responses will contain many of the same types of objects. Our call to `app.bsky.actor.getProfile` returns a single profile but if we make a call to`app.bsky.actor.getProfiles`, we can expect to get an array of `Profiles` back. Much like a type definition or an interface in Typescript, we can describe composable definitions that keep our spec clean and maintainable.

```yaml
components:
  schemas:
    Profile:
      type: object
      properties:
        did:
          type: string
          example: "did:plc:cnd3evrzk24itsn3la76mea5"
        handle:
          type: string
          example: "torin.bsky.social"
        displayName:
          type: string
          example: "juvenile human chicken"
        description:
          type: string
          example: "Second-order effect enthusiast → progressive campaigning → software engineer."
        avatar:
          type: string
          example: "https://cdn.bsky.social/imgproxy/XL3MtdWOesDtgxdJ9APH5YlXe-0Yoe6Tyh9SgcazlR0/rs:fill:1000:1000:1:0/plain/bafkreifas5cditu4pnt3yi5ory77goeqx7x4ie3ta4d4nps7mpu7bwniiu@jpeg"
        banner:
          type: string
          example: "https://cdn.bsky.social/imgproxy/gwstOMUjJRi-xmHvJtTr3e880kV1FABx1HTfJHhrw58/rs:fill:3000:1000:1:0/plain/bafkreibdwgx5wyivhupj2epaj3wsevpdsynvu6a64tg4kxx6kc3rlacaxu@jpeg"
        followsCount:
          type: integer
          example: 248
        followersCount:
          type: integer
          example: 229
        postsCount:
          type: integer
          example: 812
        indexedAt:
          type: string
          format: date-time
          example: "2023-04-12T16:40:18.256Z"
        viewer:
          type: object
          properties:
            muted:
              type: boolean
              example: false
            blockedBy:
              type: boolean
              example: false
            following:
              type: string
              example: "at://did:plc:e42n3y4awcoz262k6x6bxu75/app.bsky.graph.follow/3jt7hmgjigv2z"
        labels:
          type: array
          items:
            type: string

```

One by one, we describe each part of the response object and give it's type. We can also give an example of what it is expected to look like. I've overdone it in the example above, ChatGPT understands what a boolean is, but giving it examples of idiomatic data like the `DID`, which is atproto's identifier for individuals, or the URI for the social graph, which is in the form of atproto's URI scheme, is important. If you've used ChatGPT, you know that by giving it a few examples you can greatly increase the quality of its responses. 

### Making the First Call

This should be enough for ChatGPT to respond with the details of a user’s profile if we give it a handle. 

Let’s register our plugin and try to run it. 





| ![Registering ChatGPT plugin](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ob9c6aw3mr02ypci4mzu.png) | ![Validating ChatGPT Plugin](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/t2col5w3ezikxwoqc26r.png) |
| ------------------------------------------------------------ | ------------------------------------------------------------ |




In the ChatGPT interface, we’ll open a new thread and select `Plugin` as the model. Opening the Plugin Store, we select `Develop your own plugin` and we’ll get a screen where we can enter our local server.

Clicking `Find manifest file` will instruct ChatGPT to look for and read the `ai-plugin.json` and `openapi.yaml` files. The yaml file is validated before we can install the plugin and if there’s an issue with the file, we’ll get a warning telling us what’s wrong.

Let’s ask ChatGPT to look up a profile:



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

And it works!



We now have ChatGPT calling the Bluesky API when it thinks Bluesky can answer a user’s question. Albeit, it’s fairly limited right now. We can only look up user profiles and only if we provide them in the correct format of a Bluesky user handle. 

In the next article, we’re going to take it a step further. We will expand our proxy app and integrate Bluesky’s javascript API client to make cleaner API calls, make calls to specific HTTP endpoints that aren’t in the normal spec to search, and learn to tweak our descriptions to make sure that ChatGPT knows what to do when it’s trying to compose calls.