Writing ChatGPT Plugins with JavaScript

Learn how to create a ChatGPT plugin with JavaScript by recreating the official TODO example from Python to JavaScript.

Miro
Digital Products Tech Tales
15 min readOct 6, 2023

--

Photo by BoliviaInteligente on Unsplash

The official OpenAI API documentation does have examples with both Python and NodeJS (JavaScript). However, the ChatGPT plugin docs have only Python examples and the sample repo is also in Python. Thus, here we will try to describe how to get started with a ChatGPT plugin if you are a JavaScript developer. We will do it using the official Python TODO (of course it is a todo…) example. There are a few slight differences between how a plugin is setup between Python and Node, so hopefully this article will help clear things up!

So let’s get started!

Prerequisites

In order to build plugins for ChatGPT, you must be a subscriber to ChatGPT Plus which at the time of writing of this article is $20 per month. Also, you need to have developer access. You can request it on the OpenAI website.

Also, please make sure you have Node and npm installed. You can download Node from the official Node website and npm comes bundled with it. I will be using v18 (current LTS version) but you are free to use v20 if you like to live on the edge 😃.

Overview

With the basics out of the way, let’s see how we are going to approach building the plugin

  1. We will start with reviewing the folder structure we need to setup as well as the files and understand each one’s purpose.
  2. Next, we will dive into building the plugin by creating the necessary folders and files. We will also install any necessary dependencies/npm packages we need to stand up our server.
  3. After the project setup, we will start writing some code (after all, that’s why we are here 😆)
  4. (Optional) we will test our API to make sure everything works as expected before we deploy our plugin to ChatGPT.
  5. Finally, we will install our plugin using the ChatGPT UI console and see our plugin in action!

Without further ado, let’s get started!

What we will be building

First, let’s outline the folder structure:

|-- my-chatgpt-plugin/
|-- .well-known/
| |-- ai-plugin.json (1)
|-- routes/
|-- openai.js (2)
|-- todos.js (3)
|-- index.js (4)
|-- logo.png (5)
|-- openapi.yaml (6)
  1. As outlined in the official docs, we need to have a file named ai-plugin.json. ChatGPT will be looking for it in order to register the plugin and it will look for it in the .well-known folder. If you would like to learn more about this folder and its purpose, you can take a look at the official docs. This file contains information (metadata) about the plugin itself like which schema it is using, name, logo, among other information. Please refer to the official docs for all the available fields. For the purposes of this article, we will just grab the one from the official docs.
  2. We need to create routes (three to be exact) for ChatGPT/OpenAI to fetch the necessary metadata (ai-plugin.json), our plugin logo, and the OpenAPI specs that define our backend. One point to highlight here is we need to return the OpenAPI specs in a JSON format. The Python example returns them in yaml format, but in Node would need to parse it into JSON and return application/json. (we will dive into the code in a little bit)
  3. We need a file that will house our actual endpoints that will perform the actions we trigger via ChatGPT (in our case list, add and remove todo items).
  4. This will be our entry point to our plugin. This is where we will configure our express server and attach the necessary routes for our backend and OpenAI/ChatGPT config routes.
  5. Our logo that will represent our plugin. This is what the users will see when they search for our plugin and see when they have it installed. We are using the logo from the official example repo for this demo.
  6. Again, as outlined in the official docs, we need to provide our backend definitions and what our backend looks like — what are the available endpoints, what payload they expect to receive and return, etc. following the OpenAPI specifications. Given we are recreating the TODO example from the official docs, we will grab the backend specs from the docs/repo.

So what we should end up with is something like the below result:

Building the plugin

As promised, let’s dive into the code!

Creating the project setup

First, let’s create our directory and initialize our project.

mkdir my-chatgpt-plugin and cd my-chatgpt-plugin
mkdir .well-known routes
npm init --yes
touch index.js openapi.yaml
touch .well-known/ai-plugin.json
touch routes/{openai,todos}.js

Also, don’t forget to download the logo.png image from the OpenAI repo or use your own!

With that, our folder structure we outlined above should be all set:

Note: if you are missing the package-lock.json file, do not worry, it will be automatically created in a moment once we install our first npm package, but the rest should be there)

Please note the rest of the tutorial will use the modern import/export syntax of ES Modules. In order to do that, you should update your package.json to specify the type:

// package.json
{
"name": "my-chatgpt-plugin",
"version": "1.0.0",
"type": "module" // <-- add this line
}

You are free to use the default CommonJS import/export syntax (require, module.exports, etc.) but it might make it a bit difficult to follow the code below.

Plugin metadata (ai-plugin.json)

