Extensions

Extensions add server-side capabilities such as REST routes, AI features and user management, without touching the server core. The core stays small: it manages the in-memory documents, the socket protocol and the persistence events, and everything else is an extension registered at startup.

How extensions work

Registering extensions

An extension is a module passed to the server through the extensions option:

const api = require('@jspreadsheet/server-api');
const agent = require('./agent');

server({
    // ...
    extensions: { api, agent },
    license: { clientId, licenseKey },
});

The license hook and the server instance

When the license is validated, the server calls each extension's static license(license, server) hook, in the order they appear in the object. The server argument is the live core instance, giving the extension access to:

Property Description
server.io The socket.io server. Broadcast to document rooms with server.io.to(guid).emit(...)
server.options Everything you passed to server({...}), including the other extensions
server.spreadsheets The in-memory document cache ({ [guid]: { instance, rev, updated } })
server.helpers encodeZip / decodeZip for the wire format
server.loadSpreadsheet, server.updateSpreadsheet, ... The core document lifecycle methods

Convention 1: configure by calling the extension

An extension is configured by calling it as a function before the server starts. The call stores options in the extension's module scope; the license hook later wires it into the server. This keeps configuration (yours) separate from registration (the server's):

// Configuration: inject your persistence, keys, stores
agent({
    prompt: { list, get, set, delete },
    usage: { set, get },
});

// Registration: the server calls agent.license(...) internally
server({ extensions: { api, agent }, ... });

Convention 2: compose through the api extension

Extensions compose through the api extension. The api extension owns the HTTP server and exposes two registration points that other extensions use inside their license hook:

  • api.setAction(fn) registers global actions: routes of the form /api/<name> (e.g. /api/chats, /api/profile).
  • api.setModule(fn) registers document routes: /api/<guid>/[<worksheet>/]<name> (e.g. /api/<guid>/prompt). Document routes run after the document is loaded and the access-control hooks have passed, and receive the live worksheet instance.
Extension.license = function(license, server) {
    let api = server.options.extensions?.api;
    if (api) {
        api.setAction(myGlobalRoutes);   // adds to /api/<action>
        api.setModule(myDocumentRoutes); // adds to /api/<guid>/.../<route>
    }
}

Available extensions

api

Package: @jspreadsheet/server-api. The full REST API and the HTTP server itself. All other extensions register their routes through it. Configure with api({ s3 }) for image/media storage. See Server API and the REST API reference.

agent

Source: agent/. The AI agent: a streamed chat response at POST /api/<guid>/prompt (Content-Type: text/event-stream), an agent mode with spreadsheet tools, file attachments (Excel, PDF, images) stored in S3, chat sessions (GET /api/<guid>/prompt?list=1, ?session=<id>, DELETE /api/<guid>/prompt?session=<id>), the user's chat list at GET /api/chats, and token usage tracking at GET /api/<guid>/usage. Configure with agent({ prompt, chats, usage }), described below.

intrasheets

