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.set and checked in allow.
  • 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
PDF 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: beforeLoad decides whether the document opens, and every tool call passes beforeChange like 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.