Build your own AI extension

The OpenAI extension for Jspreadsheet is one file of about 150 lines, and it is on GitHub. Read it once, and you know how to put any model behind any spreadsheet formula. This tutorial does exactly that: run it, change it, make it yours.

icon jspreadsheet

Published on October 14, 2026

Introducing

github.com/jspreadsheet/chatgpt is the source of @jspreadsheet/openai, the extension behind =PROMPT("Translate ", A2, " to French"). It is published as a package, but it is more useful as a template. Everything a server-side AI formula needs is in there: how a formula is registered on the engine, how the browser and the server share the work, how an answer reaches every collaborator and survives a reload.

We use it as a worked example. First the anatomy, section by section. Then four changes, each a few lines: a different model (Claude, or a local Ollama), formulas of your own, a name of your own, and finally an extension that does more than formulas.

A spreadsheet with AI formulas evaluated by Jspreadsheet Server through OpenAI, Claude or Ollama

Anatomy of the extension

Clone the repository and open openai.js. It has six parts, in this order.

git clone https://github.com/jspreadsheet/chatgpt.git my-ai-extension
cd my-ai-extension

1. The wrapper

The file is a UMD module: module.exports on the server, a global in the browser. The SDK is loaded only where require exists, so the same file is served to the browser untouched and the API key never leaves the server.

if (! OpenAI && typeof(require) === 'function') {
    var OpenAI = require('openai');
}

;(function(global, factory) {
    typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
    typeof define === 'function' && define.amd ? define(factory) :
    global.openai = factory();
}(this, (function() {
    // ...
})));

2. Module state

Five variables hold everything the extension needs between calls: the server core, the Jspreadsheet namespace, the model client and two optional hooks.

let Server = null;    // core handed to license(): Server.io, Server.helpers
let JSS = null;       // the jspreadsheet namespace
let instance = null;  // the OpenAI client, null in the browser
let beforeSend;       // onbeforesend(instance, request)
let success;          // onsuccess(value, result, x, y, worksheet)

3. query: the only part that knows the model

This function builds a request, sends it, and hands the text answer to a callback. It is the one place in the file that depends on OpenAI. Remember that: changing the model means changing this function and nothing else.

const query = function(prompt, done) {
    let request = {
        messages: [{ role: "system", content: prompt.join(' ') }],
        model: "gpt-4",
    };
    if (beforeSend) {
        beforeSend(instance, request);
    }
    instance.chat.completions.create(request).then(async function(result) {
        let value = result.choices[0].message.content;
        // onsuccess may veto or replace the answer
        await done(value);
    });
}

4. getFormulas: what the user types

A plain object whose keys become spreadsheet functions. PROMPT does three things. It reuses the cached answer while a document is loading, so a reload never calls the model. It returns ... immediately. And on the server only, it calls query and, when the answer comes back, stores it with setCache and broadcasts that same call to the document's room, so every open browser applies it.

OPENAI.PROMPT = function() {
    let w = this.instance, x = this.x, y = this.y;
    let cell = w.getCellObject(x, y);

    // Loading a document: the stored answer, no API call
    if (w.parent.building === true && cell.cache) {
        cell.v = cell.cache;
        return cell.v;
    }

    // Only the server talks to the model
    if (! isFront()) {
        query.call({ w, x, y }, Array.from(arguments), async function(result) {
            let cellName = w.helpers.getCellNameFromCoords(x, y);
            w.setCache(cellName, result);
            const compressed = await Server.helpers.encodeZip({
                method: 'setCache',
                worksheet: w.options.worksheetId,
                args: [cellName, result]
            });
            Server.io.to(w.parent.guid).emit("JSS", compressed);
        });
    }

    return '...';
}

isFront() is typeof document === 'object'. That one test is the whole browser/server split.

5. Extension: the configuration call

Extensions are configured by calling them before the server starts. The hooks are taken out of the options; everything else goes to the SDK constructor.

let Extension = function(options) {
    if (options && options.apiKey) {
        if (typeof(options.onbeforesend) === 'function') { beforeSend = options.onbeforesend; delete options.onbeforesend; }
        if (typeof(options.onsuccess) === 'function') { success = options.onsuccess; delete options.onsuccess; }
        instance = new OpenAI(options);
    }
}

6. Extension.license: the registration hook

jspreadsheet.setExtensions({ openai }, server) calls this with this set to the Jspreadsheet namespace. On the server, the second argument is the live core. The hook registers the formulas on the formula engine.

Extension.license = function(license, server) {
    if (JSS === null) {
        JSS = this;
    }
    Server = server;
    if (JSS.formula) {
        JSS.formula.setFormula(getFormulas());
    }
}