Source: intrasheets/. Application-level routes: GET/POST /api/profile (user profile), GET /api/list (the user's documents, backed by the list server option) and invited-user management at GET/POST /api/<guid>/users and DELETE /api/<guid>/users/<email>. All three user routes are owner-only, enforced through the isOwner server option. Configure with intrasheets({ users: { get, set } }), where get(guid, auth) returns the invited users and set(guid, data, newUsers, auth) saves the merged list.

openai

Package: @jspreadsheet/openai. Registers the =PROMPT() formula, which sends OpenAI chat-completion requests from the server. Configure with openai({ apiKey }). See AI integration.

mcp

Source: mcp/. A Model Context Protocol server at /mcp, so AI clients (Claude, IDEs, custom agents) can read and manipulate spreadsheets as MCP tools. When the api extension is present it wraps its HTTP server; otherwise it creates one of its own. See MCP Server.

Configuring the agent extension

The agent owns three persistence concerns, each injected as a small store object. Point them at any storage; the reference implementation uses the same adapter as the documents:

agent({
    // Chat messages, split into sessions
    prompt: {
        list: async (guid, auth) => { ... },            // sessions of a user on a document
        get: async (guid, auth, sessionId) => { ... },  // messages of one session
        set: async (guid, messages, auth, sessionId) => { ... },
        delete: async (guid, auth, sessionId) => { ... },
    },
    // Chat list across documents, served at GET /api/chats
    chats: {
        get: async (auth) => { ... },
    },
    // Token usage: called after every model response with
    // { input, output, cacheRead, cacheWrite }
    usage: {
        set: async (guid, usage, auth) => { ... },
        get: async (guid, auth) => { ... },             // served at GET /api/<guid>/usage
    },
});

The prompt store receives the full message history of a session, meaning user messages, assistant responses and tool results, and saves it back after every exchange; file attachments are uploaded to S3 and only their metadata is kept in the messages. When a store is not provided, the extension falls back to the legacy server options (getPrompts/setPrompts for prompt, list for chats).

Each store callback receives the auth object, so your implementation decides which user can read which sessions, typically by decoding the JWT subject, as shown in Authentication.

Building your own extension

An extension is a plain module with the UMD wrapper, an optional configuration function, and the license hook. The skeleton below registers one global action and one document route:

Skeleton: one action and one document route

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

    // Module-scope configuration, injected before the server starts
    let store = null;

    // A global action: GET /api/audit
    const globalRoutes = function(actions) {
        actions.audit = {
            get: async function(Server) {
                // `this` is the request context: this.authQuery, this.query,
                // this.post, this.result, this.code
                this.result = await store.summary(this.authQuery);
            }
        }
    }

    // A document route: GET /api/<guid>/log, runs with the document loaded
    // and the user already authorized by your beforeLoad hook
    const documentRoutes = function(routers) {
        routers.log = {
            get: async function(Server) {
                let guid = this.path[1];
                this.result = await store.log(guid, this.authQuery);
            },
            post: async function(Server) {
                // Push a change through the same pipeline the sockets use:
                // it is applied to the in-memory instance, persisted through
                // your adapter, and broadcast to connected clients
                this.changes.push({
                    method: 'setComments',
                    worksheet: this.worksheetId,
                    args: [{ A1: 'Reviewed' }],
                });
            }
        }
    }

    // Configuration call: audit({ store })
    let Extension = function(options) {
        // Guarded so a no-arg re-instantiation cannot wipe the configuration
        if (options && options.store) {
            store = options.store;
        }
    }

    // Registration: called by the server once the license is validated
    Extension.license = function(license, server) {
        let api = server.options.extensions?.api;
        if (api) {
            api.setAction(globalRoutes);
            api.setModule(documentRoutes);
        }
    }

    return Extension;

})));

Configure and register the extension

const audit = require('./audit');

audit({ store: myAuditStore });

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

Keep a feature's code in its extension

Persistence callbacks, routes and validation for a feature belong in the extension, injected via its configuration function, not as extra callbacks on the global server({...}) options. The application's index.js should contain only wiring: adapters and access control.

Route changes through this.changes

Do not mutate worksheet options directly from a route; pushing to this.changes ensures the operation is validated, versioned, persisted and broadcast like any client edit.

Return data with this.result, errors with this.code

Setting this.code = 403 with this.result = 'Forbidden' produces a proper JSON error response.

Check the request context

Document routes run after beforeConnect/beforeLoad passed, so the user may load the document, but apply your own finer-grained checks (e.g. owner-only) inside the route when needed.

Broadcasting to clients

Any extension can push updates to the connected clients of a document:

Emitting to a document room

const compressed = await server.helpers.encodeZip({
    method: 'setData',
    worksheet: worksheetId,
    args: [[[1, 2, 3]]],
});
server.io.to(guid).emit('JSS', compressed);

Display-only versus persisted updates

If the change must also be persisted and versioned, prefer pushing it through server.updateSpreadsheet (or this.changes in a route) instead of emitting directly, because a raw emit updates screens but not the document.