Extensions
Extensions add server-side capabilities such as REST routes, AI features and user management. The core only manages the in-memory documents, the socket protocol and the persistence events; everything else is an extension registered at startup.
How extensions work
Registering extensions
Pass extension modules 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 object order. server is the live core instance:
| 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 module scope; the license hook later wires it into the server:
// 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, which owns the HTTP server and exposes two registration points for use inside a 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). They 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 REST API and the HTTP server; 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
Package: @jspreadsheet/server-agent. The AI agent:
- a streamed chat response at
POST /api/<guid>/prompt(Content-Type: text/event-stream), always with the spreadsheet tools and the document context; - file attachments (Excel, CSV, PDF, images), stored in S3 when a bucket is configured;
- 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; - 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>. The 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
Package: @jspreadsheet/mcp. A Model Context Protocol server at /mcp, so AI clients (Claude, IDEs, custom agents) can read and manipulate spreadsheets as MCP tools. It wraps the api extension's HTTP server when present, otherwise creates its own. See MCP Server.
Configuring the agent extension
The agent takes three persistence stores, which can point 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, request) => { ... },
get: async (guid, auth) => { ... }, // served at GET /api/<guid>/usage
},
// Called before every prompt reaches the model: return false, or a
// message for the user, to refuse it with HTTP 429. A hook that throws
// refuses too. request: { ip, prompt, files, bytes, session, req }
allow: async (guid, auth, request) => true,
// Attachment storage; the AWS_* environment otherwise. Without S3 an
// attachment is read in the request that sends it only
s3: { key, secret, bucket, region, url },
});
The model comes from the environment: CLAUDE_API_KEY, and CLAUDE_MODEL (default claude-sonnet-4-6; claude-haiku-4-5 is the economical choice for a public page).
Quotas. A public assistant needs limits, set with allow and usage: count tokens per address in usage.set, refuse in allow once the address or the whole site passes its daily budget, and cap prompts per minute so a burst cannot start many requests before the first one is counted. request.ip is the X-Real-IP header when a proxy sets it (proxy_set_header X-Real-IP $remote_addr; in nginx), otherwise the socket address. A complete Redis example is in the package README; the AI agent page documents every route, hook and option.
The prompt store receives the full message history of a session (user messages, assistant responses and tool results) and saves it back after every exchange; attachments are uploaded to S3 and only their metadata is kept in the messages. Without a store, 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 UMD module with an optional configuration function and the license hook.
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 validates, versions, persists and broadcasts the operation like any client edit.
Return data with this.result, errors with this.code
Setting this.code = 403 with this.result = 'Forbidden' produces a JSON error response.
Check the request context
Document routes run after beforeConnect/beforeLoad passed, so the user may load the document; apply finer-grained checks (e.g. owner-only) inside the route when needed.
Broadcasting to clients
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
A raw emit updates screens but not the document. If the change must be persisted and versioned, push it through server.updateSpreadsheet (or this.changes in a route) instead.