AI Agent
ULTIMATE
The @jspreadsheet/server-agent extension adds an AI chat to Jspreadsheet Server. For a prompt on a document, the agent gives the model the live content of that document and a set of spreadsheet tools, runs the tool calls the model makes, and streams the answer back. Tool calls are ordinary server operations: validated by your hooks, applied to the live document, persisted by your adapter and broadcast to every connected client.
The Intrasheets AI assistant is a client of this extension. See AI with Jspreadsheet Server for how it compares with the MCP server and the PROMPT formula.
Installation
npm install @jspreadsheet/server @jspreadsheet/server-api @jspreadsheet/server-agent
The agent registers its routes on the API extension, so declare both:
const server = require('@jspreadsheet/server');
const api = require('@jspreadsheet/server-api');
const agent = require('@jspreadsheet/server-agent');
agent({
// hooks, all optional: see below
});
server({
// ...your options
extensions: { api, agent },
});
Model
Configured through the environment; a .env file in the working directory is read on start.
| Variable | Use |
|---|---|
CLAUDE_API_KEY |
Anthropic API key (required) |
CLAUDE_MODEL |
Model id. Default claude-sonnet-4-6; claude-haiku-4-5 is the fast, economical choice for a public assistant |
Each answer runs up to 10 model calls (tool call, result, next step) and 16,000 output tokens per call.
Routes
| Route | Use |
|---|---|
POST /api/<guid>/prompt |
Send a prompt; the answer is streamed |
GET /api/<guid>/prompt?session=<id> |
The messages of a chat session |
GET /api/<guid>/prompt?list=1 |
The chat sessions of the document |
DELETE /api/<guid>/prompt?session=<id> |
Delete a session |
GET /api/<guid>/usage |
The usage recorded by your usage store |
GET /api/chats |
The user's sessions across documents |
Every route runs your beforeConnect hook, and the document routes your beforeLoad hook, like the rest of the REST API. The Authorization: Bearer <token> header reaches every hook as auth.token.
Sending a prompt
The body is form data:
| Field | Value |
|---|---|
prompt |
The user's message |
session |
A chat session id chosen by the client; messages of the same session form one conversation |
files |
Optional JSON array of attachments: [{ "name": "q3.xlsx", "type": "...", "data": "data:...;base64,..." }] |
const body = new FormData();
body.append('prompt', 'Add a total column for each region');
body.append('session', sessionId);
const response = await fetch(SERVER + '/api/' + guid + '/prompt', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
body: body,
});
if (response.status === 429) {
// Refused by your allow hook: the body is the message for the user
show(await response.text());
} else {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
show(text);
}
}
The response is sent as text/event-stream, but its body is plain text, not SSE events: the model's words as they are generated, with a short progress line for each tool call.
Tools
The model edits through the Jspreadsheet API, never through free text:
| Group | Tools |
|---|---|
| Reading | getData, getConfig |
| Values and formulas | setValue, setData, setRowData, setColumnData, paste |
| Structure | insertRow, deleteRow, insertColumn, deleteColumn, createWorksheet, setHeader, setColumnProperties, setCellProperties |
| Presentation | setStyle, resetStyle, setFilter, setMedia, deleteMedia |
| History | undo, redo |
| Attachments | viewImage |
Every prompt also carries a summary of the document: its worksheets, columns and data, formulas included. The model provider caches the parts that do not change between turns, so long conversations do not pay for the document on every message.
Hooks
agent({
// Before every prompt reaches the model
allow: async (guid, auth, request) => true,
// After every model call
usage: {
set: async (guid, usage, auth, request) => {},
get: async (guid, auth) => {}, // GET /api/<guid>/usage
},
// Conversations
prompt: {
get: async (guid, auth, session) => [],
set: async (guid, messages, auth, session) => {},
list: async (guid, auth) => [],
delete: async (guid, auth, session) => {},
},
// GET /api/chats
chats: {
get: async (auth) => [],
},
// Attachment storage
s3: { key, secret, bucket, region, url },
});
request describes the prompt request:
| Property | Value |
|---|---|
ip |
The client address: the X-Real-IP header when a proxy sets it, otherwise the socket address |
prompt |
The message text |
files |
Number of attachments |
bytes |
Size of the attachments as sent |
session |
The session id |
req |
The Node.js request, for anything else |
usage is { input, output, cacheRead, cacheWrite }, the token counts of one model call.
Without a prompt store every message starts a new conversation. Each store callback receives auth, so your implementation decides whose sessions are whose, typically from the token subject, as in Authentication.
Quotas
allow refuses a prompt before anything is spent: return false, or a string that the user sees, and the request ends with HTTP 429. A hook that throws refuses too, so an outage of your quota store cannot open the budget.
A typical policy has three parts:
- A daily budget per user or per address, counted in
usage.setand checked inallow. - A site-wide daily budget, the real protection against many addresses.
- A rate limit, a few prompts per minute: usage is only known after a model call, so a burst could otherwise start many requests before the first one is counted.
const LIMIT = 500000;
const today = () => new Date().toISOString().slice(0, 10);
// Cache reads cost a tenth of an input token
const tokens = (u) => u.input + u.output + u.cacheWrite + Math.ceil(u.cacheRead / 10);
agent({
allow: async function(guid, auth, request) {
const spent = Number(await redis.get('ai:' + request.ip + ':' + today()));
return spent < LIMIT || 'You reached the daily AI limit. Please come back tomorrow.';
},
usage: {
set: async function(guid, usage, auth, request) {
const key = 'ai:' + request.ip + ':' + today();
await redis.incrBy(key, tokens(usage));
await redis.expire(key, 2 * 86400);
},
},
});
The package README has the complete version with the site-wide budget, the rate limit and attachment limits. For per-user tracking, cost and billing integrations, see Usage & billing.
Behind a proxy. The server sees the proxy's address unless the proxy forwards the client's. With nginx:
location /s/ {
proxy_set_header X-Real-IP $remote_addr;
# ...the rest of the proxy configuration
}
Only rely on X-Real-IP when the server is reachable through the proxy alone; otherwise anyone can send the header. See Nginx.
Attachments
| Type | How the model reads it |
|---|---|
| Excel and CSV | Converted to tables with SheetJS |
| Converted to text | |
| Images | Shown to the model when it calls viewImage |
With S3 configured (agent({ s3 }) or the AWS_BUCKET, AWS_S3_KEY, AWS_S3_SECRET, AWS_S3_REGION and AWS_S3_URL environment variables), attachments are stored and stay available to later turns. Without it, the model reads them only in the message that sends them. Limit their number and size in allow with request.files and request.bytes.
Security
- The agent acts with the permissions of the request:
beforeLoaddecides whether the document opens, and every tool call passesbeforeChangelike any other change. - The model only sees the document of the request and the attachments of the conversation.
- The model key stays on the server; the browser only talks to your server.