Let’s start with our ai-plugin.json file. Given we are implementing the TODO example from the official docs, we will just reuse the contents of the Python sample and maybe add our own color to it by updating the name_for_human field value. In our example below, I’ve also changed the port number to 3333. You don’t have to change it, I did it because it is a bit easier to type the same four digits when installing the plugin in the ChatGPT UI 😄:

// ai-plugin.json
{
"schema_version": "v1",
"name_for_human": "My JavaScript ChatGPT Plugin",
"name_for_model": "todo",
"description_for_human": "Manage your TODO list. You can add, remove and view your TODOs.",
"description_for_model": "Help the user with managing a TODO list. You can add, remove and view your TODOs.",
"auth": {
"type": "none"
},
"api": {
"type": "openapi",
"url": "http://localhost:3333/openapi.yaml"
},
"logo_url": "http://localhost:3333/logo.png",
"contact_email": "support@yourdomain.com",
"legal_info_url": "http://www.yourdomain.com/legal"
}

This is already mentioned in the official docs, but I think it is worth calling out that the description fields of the metadata are really important. ChatGPT uses them to chose the correct model the plugin will understand.

Backend specifications (OpenAPI specs — openapi.yaml)

Next, let’s do the same with our OpenAPI specs — copy and paste the contents from the repo to our own file (If you ended up updating the port in your ai-plugin.json file, please make sure to update it here as well!):

// openapi.yaml
openapi: 3.0.1
info:
title: TODO Plugin
description: A plugin that allows the user to create and manage a TODO list using ChatGPT and JavaScript. If you do not know the user's username, ask them first before making queries to the plugin. Otherwise, use the username "global".
version: 'v1'
servers:
- url: http://localhost:3333
paths:
/todos/{username}:
get:
operationId: getTodos
summary: Get the list of todos
parameters:
- in: path
name: username
schema:
type: string
required: true
description: The name of the user.
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/getTodosResponse'
post:
operationId: addTodo
summary: Add a todo to the list
parameters:
- in: path
name: username
schema:
type: string
required: true
description: The name of the user.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/addTodoRequest'
responses:
"200":
description: OK
delete:
operationId: deleteTodo
summary: Delete a todo from the list
parameters:
- in: path
name: username
schema:
type: string
required: true
description: The name of the user.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/deleteTodoRequest'
responses:
"200":
description: OK

components:
schemas:
getTodosResponse:
type: object
properties:
todos:
type: array
items:
type: string
description: The list of todos.
addTodoRequest:
type: object
required:
- todo
properties:
todo:
type: string
description: The todo to add to the list.
required: true
deleteTodoRequest:
type: object
required:
- idx
properties:
idx:
type: integer
description: The index of the todo to delete.
required: true

Install necessary packages

Enough copy and paste! Let’s roll up our sleeves and write some code!

First let’s install a couple of npm packages that we would need. We have our API definitions, so we need to stand up a server that will serve our endpoints, thus, we will install expressjs . Also, we will need to manage which origins can access our server, so we can allow ChatGPT to access it. For this purpose, we will install cors to help us with that. Finally, as we pointed out earlier, we will need to parse the OpenAPI specs to JSON format and we will use js-yaml . And that should do it for us. To install the necessary packages just run

npm install --save express cors js-yaml

This should create your package-lock.json file 😉

Routes to expose the plugin metadata (openai.js)

Next, let’s create the three routes we mentioned above to allow ChatGPT to grab the necessary information about our plugin during the plugin installation process. The three routes are GET (duh!) routes and will return the:

  1. logo
  2. plugin metadata (ai-plugin.json)
  3. our OpenAPI specs in JSON format (openapi.yaml)

First, let’s import the necessary dependencies and initialize the express router:

// openai.js

import { Router } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
import { load } from 'js-yaml';

const router = Router();

The first route is the simplest one — returning the logo:

// openai.js
// ...

function getPluginLogo(req, res) {
res.sendFile(join(process.cwd(), 'logo.png'));
}

router.get('/logo.png', getPluginLogo);

We need to return the contents of the ai-plugin.json file, but there is no special handling of the contents, and we can just return it:

// openai.js
// ...

function getPluginMetadata(req, res) {
res.sendFile(join(process.cwd(), '/.well-known/ai-plugin.json'));
}

router.get('/.well-known/ai-plugin.json', getPluginMetadata);

And finally, the last route we will define is sharing the OpenAPI specs with ChatGPT. The route would read the yaml file from disk and convert it to JSON before returning the OpenAPI specs:

// openai.js
// ...