That is the complete extension. The extensions documentation lists everything else the server argument exposes; we come back to it at the end.

Run it as it is

Before changing anything, see it work. On the server:

const server = require('@jspreadsheet/server');
const openai = require('./openai');   // the cloned file

openai({ apiKey: process.env.OPENAI_API_KEY });

server({
    // ... persistence hooks and license
    extensions: { api, openai },
});

In the browser, register the same file next to the formula engine and the client:

jspreadsheet.setExtensions({ formula, client, openai });

Type =PROMPT("Capital of", "France") in a cell. It shows ..., then Paris, on every browser with the document open. The README in the repository has the complete server and browser setup, and the Intrasheets demo is a dockerised application where the file plugs in with three lines.

Change 1: a different model

Everything model-specific lives in query, so this is where the first change goes. Three options, from no code to a new provider.

Option A: keep the file, change the endpoint

Ollama, OpenRouter, Azure OpenAI and many others speak the OpenAI chat completions protocol, and every option the extension does not recognise is forwarded to the SDK constructor. Pointing it at a local Ollama is configuration only:

openai({
    apiKey: 'ollama',                      // required by the SDK, ignored by Ollama
    baseURL: 'http://localhost:11434/v1',  // Ollama's OpenAI-compatible endpoint
    onbeforesend: function(client, request) {
        request.model = 'llama3.2';
    },
});

The spreadsheet data never leaves the machine that runs the server. The same shape works with Anthropic's OpenAI-compatible endpoint, https://api.anthropic.com/v1/, with an Anthropic key and a Claude model name.

Option B: replace query with Claude

For the native Anthropic SDK, swap the require at the top and rewrite query. The rest of the file does not change: PROMPT still calls query(prompt, done) and still receives a string.

// top of the file
if (! Anthropic && typeof(require) === 'function') {
    var Anthropic = require('@anthropic-ai/sdk');
}

// in Extension(options)
instance = new Anthropic(options);

// query
const query = function(prompt, done) {
    let request = {
        model: 'claude-haiku-5-5',
        max_tokens: 300,
        system: 'Reply with the value only, no explanation.',
        messages: [{ role: 'user', content: prompt.join(' ') }],
    };
    if (beforeSend) {
        beforeSend(instance, request);
    }
    instance.messages.create(request).then(async function(result) {
        await done(result.content[0].text);
    });
}

Option C: replace query with plain fetch

Ollama's own API needs no SDK at all. Delete the require at the top, and query becomes a fetch:

const query = function(prompt, done) {
    let request = {
        model: 'llama3.2',
        stream: false,
        messages: [{ role: 'user', content: prompt.join(' ') }],
    };
    if (beforeSend) {
        beforeSend(null, request);
    }
    fetch('http://localhost:11434/api/chat', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(request),
    }).then((r) => r.json()).then(async function(result) {
        await done(result.message.content);
    });
}

With no SDK in the file, the browser bundle needs no fallback rule either: the file imports nothing.

Change 2: your own formulas

PROMPT makes the user write the instructions in every cell, and a model left to itself answers with a paragraph. A formula of your own bakes the instructions in: =TRANSLATE(A2, "French") returns Bonjour, not a sentence about translations.

Factor the body of PROMPT into a helper that takes a function from the formula arguments to the prompt, and getFormulas becomes a list:

const getFormulas = function() {
    // build(args) returns the prompt to send for this formula
    const aiFormula = function(build) {
        return function() {
            let w = this.instance, x = this.x, y = this.y;
            let cell = w.getCellObject(x, y);
            if (w.parent.building === true && cell.cache) {
                cell.v = cell.cache;
                return cell.v;
            }
            if (! isFront()) {
                query.call({ w, x, y }, build(Array.from(arguments)), async function(result) {
                    let cellName = w.helpers.getCellNameFromCoords(x, y);
                    w.setCache(cellName, result.trim());
                    const compressed = await Server.helpers.encodeZip({
                        method: 'setCache',
                        worksheet: w.options.worksheetId,
                        args: [cellName, result.trim()]
                    });
                    Server.io.to(w.parent.guid).emit("JSS", compressed);
                });
            }
            return '...';
        }
    }

    return {
        // The original, unchanged in behaviour
        PROMPT: aiFormula((args) => args),
        // =TRANSLATE(text, language)
        TRANSLATE: aiFormula(([text, language]) => [
            'Translate to ' + language + '. Reply with the translation only:', text
        ]),
        // =CLASSIFY(text, "billing, bug, feature")
        CLASSIFY: aiFormula(([text, categories]) => [
            'Classify into exactly one of: ' + categories + '. Reply with the category only:', text
        ]),
        // =EXTRACT(text, "invoice number")
        EXTRACT: aiFormula(([text, what]) => [
            'Extract the ' + what + '. Reply with the value only, or an empty string:', text
        ]),
        // =SUMMARIZE(text, words)
        SUMMARIZE: aiFormula(([text, words]) => [
            'Summarize in at most ' + (words || 30) + ' words:', text
        ]),
    };
}

