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.