function getOpenAPISpecs(req, res) {
// grab the OpenAPI specs from disk
const openApiSpecs = readFileSync(join(process.cwd(), 'openapi.yaml'), 'utf8');

// let's try to parse the YAML file and return it as JSON
try {
const specsInJson = load(openApiSpecs);
res.json(specsInJson);
} catch(error) {
console.error(error.message)
res.status(500).send({ error: 'Unable to parse the OpenAPI specs.' });
}
}

router.get('/openapi.yaml', getOpenAPISpecs);

and last but not least, we need to export our routes so we can make them available to our server later on:

// openai.js
// ...

export default router;

The final version of openai.js should look something like this:

// openai.js

import { readFileSync } from 'fs';
import { join } from 'path';
import { Router } from 'express';
import { load } from 'js-yaml';

const router = Router();

function getPluginLogo(req, res) {
res.sendFile(join(process.cwd(), 'logo.png'));
}

function getPluginMetadata(req, res) {
res.sendFile(join(process.cwd(), '/.well-known/ai-plugin.json'));
}

function getOpenAPISpecs(req, res) {
const openApiSpecs = readFileSync(join(process.cwd(), 'openapi.yaml'), 'utf8');

// let's try to parse the YAML file and return it as JSON
try {
const specsInJson = load(openApiSpecs);
res.json(specsInJson);
} catch(error) {
console.error(error.message)
res.status(500).send({ error: 'Unable to parse the OpenAPI specs.' });
}
}

router.get('/logo.png', getPluginLogo);
router.get('/.well-known/ai-plugin.json', getPluginMetadata);
router.get('/openapi.yaml', getOpenAPISpecs);

export default router;

Our API (todos.js)

Supplying metadata to ChatGPT will not be enough for our plugin to work, so we need to build the actual backend that will handle the requests. Our todo plugin will need to be able to list tasks (GET), add new tasks (POST), and, of course, mark tasks as completed when done (DELETE). Our endpoints must match our OpenAPI specs, thus, the URI parameters, request and responses bodies, etc. of our routes will match the specs. (You are welcome to experiment and change the routes’ logic and update the OpenAPI specs to reflect that)

With our goals defined, we will start with importing the necessary dependencies and initialize the express router:

// todos.js
import { Router } from 'express';

const router = Router();

We will not be using a DB to keep things simple, so we will define an object that will keep track of our tasks:

// todos.js
// ...

// Keep track of todo's. Does not persist if the Node process is restarted.
const todos = {};

The first route we will build is to return the available tasks for the current user:

// todos.js
// ...

// returns the list of todos
function getTodos(req, res) {
res.json({ todos: todos[req.params.username] ?? [] });
}

router.get('/:username', getTodos);

Now that we can get a list of our todos, let’s add a way to create new tasks for our users:

// todos.js
// ...

// adds a new todo to the list
function addTodo(req, res) {
// grab the username from the URI path
const { username } = req.params;

// add the username to the map to keep track of the
// user's tasks if the user does not exist already
if (!todos[username]) {
todos[username] = [];
}

// add the new task to the list
todos[username].push(req.body.todo);

// return the new task
res.json(req.body);
}

router.post('/:username', addTodo);

Let’s add a way to mark our tasks completed:

// todos.js
// ...

// marks a todo as completed
function removeTodo(req, res) {
// grab the username from the URI path
const { username } = req.params;

// grab the index of the todo to be removed from the request body
const { idx } = req.body;

// remove the todo from the list
todos[username]?.splice(idx, 1);

res.json({ message : 'Todo successfully marked as completed.' });
}

router.delete('/:username', removeTodo);

And let’s not forget to export our routes, so we can make them available to our server which we will define in the next section below:

// todos.js
// ...

export default router;

Let’s put everything for our API routes (todos.js) together now:

// todos.js

import { Router } from 'express';

const router = Router();

// Keep track of todo's. Does not persist if the Node process is restarted.
const todos = {};

// returns the list of todos
function getTodos(req, res) {
res.json({ todos: todos[req.params.username] ?? [] });
}

// adds a new todo to the list
function addTodo(req, res) {
// grab the username from the URI path
const { username } = req.params;

// add the username to the map to keep track of the
// user's tasks if the user does not exist already
if (!todos[username]) {
todos[username] = [];
}

// add the new task to the list
todos[username].push(req.body.todo);

// return the new task
res.json(req.body);
}

// marks a todo as completed
function removeTodo(req, res) {
// grab the username from the URI path
const { username } = req.params;

// grab the index of the todo to be removed from the request body
const { idx } = req.body;

// remove the todo from the list
todos[username]?.splice(idx, 1);

res.json({ message : 'Todo successfully marked as completed.' });
}

router.get('/:username', getTodos);
router.post('/:username', addTodo);
router.delete('/:username', removeTodo);

export default router;

Our server (index.js)