query still receives an array and joins it, so none of the options from Change 1 need to be touched. Each formula is an ordinary spreadsheet function: it takes references, fills down, recalculates when its inputs change and feeds other formulas. A COUNTIF over a CLASSIFY column is a support dashboard.

Formula Example Result
PROMPT =PROMPT("Capital of", A2) Paris
TRANSLATE =TRANSLATE(B2, "French") Bonjour
CLASSIFY =CLASSIFY(C2, "billing, bug, feature request") bug
EXTRACT =EXTRACT(D2, "invoice number") INV-2041
SUMMARIZE =SUMMARIZE(E2, 20) one short sentence

Change 3: make it yours

The extension is registered under the key you give it in setExtensions, and that key must be the same on the server and in the browser. Three edits turn the clone into your package:

  1. The global name in the wrapper: global.openai = factory() becomes global.ai = factory(), so a script tag exposes ai.
  2. The registration key: jspreadsheet.setExtensions({ formula, client, ai }) on both sides, and extensions: { api, ai } on the server.
  3. The package: a package.json with your name, "main": "ai.js", and the SDK you kept as its only dependency. Publish it to npm, or keep it in your repository and require('./ai').
const ai = require('@acme/sheets-ai');

ai({ apiKey: process.env.ANTHROPIC_API_KEY });

server({
    // ...
    extensions: { api, ai },
});

One rule to keep from the original: the configuration call happens before server(), and the license hook does the wiring. The server calls every extension's hook in object order once the license is validated, so your extension can rely on api being there if you list it first.

Change 4: more than formulas

Formulas are one thing an extension can register. The server argument of license is the live core, and through server.options.extensions.api your extension can add HTTP routes next to the REST API. A document route receives the loaded worksheet after your authentication hooks have passed, which is exactly what a "translate this whole column" button needs: one request, one model call per cell, no formulas left behind.

// A document route: POST /api/<guid>/<worksheet>/translate  { column: 1, language: 'French' }
const documentRoutes = function(routers) {
    routers.translate = {
        post: async function(Server) {
            // `this` is the request context: this.post is the body,
            // this.worksheet the live worksheet, this.worksheetId its id
            let { column, language } = this.post;
            let values = this.worksheet.getColumnData(column);
            let translated = [];
            for (let value of values) {
                translated.push(value ? await ask(['Translate to ' + language + '. Reply with the translation only:', value]) : '');
            }
            // Push the result through the same pipeline the sockets use: applied,
            // persisted by your adapter and broadcast to every connected browser
            this.changes.push({
                method: 'setColumnData',
                worksheet: this.worksheetId,
                args: [column, translated],
            });
        }
    }
}

Extension.license = function(license, server) {
    Server = server;
    if (this.formula) {
        this.formula.setFormula(getFormulas());
    }
    let api = server.options.extensions && server.options.extensions.api;
    if (api) {
        api.setModule(documentRoutes);
    }
}

ask is query wrapped in a promise. The route runs after the document is loaded and your beforeLoad hook has passed, and the write goes through this.changes, so there is nothing to persist or broadcast by hand.

The agent, the MCP server and the Intrasheets routes are all extensions written this way, with the same two hooks you have just read. The extensions documentation describes setAction for global routes, setModule for document routes, and the rest of the core.

Good practices

  • Keep the browser/server split. The browser returns the placeholder, the server owns the keys. An extension that calls a model from the browser gives every user your key and a different answer.
  • Ask for the value only. The system prompt does the formatting; a cell has no room for an explanation.
  • Pick the model per formula. Classification and extraction are easy and cheap on a small model; reserve larger ones for summaries and open questions.
  • Publish errors. Store #AI rate limit with setCache instead of leaving the cell on .... A visible error is a diagnosis; a pending cell is a support ticket.
  • Mind recalculation. Every recalculation is a model call. A column filled down over ten thousand rows deserves a rate limit in query, or a local model.
  • Prefer local models for sensitive data. With Ollama nothing leaves your infrastructure, and the formulas do not change.

Useful links

The OpenAI extension on GitHub, the template for this tutorial
Writing server extensions
PROMPT formula documentation
AI with Jspreadsheet Server
Intrasheets demo application