In the first article in this series, 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:
{
"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 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, we see that getProfile takes a single parameter:
{
"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
:
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:
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.
curl 'localhost:3005/api/app.bsky.actor.getProfile?actor=bsky.app' | jq '.'
{
"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.
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 toapp.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.
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.
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:
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.
Top comments (0)