Finally, let’s setup our server and wire everything together starting with our imports and initializing our express app:

// index.js

import express, { json } from 'express';
import cors from 'cors';
import { default as openaiRoutes } from './routes/openai.js';
import { default as todoRoutes } from './routes/todos.js';

const app = express();
const PORT = 3333; // ideally this should be an environment variable ;-)

As mentioned earlier, we need to allow ChatGPT to talk to our backend, thus, we need to setup our CORS rules and we will do that with the help of the cors package we already installed:

// index.js
// ...

// Setting CORS to allow the right origins (chat.openapi.com is required for OpenAI/ChatGPT)
app.use(cors({ origin: [`http://localhost:${PORT}`, 'https://chat.openai.com'] }));

ChatGPT’s calls to our backend will be of application/json MIME type, thus, we need to allow our backend to process these requests. And while we are at it, let’s add a small middleware to log all requests that come to our API for a sanity check:

// index.js
// ...

// Middleware to parse JSON bodies (application/json mime type)
app.use(json());

// Middleware to log every request, so we can easily verify our plugin is being called by ChatGPT
app.use((req, res, next) => {
console.log(`Request: ${req.method} ${req.path}`);
next();
});

With the setup out of the way, it is time we added the routes we built already:

// index.js
// ...

// OpenAI/ChatGPT metadata/necessary routes
app.use(openaiRoutes);

// The todos routes (our plugin's routes)
app.use('/todos', todoRoutes);

We have everything setup and wired now, so the last piece missing is to tell our express server to listen on the port we defined in our OpenAPI specs and above in our index.js file:

// index.js
// ...

app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});

Our index.js should look something like this:

// index.js

import express, { json } from 'express';
import cors from 'cors';
import { default as openaiRoutes } from './routes/openai.js';
import { default as todoRoutes } from './routes/todos.js';

const app = express();
const PORT = 3333; // ideally this should be an environment variable ;-)

// Setting CORS to allow the right origins (chat.openapi.com is required by OpenAI/ChatGPT)
app.use(cors({ origin: [`http://localhost:${PORT}`, 'https://chat.openai.com'] }));

// Middleware to parse JSON bodies
app.use(json());

// Middleware to log every request, so we can easily verify our plugin is being called by ChatGPT
app.use((req, res, next) => {
console.log(`Request: ${req.method} ${req.path}`);
next();
});

// OpenAI/ChatGPT metadata/necessary routes
app.use(openaiRoutes);

// The todos routes (our plugin's routes)
app.use('/todos', todoRoutes);

app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});

Time to start our server! 🚀

node index.js

If everything worked as expected, you should see the below out in your terminal:

Server listening on port 3333

Test our API (Optional)

Before we go ahead and install our plugin, it might be a good idea to test our routes to make sure they work as we expect them to as a sanity check. You can use curl, Postman or any other tool you feel comfortable using. So, let’s make a few calls to verify our backend:

Getting the list of tasks for user “miro” and as expected, there are no TODOs to start with because we have not created any yet.
Adding a task to buy milk 🥛
If we get our list now, it should have the task we just added
It seems we were able to mark our task as completed ✅, let’s verify that by getting the list again 😉
We bought milk and we have no more tasks! 🎉

We successfully validated our API and we are ready to use it with ChatGPT!

Installing the plugin

Go to https://chat.openai.com/ → login if you are not already → start a new chat (if not automatically started) → hover over GPT-4 and click Plugins:

Click on Plugin store:

Click on Develop your own plugin (in the bottom right of the modal):

Enter the server’s URL, in our case localhost:3333 and click Find manifest file button:

At this point you should see two requests being logged in your terminal — GET /.well-known/ai-plugin.json and GET /openapi.yaml. If ChatGPT successfully validated our plugin, you should see a modal that looks something like the below:

Let’s click that Install localhost plugin button finally! The plugin will install and it should be selected already, but if not, make sure to select it:

Note: If you have other plugins installed, I would recommend to disable them. Local plugins may not work (well) with other plugins enabled and may cause some issues while testing your local plugin.

Let’s test our plugin now!

Let’s set our user and see if we have any tasks:

ChatGPT offers a nice debugging info, so we can see what got sent and received by ChatGPT. Pretty neat!

We do not have any tasks, so let’s add something to our list — buy milk and wash the car:

We can add more than one task at a time and ChatGPT will call our backend as many times as it needs to, no need for us do separate prompts!

And finally, let’s complete a task:

Conclusion

That is all! We have successfully built, installed, and tested our ChatGPT plugin written in JavaScript! Hope this was helpful in figuring out how to setup your plugin project when working with JavaScript/Node backends!

--

--